diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index 71c9ce53..cd5106da 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -263,41 +263,21 @@ function ExtensionSettingsSection({
if (field.type === "slider") {
return (
-
-
- {field.label}
-
-
- {
- extensionHost.setExtensionSetting(
- extensionId,
- field.id,
- parseFloat(e.target.value),
- );
- forceUpdate((n) => n + 1);
- }}
- className="w-20 h-1 accent-[#2563EB]"
- />
-
- {(typeof value === "number"
- ? value
- : (field.defaultValue as number)
- ).toFixed(1)}
-
-
+
+ {
+ extensionHost.setExtensionSetting(extensionId, field.id, v);
+ forceUpdate((n) => n + 1);
+ }}
+ formatValue={(v) => v.toFixed(1)}
+ parseInput={(text) => parseFloat(text)}
+ />
);
}
@@ -1215,9 +1195,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 =
@@ -1225,16 +1208,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,
@@ -1480,6 +1458,13 @@ export function SettingsPanel({
const resetBackgroundSection = () => {
onBackgroundBlurChange?.(initialEditorPreferences.backgroundBlur);
+ onWallpaperChange(
+ initialEditorPreferences.wallpaper ||
+ builtInWallpaperPaths[0] ||
+ extensionWallpaperPaths[0] ||
+ BUILT_IN_WALLPAPERS[0]?.publicPath ||
+ "",
+ );
};
const resetZoomSection = () => {
@@ -1558,7 +1543,11 @@ export function SettingsPanel({
};
const resetFrameSection = () => {
+ onShadowChange?.(initialEditorPreferences.shadowIntensity);
+ onBorderRadiusChange?.(initialEditorPreferences.borderRadius);
onAspectRatioChange?.(initialEditorPreferences.aspectRatio);
+ onPaddingChange?.({ ...initialEditorPreferences.padding });
+ onFrameChange?.(initialEditorPreferences.frame);
removeBackgroundStateRef.current = null;
};
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx
index 836af7d5..47ebf8a6 100644
--- a/src/components/video-editor/VideoPlayback.tsx
+++ b/src/components/video-editor/VideoPlayback.tsx
@@ -430,6 +430,14 @@ const VideoPlayback = forwardRef
(
const [pixiRendererBackend, setPixiRendererBackend] = useState(
null,
);
+ const [frameUpdateCounter, setFrameUpdateCounter] = useState(0);
+
+ useEffect(() => {
+ return extensionHost.onChange(() => {
+ setFrameUpdateCounter((c) => c + 1);
+ });
+ }, []);
+
const overlayRef = useRef(null);
const focusIndicatorRef = useRef(null);
const webcamVideoRef = useRef(null);
@@ -998,10 +1006,14 @@ const VideoPlayback = forwardRef(
const frameContainer = frameContainerRef.current;
if (!frameContainer) return;
- // Clear existing frame sprite
+ // 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 +1077,7 @@ const VideoPlayback = forwardRef(
return () => {
cancelled = true;
};
- }, [frame]);
+ }, [frame, frameUpdateCounter]);
const selectedZoom = useMemo(() => {
if (!selectedZoomId) return null;
diff --git a/src/lib/extensions/extensionHost.ts b/src/lib/extensions/extensionHost.ts
index 1a9147ab..9e9578f4 100644
--- a/src/lib/extensions/extensionHost.ts
+++ b/src/lib/extensions/extensionHost.ts
@@ -6,6 +6,7 @@
*/
import { createExtensionModuleUrl, resolveExtensionRelativeFileUrl } from "./fileUrls";
+import * as PhosphorIcons from "@phosphor-icons/react";
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> | null = null;
+ private persistTimeout: any = null;
+ private iconPathCache = new Map();
// Shared playback/project state — set by the app, queried by extensions
private _videoInfo: { width: number; height: number; durationMs: number; fps: number } | null =
@@ -468,6 +472,7 @@ export class ExtensionHost {
borderRadius: layout.borderRadius,
padding: normalizedPadding,
};
+ this.notifyListeners();
}
setZoomState(
@@ -478,6 +483,7 @@ export class ExtensionHost {
setShadowConfig(config: { enabled: boolean; intensity: number }): void {
this._shadowConfig = config;
+ this.notifyListeners();
}
setCursorTelemetry(
@@ -572,12 +578,20 @@ export class ExtensionHost {
}
}
+ private getFullSettingsStore(): Record> {
+ 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 +602,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 +611,14 @@ 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);
}
/**
@@ -957,6 +978,68 @@ 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 cacheKey = `${name}:${weight}`;
+ let path = host.iconPathCache.get(cacheKey);
+
+ if (!path) {
+ // 1. Get the Icon component from the project's library
+ const Icon = (PhosphorIcons as any)[name];
+ if (!Icon) return;
+
+ try {
+ // 2. Extract path data by dry-running the component
+ const Icon = (PhosphorIcons as any)[name];
+ if (!Icon) {
+ console.warn(`[extensions] Icon ${name} not found in Phosphor library`);
+ return;
+ }
+
+ const element = (Icon as any).render?.({ weight }, null);
+ const weights = element?.props?.weights;
+ const definition = weights?.get(weight);
+ const children = definition?.props?.children;
+
+ // Handle both single path and array of paths
+ let pathElement = children;
+ if (Array.isArray(children)) {
+ pathElement = children.find((c: any) => c?.type === 'path' || c?.props?.d);
+ }
+
+ const pathData = pathElement?.props?.d;
+
+ if (pathData) {
+ path = new Path2D(pathData);
+ host.iconPathCache.set(cacheKey, path);
+ } else {
+ console.warn(`[extensions] No path data found for ${name}:${weight}`, { element, children });
+ }
+ } catch (err) {
+ console.error(`[extensions] Failed to extract path for icon ${name}:`, err);
+ return;
+ }
+ }
+
+ 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());
diff --git a/src/lib/extensions/types.ts b/src/lib/extensions/types.ts
index a77e58a6..4b880d8c 100644
--- a/src/lib/extensions/types.ts
+++ b/src/lib/extensions/types.ts
@@ -565,6 +565,26 @@ export interface RecordlyExtensionAPI {
* Useful for bulk-reading initial state on activation.
*/
getAllSettings(): Record;
+
+ /**
+ * 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;
}
// ---------------------------------------------------------------------------