Merge pull request #459 from ExtraBinoss/feat/settings-panel-auto-props-extension-ui

add: drawIcons for extensions, SettingsPanel for extension fixed.
This commit is contained in:
ExtraBinoss
2026-05-09 22:34:12 +02:00
committed by GitHub
7 changed files with 467 additions and 52 deletions
+19
View File
@@ -230,8 +230,27 @@ api.getActiveFrame();
api.isExtensionActive(extensionId);
api.getPlaybackState();
api.getCanvasDimensions();
api.drawIcon(ctx, "Sparkle", 100, 100, 20, "#2563EB", "regular");
```
### Drawing Icons
Extensions can draw icons from Recordly's bundled Phosphor icon set directly on a canvas context:
```js
api.drawIcon(
ctx,
"ArrowClockwise", // icon name from @phosphor-icons/react
120, // x (center)
80, // y (center)
18, // size in px
"#ffffff", // color
"bold", // optional weight: thin | light | regular | bold | fill
);
```
This is useful for lightweight overlays and avoids bundling your own icon assets.
## Settings Panels
```js
+59 -44
View File
@@ -110,6 +110,15 @@ const tahoeCursorUrl = cursorSetAssets.tahoe.arrow.url;
const BUILTIN_CURSOR_PREVIEW_SIZE = 28;
const BUILTIN_CURSOR_PREVIEW_FRAME_SIZE = 48;
function getStepPrecision(step: number): number {
if (!Number.isFinite(step) || step <= 0) return 0;
const [mantissa = "0", exponentPart = "0"] = step.toExponential().split("e");
const exponent = Number.parseInt(exponentPart, 10);
const mantissaDecimals = (mantissa.split(".")[1] ?? "").replace(/0+$/, "").length;
const precision = exponent < 0 ? Math.max(0, -exponent + mantissaDecimals) : mantissaDecimals;
return Math.min(12, precision);
}
const GRADIENTS = [
"linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )",
"linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)",
@@ -262,42 +271,24 @@ function ExtensionSettingsSection({
}
if (field.type === "slider") {
const step = field.step ?? 0.01;
const precision = getStepPrecision(step);
return (
<div
key={field.id}
className="flex items-center justify-between gap-2 rounded-lg bg-foreground/[0.03] px-2.5 py-1.5"
>
<span className="text-[11px] text-muted-foreground flex-shrink-0">
{field.label}
</span>
<div className="flex items-center gap-1.5">
<input
type="range"
min={field.min ?? 0}
max={field.max ?? 1}
step={field.step ?? 0.01}
value={
typeof value === "number"
? value
: (field.defaultValue as number)
}
onChange={(e) => {
extensionHost.setExtensionSetting(
extensionId,
field.id,
parseFloat(e.target.value),
);
forceUpdate((n) => n + 1);
}}
className="w-20 h-1 accent-[#2563EB]"
/>
<span className="text-[10px] text-muted-foreground/70 w-8 text-right font-mono">
{(typeof value === "number"
? value
: (field.defaultValue as number)
).toFixed(1)}
</span>
</div>
<div key={field.id} className="mt-1">
<SliderControl
label={field.label}
value={typeof value === "number" ? value : (field.defaultValue as number)}
defaultValue={field.defaultValue as number}
min={field.min ?? 0}
max={field.max ?? 1}
step={step}
onChange={(v) => {
extensionHost.setExtensionSetting(extensionId, field.id, v);
forceUpdate((n) => n + 1);
}}
formatValue={(v) => v.toFixed(precision)}
parseInput={(text) => parseFloat(text)}
/>
</div>
);
}
@@ -1233,9 +1224,12 @@ export function SettingsPanel({
if (GRADIENTS.includes(selected)) {
setGradient(selected);
}
}, [selected]);
if (selected.startsWith("data:image") && !customImages.includes(selected)) {
setCustomImages((prev) => [selected, ...prev]);
useEffect(() => {
if (selected.startsWith("data:image")) {
setCustomImages((prev) => (prev.includes(selected) ? prev : [selected, ...prev]));
return;
}
const isKnownWallpaper =
@@ -1243,16 +1237,11 @@ export function SettingsPanel({
wallpaperPreviewPaths.includes(selected) ||
extensionWallpaperPaths.includes(selected);
if (
!isKnownWallpaper &&
isVideoWallpaperSource(selected) &&
!customImages.includes(selected)
) {
setCustomImages((prev) => [selected, ...prev]);
if (!isKnownWallpaper && isVideoWallpaperSource(selected)) {
setCustomImages((prev) => (prev.includes(selected) ? prev : [selected, ...prev]));
}
}, [
builtInWallpaperPaths,
customImages,
extensionWallpaperPaths,
selected,
wallpaperPreviewPaths,
@@ -1498,6 +1487,22 @@ export function SettingsPanel({
const resetBackgroundSection = () => {
onBackgroundBlurChange?.(initialEditorPreferences.backgroundBlur);
const preferredWallpaper = initialEditorPreferences.wallpaper;
const hasPreferredWallpaper =
(preferredWallpaper && builtInWallpaperPaths.includes(preferredWallpaper)) ||
(preferredWallpaper && extensionWallpaperPaths.includes(preferredWallpaper)) ||
(preferredWallpaper && customImages.includes(preferredWallpaper)) ||
(preferredWallpaper && isHexWallpaper(preferredWallpaper)) ||
(preferredWallpaper && GRADIENTS.includes(preferredWallpaper));
onWallpaperChange(
(hasPreferredWallpaper ? preferredWallpaper : "") ||
builtInWallpaperPaths[0] ||
extensionWallpaperPaths[0] ||
BUILT_IN_WALLPAPERS[0]?.publicPath ||
"",
);
};
const resetZoomSection = () => {
@@ -1576,7 +1581,17 @@ export function SettingsPanel({
};
const resetFrameSection = () => {
const preferredFrame = initialEditorPreferences.frame;
const resolvedFrame = preferredFrame
? availableFrames.some((candidate) => candidate.id === preferredFrame)
? preferredFrame
: null
: null;
onShadowChange?.(initialEditorPreferences.shadowIntensity);
onBorderRadiusChange?.(initialEditorPreferences.borderRadius);
onAspectRatioChange?.(initialEditorPreferences.aspectRatio);
onPaddingChange?.({ ...initialEditorPreferences.padding });
onFrameChange?.(resolvedFrame);
removeBackgroundStateRef.current = null;
};
+13 -1
View File
@@ -1642,17 +1642,28 @@ export default function VideoEditor() {
// Extension-contributed standalone section pages (no parentSection)
const [extensionSectionButtons, setExtensionSectionButtons] = useState<
{ id: EditorEffectSection; label: string; icon: typeof PhPuzzle | string }[]
{
id: EditorEffectSection;
label: string;
icon: typeof PhPuzzle | string;
extensionPath?: string | null;
}[]
>([]);
useEffect(() => {
const update = () => {
const panels = extensionHost.getSettingsPanels();
const extensionPathById = new Map(
extensionHost
.getActiveExtensions()
.map((extension) => [extension.manifest.id, extension.path]),
);
const standalone = panels
.filter((p) => !p.panel.parentSection)
.map((p) => ({
id: `ext:${p.extensionId}/${p.panel.id}` as EditorEffectSection,
label: p.panel.label,
icon: p.panel.icon || (PhPuzzle as typeof PhPuzzle | string),
extensionPath: extensionPathById.get(p.extensionId),
}));
setExtensionSectionButtons(standalone);
};
@@ -5681,6 +5692,7 @@ export default function VideoEditor() {
{typeof section.icon === "string" ? (
<ExtensionIcon
icon={section.icon}
extensionPath={section.extensionPath}
className="h-[27px] w-[27px]"
/>
) : (
+84 -4
View File
@@ -74,6 +74,44 @@ function getContributedCursorStylesSignature() {
.join("|");
}
function getRegisteredFramesSignature() {
return extensionHost
.getFrames()
.map(
(frame) =>
`${frame.id}:${frame.filePath}:${frame.thumbnailPath}:${frame.appearance ?? ""}`,
)
.sort()
.join("|");
}
function serializeExtensionSettingValue(value: unknown): string {
try {
const serialized = JSON.stringify(value);
return serialized ?? "undefined";
} catch {
try {
return String(value);
} catch {
return "[unserializable]";
}
}
}
function getExtensionSettingsSignature() {
return extensionHost
.getSettingsPanels()
.flatMap((registeredPanel) => {
const { extensionId, panel } = registeredPanel;
return panel.fields.map((field) => {
const value = extensionHost.getExtensionSetting(extensionId, field.id);
return `${extensionId}:${panel.id}:${field.id}:${serializeExtensionSettingValue(value)}`;
});
})
.sort()
.join("|");
}
import { extensionHost } from "@/lib/extensions";
import {
mapCursorToCanvasNormalized,
@@ -430,6 +468,26 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const [pixiRendererBackend, setPixiRendererBackend] = useState<PixiPreviewBackend | null>(
null,
);
const [frameUpdateCounter, setFrameUpdateCounter] = useState(0);
useEffect(() => {
let framesSignature = getRegisteredFramesSignature();
let settingsSignature = getExtensionSettingsSignature();
return extensionHost.onChange(() => {
const nextFramesSignature = getRegisteredFramesSignature();
const nextSettingsSignature = getExtensionSettingsSignature();
if (
nextFramesSignature === framesSignature &&
nextSettingsSignature === settingsSignature
) {
return;
}
framesSignature = nextFramesSignature;
settingsSignature = nextSettingsSignature;
setFrameUpdateCounter((c) => c + 1);
});
}, []);
const overlayRef = useRef<HTMLDivElement | null>(null);
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
const webcamVideoRef = useRef<HTMLVideoElement | null>(null);
@@ -466,6 +524,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const frameSpriteRef = useRef<Sprite | null>(null);
const frameContainerRef = useRef<Container | null>(null);
const frameIdRef = useRef<string | null>(frame);
const frameReloadKeyRef = useRef<string | null>(null);
const isPlayingRef = useRef(isPlaying);
const suspendRenderingRef = useRef(suspendRendering);
const isSeekingRef = useRef(false);
@@ -997,11 +1056,27 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
useEffect(() => {
const frameContainer = frameContainerRef.current;
if (!frameContainer) return;
const nextFrameReloadKey = `${frame ?? ""}:${frameUpdateCounter}`;
const activeFrameData = frame
? extensionHost.getFrames().find((registeredFrame) => registeredFrame.id === frame)
: null;
const shouldRedrawDynamicFrame = Boolean(activeFrameData?.draw && frameSpriteRef.current);
// Clear existing frame sprite
// Layout-only changes should not force texture/sprite recreation.
if (frameReloadKeyRef.current === nextFrameReloadKey && !shouldRedrawDynamicFrame) {
layoutVideoContentRef.current?.();
return;
}
frameReloadKeyRef.current = nextFrameReloadKey;
// Clear existing frame sprite and its texture to free memory
if (frameSpriteRef.current) {
frameContainer.removeChild(frameSpriteRef.current);
frameSpriteRef.current.destroy();
const sprite = frameSpriteRef.current;
frameContainer.removeChild(sprite);
if (sprite.texture) {
sprite.texture.destroy(true); // destroy texture and its baseTexture
}
sprite.destroy();
frameSpriteRef.current = null;
}
@@ -1065,7 +1140,12 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
return () => {
cancelled = true;
};
}, [frame]);
}, [aspectRatio, borderRadius, cropRegion, frame, frameUpdateCounter, padding]);
// Always re-run geometric layout when layout props change, even if frame sprite isn't reloaded.
useEffect(() => {
layoutVideoContentRef.current?.();
}, [aspectRatio, borderRadius, cropRegion, padding]);
const selectedZoom = useMemo(() => {
if (!selectedZoomId) return null;
+63 -3
View File
@@ -6,6 +6,7 @@
*/
import { createExtensionModuleUrl, resolveExtensionRelativeFileUrl } from "./fileUrls";
import { resolveIconPath } from "./iconDraw";
import type {
ContributedCursorStyle,
ContributedFrame,
@@ -143,6 +144,9 @@ export class ExtensionHost {
Set<(settingId: string, value: unknown) => void>
>();
private listeners = new Set<() => void>();
private fullSettingsStore: Record<string, Record<string, unknown>> | null = null;
private persistTimeout: ReturnType<typeof setTimeout> | null = null;
private iconPathCache = new Map<string, Path2D>();
// Shared playback/project state — set by the app, queried by extensions
private _videoInfo: { width: number; height: number; durationMs: number; fps: number } | null =
@@ -181,6 +185,14 @@ export class ExtensionHost {
isPlaying: boolean;
} | null = null;
constructor() {
if (typeof window !== "undefined") {
window.addEventListener("beforeunload", () => {
this.flushPersistedSettings();
});
}
}
/**
* Activate an extension given its info and resolved module URL.
*/
@@ -270,6 +282,7 @@ export class ExtensionHost {
}
this.activeExtensions.delete(extensionId);
this.flushPersistedSettings();
this.notifyListeners();
console.log(`[extensions] Deactivated: ${extensionId}`);
}
@@ -282,6 +295,7 @@ export class ExtensionHost {
for (const id of ids) {
await this.deactivateExtension(id);
}
this.flushPersistedSettings();
}
// ---------------------------------------------------------------------------
@@ -572,12 +586,20 @@ export class ExtensionHost {
}
}
private getFullSettingsStore(): Record<string, Record<string, unknown>> {
if (this.fullSettingsStore) {
return this.fullSettingsStore;
}
this.fullSettingsStore = this.readPersistedSettingsStore();
return this.fullSettingsStore;
}
private ensureExtensionSettingsLoaded(extensionId: string): void {
if (this.extensionSettings.has(extensionId)) {
return;
}
const store = this.readPersistedSettingsStore();
const store = this.getFullSettingsStore();
const persisted = store[extensionId];
const normalized =
persisted && typeof persisted === "object" && !Array.isArray(persisted)
@@ -588,7 +610,7 @@ export class ExtensionHost {
}
private persistExtensionSettings(extensionId: string): void {
const store = this.readPersistedSettingsStore();
const store = this.getFullSettingsStore();
const settings = this.extensionSettings.get(extensionId) ?? {};
if (Object.keys(settings).length === 0) {
@@ -597,7 +619,22 @@ export class ExtensionHost {
store[extensionId] = { ...settings };
}
this.writePersistedSettingsStore(store);
// Debounce the actual write to localStorage to avoid blocking the UI thread during rapid changes
if (this.persistTimeout) {
clearTimeout(this.persistTimeout);
}
this.persistTimeout = setTimeout(() => {
this.writePersistedSettingsStore(store);
this.persistTimeout = null;
}, 500);
}
private flushPersistedSettings(): void {
if (this.persistTimeout) {
clearTimeout(this.persistTimeout);
this.persistTimeout = null;
}
this.writePersistedSettingsStore(this.getFullSettingsStore());
}
/**
@@ -957,6 +994,29 @@ export class ExtensionHost {
};
},
drawIcon(
ctx: CanvasRenderingContext2D,
name: string,
x: number,
y: number,
size: number,
color: string,
weight: "thin" | "light" | "regular" | "bold" | "fill" = "regular",
): void {
const path = resolveIconPath(name, weight, host.iconPathCache);
if (path) {
ctx.save();
ctx.translate(x, y);
const scale = size / 256; // Phosphor icons use a 256x256 grid
ctx.scale(scale, scale);
ctx.translate(-128, -128); // Center the icon
ctx.fillStyle = color;
ctx.fill(path);
ctx.restore();
}
},
onSettingChange(callback: (settingId: string, value: unknown) => void): () => void {
if (!host.settingChangeCallbacks.has(extensionId)) {
host.settingChangeCallbacks.set(extensionId, new Set());
+209
View File
@@ -0,0 +1,209 @@
import * as PhosphorIcons from "@phosphor-icons/react";
type IconWeight = "thin" | "light" | "regular" | "bold" | "fill";
const missingIconPathCache = new Set<string>();
function resolveIconComponent(name: string): {
iconName: string;
icon: { render?: (props: { weight: string }, ref: unknown) => unknown };
} | null {
const iconLibrary = PhosphorIcons as Record<string, unknown>;
const iconName =
typeof iconLibrary[name] !== "undefined"
? name
: typeof iconLibrary[`${name}Icon`] !== "undefined"
? `${name}Icon`
: null;
if (!iconName) {
return null;
}
return {
iconName,
icon: iconLibrary[iconName] as {
render?: (props: { weight: string }, ref: unknown) => unknown;
},
};
}
function toNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
return null;
}
function parsePointList(value: unknown): Array<{ x: number; y: number }> {
if (typeof value !== "string") return [];
const nums = value
.trim()
.split(/[\s,]+/)
.map((part) => Number(part))
.filter((n) => Number.isFinite(n));
const points: Array<{ x: number; y: number }> = [];
for (let i = 0; i + 1 < nums.length; i += 2) {
points.push({ x: nums[i], y: nums[i + 1] });
}
return points;
}
function collectPathData(node: unknown, output: Path2D): number {
if (!node) return 0;
if (Array.isArray(node)) {
let added = 0;
for (const child of node) added += collectPathData(child, output);
return added;
}
if (typeof node !== "object") return 0;
const maybeNode = node as {
type?: unknown;
props?: {
d?: unknown;
children?: unknown;
x1?: unknown;
y1?: unknown;
x2?: unknown;
y2?: unknown;
points?: unknown;
cx?: unknown;
cy?: unknown;
r?: unknown;
rx?: unknown;
ry?: unknown;
x?: unknown;
y?: unknown;
width?: unknown;
height?: unknown;
};
};
const props = maybeNode.props;
let added = 0;
if (maybeNode.type === "path" && typeof props?.d === "string") {
output.addPath(new Path2D(props.d));
added += 1;
} else if (maybeNode.type === "line") {
const x1 = toNumber(props?.x1);
const y1 = toNumber(props?.y1);
const x2 = toNumber(props?.x2);
const y2 = toNumber(props?.y2);
if (x1 !== null && y1 !== null && x2 !== null && y2 !== null) {
output.moveTo(x1, y1);
output.lineTo(x2, y2);
added += 1;
}
} else if (maybeNode.type === "polyline" || maybeNode.type === "polygon") {
const points = parsePointList(props?.points);
if (points.length > 0) {
output.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i += 1) {
output.lineTo(points[i].x, points[i].y);
}
if (maybeNode.type === "polygon") {
output.closePath();
}
added += 1;
}
} else if (maybeNode.type === "circle") {
const cx = toNumber(props?.cx);
const cy = toNumber(props?.cy);
const r = toNumber(props?.r);
if (cx !== null && cy !== null && r !== null) {
output.arc(cx, cy, r, 0, Math.PI * 2);
added += 1;
}
} else if (maybeNode.type === "ellipse") {
const cx = toNumber(props?.cx);
const cy = toNumber(props?.cy);
const rx = toNumber(props?.rx);
const ry = toNumber(props?.ry);
if (cx !== null && cy !== null && rx !== null && ry !== null) {
output.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
added += 1;
}
} else if (maybeNode.type === "rect") {
const x = toNumber(props?.x) ?? 0;
const y = toNumber(props?.y) ?? 0;
const width = toNumber(props?.width);
const height = toNumber(props?.height);
const rxRaw = toNumber(props?.rx);
const ryRaw = toNumber(props?.ry);
if (width !== null && height !== null) {
const resolvedRxRaw = rxRaw ?? ryRaw ?? 0;
const resolvedRyRaw = ryRaw ?? rxRaw ?? 0;
const rx = Math.max(0, Math.min(resolvedRxRaw, width / 2));
const ry = Math.max(0, Math.min(resolvedRyRaw, height / 2));
if (rx === 0 && ry === 0) {
output.rect(x, y, width, height);
} else {
const right = x + width;
const bottom = y + height;
output.moveTo(x + rx, y);
output.lineTo(right - rx, y);
output.ellipse(right - rx, y + ry, rx, ry, 0, -Math.PI / 2, 0);
output.lineTo(right, bottom - ry);
output.ellipse(right - rx, bottom - ry, rx, ry, 0, 0, Math.PI / 2);
output.lineTo(x + rx, bottom);
output.ellipse(x + rx, bottom - ry, rx, ry, 0, Math.PI / 2, Math.PI);
output.lineTo(x, y + ry);
output.ellipse(x + rx, y + ry, rx, ry, 0, Math.PI, (3 * Math.PI) / 2);
output.closePath();
}
added += 1;
}
}
added += collectPathData(props?.children, output);
return added;
}
export function resolveIconPath(
name: string,
weight: IconWeight,
cache: Map<string, Path2D>,
): Path2D | null {
const cacheKey = `${name}:${weight}`;
if (missingIconPathCache.has(cacheKey)) {
return null;
}
const cached = cache.get(cacheKey);
if (cached) {
return cached;
}
const resolved = resolveIconComponent(name);
if (!resolved) {
console.warn(`[extensions] Icon ${name} not found in Phosphor library`);
missingIconPathCache.add(cacheKey);
return null;
}
try {
const element = resolved.icon.render?.({ weight }, null) as
| { props?: { weights?: Map<string, { props?: { children?: unknown } }> } }
| undefined;
const weights = element?.props?.weights;
const definition = weights?.get(weight);
const children = definition?.props?.children;
const combinedPath = new Path2D();
const shapeCount = collectPathData(children, combinedPath);
if (shapeCount === 0) {
console.warn(`[extensions] No path data found for ${name}:${weight}`, {
iconName: resolved.iconName,
element,
children,
});
missingIconPathCache.add(cacheKey);
return null;
}
cache.set(cacheKey, combinedPath);
return combinedPath;
} catch (err) {
console.error(`[extensions] Failed to extract path for icon ${name}:`, err);
missingIconPathCache.add(cacheKey);
return null;
}
}
+20
View File
@@ -565,6 +565,26 @@ export interface RecordlyExtensionAPI {
* Useful for bulk-reading initial state on activation.
*/
getAllSettings(): Record<string, unknown>;
/**
* Draw a Phosphor icon from the project's library onto the canvas.
* @param ctx The 2D rendering context
* @param name The name of the icon (e.g. "CaretLeft", "ArrowClockwise")
* @param x X coordinate (center)
* @param y Y coordinate (center)
* @param size Icon size in pixels
* @param color Icon color (CSS color string)
* @param weight Icon weight (optional: 'thin' | 'light' | 'regular' | 'bold' | 'fill', default 'regular')
*/
drawIcon(
ctx: CanvasRenderingContext2D,
name: string,
x: number,
y: number,
size: number,
color: string,
weight?: "thin" | "light" | "regular" | "bold" | "fill",
): void;
}
// ---------------------------------------------------------------------------