From 34ead66e2a55e22bef5e317ec9d764532dde523f Mon Sep 17 00:00:00 2001
From: webadderall <131426131+webadderall@users.noreply.github.com>
Date: Sat, 11 Apr 2026 18:41:38 +1000
Subject: [PATCH] feat(extensions): integrate extension hooks into editor
playback and settings
- Wire render hooks and cursor effects into VideoPlayback canvas pipeline
- Add extension settings panels to SettingsPanel
- Persist extension-related editor preferences and project state
---
src/components/video-editor/SettingsPanel.tsx | 239 +++++++++-
src/components/video-editor/VideoPlayback.tsx | 426 +++++++++++++++++-
.../video-editor/editorPreferences.ts | 4 +
.../video-editor/projectPersistence.ts | 3 +
4 files changed, 664 insertions(+), 8 deletions(-)
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index fe879c31..8be273f6 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -14,6 +14,8 @@ import { Switch } from "@/components/ui/switch";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath";
import { cn } from "@/lib/utils";
+import { extensionHost, type FrameInstance } from "@/lib/extensions";
+import type { ExtensionSettingField } from "@/lib/extensions";
import type { BuiltInWallpaper } from "@/lib/wallpapers";
import { BUILT_IN_WALLPAPERS, getAvailableWallpapers, isVideoWallpaperSource } from "@/lib/wallpapers";
import { type AspectRatio } from "@/utils/aspectRatioUtils";
@@ -114,7 +116,9 @@ export type EditorEffectSection =
| "webcam"
| "zoom"
| "frame"
- | "crop";
+ | "crop"
+ | "extensions"
+ | `ext:${string}`;
function isHexWallpaper(value: string): boolean {
return /^#(?:[0-9a-f]{3}){1,2}$/i.test(value);
@@ -142,6 +146,129 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
);
}
+/**
+ * Renders extension-contributed settings fields (toggle, slider, select, color, text).
+ */
+function ExtensionSettingsSection({ extensionId, label, fields }: {
+ extensionId: string;
+ label: string;
+ fields: ExtensionSettingField[];
+}) {
+ const [, forceUpdate] = useState(0);
+
+ return (
+
+
{label}
+ {fields.map((field) => {
+ const value = extensionHost.getExtensionSetting(extensionId, field.id) ?? field.defaultValue;
+
+ if (field.type === 'toggle') {
+ return (
+
+ {field.label}
+ {
+ extensionHost.setExtensionSetting(extensionId, field.id, checked);
+ forceUpdate(n => n + 1);
+ }}
+ className="data-[state=checked]:bg-[#2563EB] scale-75"
+ />
+
+ );
+ }
+
+ 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 : 0).toFixed(1)}
+
+
+
+ );
+ }
+
+ if (field.type === 'select' && field.options) {
+ return (
+
+ {field.label}
+
+
+ );
+ }
+
+ if (field.type === 'color') {
+ return (
+
+ {field.label}
+ {
+ extensionHost.setExtensionSetting(extensionId, field.id, e.target.value);
+ forceUpdate(n => n + 1);
+ }}
+ className="w-7 h-5 rounded border border-white/10 cursor-pointer bg-transparent"
+ />
+
+ );
+ }
+
+ if (field.type === 'text') {
+ return (
+
+ {field.label}
+ {
+ extensionHost.setExtensionSetting(extensionId, field.id, e.target.value);
+ forceUpdate(n => n + 1);
+ }}
+ className="w-24 h-6 rounded bg-white/[0.06] border border-white/10 px-1.5 text-[10px] text-slate-200"
+ />
+
+ );
+ }
+
+ return null;
+ })}
+
+ );
+}
+
interface SettingsPanelProps {
panelMode?: "editor" | "background";
activeEffectSection?: EditorEffectSection;
@@ -213,6 +340,8 @@ interface SettingsPanelProps {
onClearWebcam?: () => void;
padding?: number;
onPaddingChange?: (padding: number) => void;
+ frame?: string | null;
+ onFrameChange?: (frameId: string | null) => void;
cropRegion?: CropRegion;
onCropChange?: (region: CropRegion) => void;
aspectRatio: AspectRatio;
@@ -562,6 +691,8 @@ export function SettingsPanel({
onClearWebcam,
padding = 50,
onPaddingChange,
+ frame = null,
+ onFrameChange,
cropRegion,
onCropChange,
aspectRatio,
@@ -670,6 +801,38 @@ export function SettingsPanel({
GRADIENTS.includes(selected) ? selected : GRADIENTS[0],
);
const removeBackgroundEnabled = aspectRatio === "native" && padding === 0;
+
+ // Device frames from extension system
+ const [availableFrames, setAvailableFrames] = useState([]);
+ useEffect(() => {
+ const update = () => setAvailableFrames(extensionHost.getFrames());
+ update();
+ return extensionHost.onChange(update);
+ }, []);
+
+ // Extension-contributed settings panels
+ const [extensionPanels, setExtensionPanels] = useState>([]);
+ useEffect(() => {
+ const update = () => setExtensionPanels(extensionHost.getSettingsPanels());
+ update();
+ return extensionHost.onChange(update);
+ }, []);
+
+ const renderExtensionPanelsForSections = (...sections: string[]) =>
+ extensionPanels
+ .filter((panel) => {
+ const parentSection = panel.panel.parentSection;
+ return parentSection ? sections.includes(parentSection) : false;
+ })
+ .map((panel) => (
+
+ ));
+
const [backgroundTab, setBackgroundTab] = useState(() =>
getBackgroundTabForWallpaper(selected),
);
@@ -932,6 +1095,7 @@ export function SettingsPanel({
onShadowChange?.(initialEditorPreferences.shadowIntensity);
onBorderRadiusChange?.(initialEditorPreferences.borderRadius);
onPaddingChange?.(initialEditorPreferences.padding);
+ onFrameChange?.(null);
onAspectRatioChange?.(initialEditorPreferences.aspectRatio);
removeBackgroundStateRef.current = null;
};
@@ -1443,6 +1607,53 @@ export function SettingsPanel({
className="data-[state=checked]:bg-[#2563EB] scale-75"
/>
+ {/* Frame Picker */}
+ {availableFrames.length > 0 && (
+
+
+ Frame
+ {frame && (
+
+ )}
+
+
+ {availableFrames.map((f) => {
+ const isSelected = frame === f.id;
+ return (
+
+ );
+ })}
+
+
+ )}
);
@@ -1744,6 +1955,7 @@ export function SettingsPanel({
formatValue={(value) => `${Math.round(value * 100)}%`}
parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100}
/>
+ {renderExtensionPanelsForSections("captions")}
);
@@ -1755,6 +1967,7 @@ export function SettingsPanel({
{zoomSectionContent}
{frameSectionContent}
{cropSectionContent}
+ {renderExtensionPanelsForSections("scene", "appearance", "zoom", "frame", "crop")}
);
@@ -1909,6 +2122,7 @@ export function SettingsPanel({
}}
/>
+ {renderExtensionPanelsForSections("cursor")}
);
case "webcam":
@@ -2088,9 +2302,32 @@ export function SettingsPanel({
+ {renderExtensionPanelsForSections("webcam")}
);
+ default: {
+ // Handle extension-contributed standalone section pages (ext:extensionId/panelId)
+ if (activeEffectSection?.startsWith('ext:')) {
+ const panels = extensionPanels.filter(
+ p => !p.panel.parentSection && `ext:${p.extensionId}/${p.panel.id}` === activeEffectSection,
+ );
+ if (panels.length > 0) {
+ const p = panels[0];
+ return (
+
+ );
+ }
+ }
+ return sceneSectionContent;
+ }
}
})();
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx
index 00c50c04..98ef0889 100644
--- a/src/components/video-editor/VideoPlayback.tsx
+++ b/src/components/video-editor/VideoPlayback.tsx
@@ -92,6 +92,18 @@ import {
formatAspectRatioForCSS,
} from "@/utils/aspectRatioUtils";
import { AnnotationOverlay } from "./AnnotationOverlay";
+import { extensionHost } from "@/lib/extensions";
+import {
+ mapCursorToCanvasNormalized,
+ mapSmoothedCursorToCanvasNormalized,
+} from "@/lib/extensions/cursorCoordinates";
+import { applyCanvasSceneTransform } from "@/lib/extensions/sceneTransform";
+import {
+ notifyCursorInteraction,
+ executeExtensionCursorEffects,
+ executeExtensionRenderHooks,
+ clearCursorEffects,
+} from "@/lib/extensions/renderHooks";
import {
DEFAULT_CURSOR_CLICK_BOUNCE,
DEFAULT_CURSOR_CLICK_BOUNCE_DURATION,
@@ -138,6 +150,41 @@ function createPlaybackAnimationState(): PlaybackAnimationState {
};
}
+function getCursorPositionAtTime(
+ telemetry: CursorTelemetryPoint[],
+ timeMs: number,
+ params?: {
+ maskRect?: { x: number; y: number; width: number; height: number } | null;
+ canvasWidth: number;
+ canvasHeight: number;
+ },
+): { cx: number; cy: number; interactionType?: string } | null {
+ if (telemetry.length === 0) {
+ return null;
+ }
+
+ let closest = telemetry[0];
+ let minDist = Math.abs(telemetry[0].timeMs - timeMs);
+
+ for (let index = 1; index < telemetry.length; index++) {
+ const point = telemetry[index];
+ const distance = Math.abs(point.timeMs - timeMs);
+ if (distance < minDist) {
+ minDist = distance;
+ closest = point;
+ }
+ if (point.timeMs > timeMs) {
+ break;
+ }
+ }
+
+ return mapCursorToCanvasNormalized({
+ cx: closest.cx,
+ cy: closest.cy,
+ interactionType: closest.interactionType,
+ }, params ?? { canvasWidth: 1, canvasHeight: 1 });
+}
+
function getEffectiveNativeAspectRatio(
dimensions: { width: number; height: number } | null | undefined,
cropRegion?: import("./types").CropRegion,
@@ -187,6 +234,7 @@ interface VideoPlaybackProps {
connectedZoomEasing?: ZoomTransitionEasing;
borderRadius?: number;
padding?: number;
+ frame?: string | null;
cropRegion?: import("./types").CropRegion;
webcam?: WebcamOverlaySettings;
webcamVideoPath?: string | null;
@@ -262,6 +310,7 @@ const VideoPlayback = forwardRef(
connectedZoomEasing = DEFAULT_CONNECTED_ZOOM_EASING,
borderRadius = 0,
padding = 50,
+ frame = null,
cropRegion,
webcam,
webcamVideoPath,
@@ -333,6 +382,9 @@ const VideoPlayback = forwardRef(
}>({ x: 0, y: 0, width: 0, height: 0 });
const cropBoundsRef = useRef({ startX: 0, endX: 0, startY: 0, endY: 0 });
const maskGraphicsRef = useRef(null);
+ const frameSpriteRef = useRef(null);
+ const frameContainerRef = useRef(null);
+ const frameIdRef = useRef(frame);
const isPlayingRef = useRef(isPlaying);
const isSeekingRef = useRef(false);
const allowPlaybackRef = useRef(false);
@@ -357,6 +409,7 @@ const VideoPlayback = forwardRef(
const connectedZoomEasingRef = useRef(connectedZoomEasing);
const videoReadyRafRef = useRef(null);
const cursorOverlayRef = useRef(null);
+ const cursorEffectsCanvasRef = useRef(null);
const cursorTelemetryRef = useRef([]);
const showCursorRef = useRef(showCursor);
const cursorSizeRef = useRef(cursorSize);
@@ -366,6 +419,7 @@ const VideoPlayback = forwardRef(
const cursorClickBounceRef = useRef(cursorClickBounce);
const cursorClickBounceDurationRef = useRef(cursorClickBounceDuration);
const cursorSwayRef = useRef(cursorSway);
+ const lastEmittedClickTimeMsRef = useRef(-1);
// Spring animation state for smooth zoom transitions
const springScaleRef = useRef(createSpringState(1));
@@ -567,6 +621,16 @@ const VideoPlayback = forwardRef(
};
}
+ // Look up device frame insets so layout centers the full frame (video + bezels)
+ let frameInsets: { top: number; right: number; bottom: number; left: number } | null = null;
+ if (frame) {
+ const frames = extensionHost.getFrames();
+ const frameData = frames.find((f) => f.id === frame);
+ if (frameData?.screenInsets) {
+ frameInsets = frameData.screenInsets;
+ }
+ }
+
const result = layoutVideoContentUtil({
container,
app,
@@ -577,6 +641,7 @@ const VideoPlayback = forwardRef(
lockedVideoDimensions: lockedVideoDimensionsRef.current,
borderRadius,
padding,
+ frameInsets,
});
if (result) {
@@ -587,6 +652,57 @@ const VideoPlayback = forwardRef(
baseMaskRef.current = result.maskRect;
cropBoundsRef.current = result.cropBounds;
+ // Sync extension cursor effects canvas resolution with renderer
+ const effectsCanvas = cursorEffectsCanvasRef.current;
+ if (effectsCanvas) {
+ const w = result.stageSize.width;
+ const h = result.stageSize.height;
+ if (effectsCanvas.width !== w || effectsCanvas.height !== h) {
+ effectsCanvas.width = w;
+ effectsCanvas.height = h;
+ }
+ }
+
+ // Push layout info to extension host for query APIs
+ extensionHost.setVideoLayout({
+ maskRect: { x: result.maskRect.x, y: result.maskRect.y, width: result.maskRect.width, height: result.maskRect.height },
+ canvasWidth: result.stageSize.width,
+ canvasHeight: result.stageSize.height,
+ borderRadius,
+ padding,
+ });
+ extensionHost.setShadowConfig({
+ enabled: Boolean(showShadow) && shadowIntensity > 0,
+ intensity: shadowIntensity,
+ });
+
+ // Position device frame sprite to fill the stage
+ const frameSprite = frameSpriteRef.current;
+ if (frameSprite && frame) {
+ const frames = extensionHost.getFrames();
+ const frameData = frames.find((f) => f.id === frame);
+ if (frameData) {
+ const maskRect = result.maskRect;
+ const insets = frameData.screenInsets;
+ if (insets) {
+ // Frame is larger than screen area — compute full frame size from insets
+ const screenW = maskRect.width;
+ const screenH = maskRect.height;
+ const frameW = screenW / (1 - insets.left - insets.right);
+ const frameH = screenH / (1 - insets.top - insets.bottom);
+ const frameX = maskRect.x - insets.left * frameW;
+ const frameY = maskRect.y - insets.top * frameH;
+ frameSprite.position.set(frameX, frameY);
+ frameSprite.width = frameW;
+ frameSprite.height = frameH;
+ } else {
+ frameSprite.position.set(maskRect.x, maskRect.y);
+ frameSprite.width = maskRect.width;
+ frameSprite.height = maskRect.height;
+ }
+ }
+ }
+
// Reset camera container to identity
cameraContainer.scale.set(1);
cameraContainer.position.set(0, 0);
@@ -601,7 +717,7 @@ const VideoPlayback = forwardRef(
updateOverlayForRegion(activeRegion);
applyWebcamBubbleLayout(animationStateRef.current.appliedScale || 1);
}
- }, [updateOverlayForRegion, cropRegion, borderRadius, padding, applyWebcamBubbleLayout]);
+ }, [updateOverlayForRegion, cropRegion, borderRadius, padding, frame, showShadow, shadowIntensity, applyWebcamBubbleLayout]);
useEffect(() => {
const video = videoRef.current;
@@ -616,6 +732,81 @@ const VideoPlayback = forwardRef(
layoutVideoContentRef.current = layoutVideoContent;
}, [layoutVideoContent]);
+ // Sync device frame ref
+ useEffect(() => {
+ frameIdRef.current = frame;
+ extensionHost.setActiveFrame(frame ?? null);
+ }, [frame]);
+
+ // Manage device frame sprite
+ useEffect(() => {
+ const frameContainer = frameContainerRef.current;
+ if (!frameContainer) return;
+
+ // Clear existing frame sprite
+ if (frameSpriteRef.current) {
+ frameContainer.removeChild(frameSpriteRef.current);
+ frameSpriteRef.current.destroy();
+ frameSpriteRef.current = null;
+ }
+
+ if (!frame) {
+ layoutVideoContentRef.current?.();
+ return;
+ }
+
+ let cancelled = false;
+
+ function tryLoadFrame() {
+ if (cancelled) return;
+ const container = frameContainerRef.current;
+ if (!container) return false;
+ const frames = extensionHost.getFrames();
+ const frameData = frames.find((f) => f.id === frame);
+ if (!frameData) return false;
+
+ if (frameData.draw) {
+ // Resolution-independent: draw at a reasonable size, Pixi handles the rest
+ const drawW = 1920;
+ const drawH = 1080;
+ const canvas = document.createElement('canvas');
+ canvas.width = drawW;
+ canvas.height = drawH;
+ const ctx = canvas.getContext('2d');
+ if (ctx) frameData.draw(ctx, drawW, drawH);
+ if (cancelled || frameIdRef.current !== frame) return true;
+ const texture = Texture.from(canvas);
+ const sprite = new Sprite(texture);
+ frameSpriteRef.current = sprite;
+ container.addChild(sprite);
+ layoutVideoContentRef.current?.();
+ } else {
+ const img = new Image();
+ img.onload = () => {
+ if (cancelled || frameIdRef.current !== frame) return;
+ const texture = Texture.from(img);
+ const sprite = new Sprite(texture);
+ frameSpriteRef.current = sprite;
+ container.addChild(sprite);
+ layoutVideoContentRef.current?.();
+ };
+ img.src = frameData.filePath;
+ }
+ return true;
+ }
+
+ // Try immediately; if extension hasn't registered frames yet,
+ // listen for changes and retry once they become available.
+ if (!tryLoadFrame()) {
+ const unsub = extensionHost.onChange(() => {
+ if (tryLoadFrame()) unsub();
+ });
+ return () => { cancelled = true; unsub(); };
+ }
+
+ return () => { cancelled = true; };
+ }, [frame]);
+
const selectedZoom = useMemo(() => {
if (!selectedZoomId) return null;
return zoomRegions.find((region) => region.id === selectedZoomId) ?? null;
@@ -778,6 +969,7 @@ const VideoPlayback = forwardRef(
useEffect(() => {
isPlayingRef.current = isPlaying;
+ extensionHost.emitEvent({ type: isPlaying ? 'playback:play' : 'playback:pause', timeMs: currentTimeRef.current });
// Snap springs to current position when pausing so scrubbing is instant
if (!isPlaying) {
resetSpringState(springScaleRef.current);
@@ -855,6 +1047,16 @@ const VideoPlayback = forwardRef(
useEffect(() => {
cursorTelemetryRef.current = cursorTelemetry;
+ // Push to extension host for query APIs
+ extensionHost.setCursorTelemetry(
+ cursorTelemetry.map(p => ({
+ timeMs: p.timeMs,
+ cx: p.cx,
+ cy: p.cy,
+ interactionType: p.interactionType,
+ pressure: (p as any).pressure,
+ })),
+ );
}, [cursorTelemetry]);
useEffect(() => {
@@ -898,8 +1100,15 @@ const VideoPlayback = forwardRef(
}, [cursorSway]);
useEffect(() => {
- currentTimeRef.current = currentTime * 1000;
- }, [currentTime]);
+ const timeMs = currentTime * 1000;
+ currentTimeRef.current = timeMs;
+ const videoInfo = extensionHost.getVideoInfoSnapshot();
+ extensionHost.setPlaybackState({
+ currentTimeMs: timeMs,
+ durationMs: videoInfo?.durationMs ?? 0,
+ isPlaying,
+ });
+ }, [currentTime, isPlaying]);
useEffect(() => {
if (!pixiReady || !videoReady) return;
@@ -1115,6 +1324,11 @@ const VideoPlayback = forwardRef(
videoContainerRef.current = videoContainer;
cameraContainer.addChild(videoContainer);
+ // Device frame overlay container — sits above video but below cursor
+ const frameContainer = new Container();
+ frameContainerRef.current = frameContainer;
+ cameraContainer.addChild(frameContainer);
+
const cursorContainer = new Container();
cursorContainerRef.current = cursorContainer;
cameraContainer.addChild(cursorContainer);
@@ -1164,6 +1378,8 @@ const VideoPlayback = forwardRef(
appRef.current = null;
cameraContainerRef.current = null;
videoContainerRef.current = null;
+ frameContainerRef.current = null;
+ frameSpriteRef.current = null;
cursorContainerRef.current = null;
videoSpriteRef.current = null;
};
@@ -1423,6 +1639,14 @@ const VideoPlayback = forwardRef(
state.focusY = targetFocus.cy;
state.progress = targetProgress;
+ // Push zoom state to extension host for query APIs
+ extensionHost.setZoomState({
+ scale: targetScaleFactor,
+ focusX: targetFocus.cx,
+ focusY: targetFocus.cy,
+ progress: targetProgress,
+ });
+
const projectedTransform = computeZoomTransform({
stageSize: stageSizeRef.current,
baseMask: baseMaskRef.current,
@@ -1479,17 +1703,182 @@ const VideoPlayback = forwardRef(
);
applyWebcamBubbleLayout(animationStateRef.current.appliedScale || 1);
- // Update cursor overlay
+ const timeMs = currentTimeRef.current;
+ const effectsCanvas = cursorEffectsCanvasRef.current;
+ const extensionCanvasWidth = effectsCanvas?.width || stageSizeRef.current.width;
+ const extensionCanvasHeight = effectsCanvas?.height || stageSizeRef.current.height;
+ let smoothedCursorForHooks: {
+ cx: number;
+ cy: number;
+ trail: Array<{ cx: number; cy: number }>;
+ } | null = null;
+
+ // Update cursor overlay + emit cursor events
const cursorOverlay = cursorOverlayRef.current;
if (cursorOverlay) {
- const timeMs = currentTimeRef.current;
+ const telemetry = cursorTelemetryRef.current;
cursorOverlay.update(
- cursorTelemetryRef.current,
+ telemetry,
timeMs,
baseMaskRef.current,
showCursorRef.current,
!isPlayingRef.current || isSeekingRef.current,
);
+
+ smoothedCursorForHooks = mapSmoothedCursorToCanvasNormalized(
+ cursorOverlay.getSmoothedCursorSnapshot(),
+ {
+ maskRect: baseMaskRef.current,
+ canvasWidth: extensionCanvasWidth,
+ canvasHeight: extensionCanvasHeight,
+ },
+ );
+ extensionHost.setSmoothedCursor(
+ smoothedCursorForHooks
+ ? {
+ timeMs,
+ cx: smoothedCursorForHooks.cx,
+ cy: smoothedCursorForHooks.cy,
+ trail: smoothedCursorForHooks.trail,
+ }
+ : null,
+ );
+
+ // Emit cursor:click events for extensions
+ if (isPlayingRef.current && telemetry.length > 0) {
+ for (let i = telemetry.length - 1; i >= 0; i--) {
+ const p = telemetry[i];
+ if (p.timeMs > timeMs) continue;
+ if (p.timeMs < timeMs - 100) break;
+ if (
+ p.interactionType &&
+ p.interactionType !== 'move' &&
+ p.timeMs !== lastEmittedClickTimeMsRef.current
+ ) {
+ const extensionCursor = mapCursorToCanvasNormalized(
+ {
+ cx: p.cx,
+ cy: p.cy,
+ interactionType: p.interactionType,
+ },
+ {
+ maskRect: baseMaskRef.current,
+ canvasWidth: extensionCanvasWidth,
+ canvasHeight: extensionCanvasHeight,
+ },
+ );
+ lastEmittedClickTimeMsRef.current = p.timeMs;
+ extensionHost.emitEvent({
+ type: 'cursor:click',
+ timeMs: p.timeMs,
+ data: extensionCursor,
+ });
+ if (extensionCursor) {
+ notifyCursorInteraction(
+ p.timeMs,
+ extensionCursor.cx,
+ extensionCursor.cy,
+ p.interactionType,
+ );
+ }
+ }
+ break;
+ }
+ }
+ }
+
+ if (!cursorOverlay) {
+ extensionHost.setSmoothedCursor(null);
+ }
+
+ if (effectsCanvas && effectsCanvas.width > 0 && effectsCanvas.height > 0) {
+ const ctx2d = effectsCanvas.getContext("2d");
+ if (ctx2d) {
+ ctx2d.clearRect(0, 0, effectsCanvas.width, effectsCanvas.height);
+
+ const maskRect = baseMaskRef.current;
+ const animationState = animationStateRef.current;
+ const videoInfo = extensionHost.getVideoInfoSnapshot();
+ const rawCursor = getCursorPositionAtTime(cursorTelemetryRef.current, timeMs, {
+ maskRect,
+ canvasWidth: effectsCanvas.width,
+ canvasHeight: effectsCanvas.height,
+ });
+ const hookParams = {
+ width: effectsCanvas.width,
+ height: effectsCanvas.height,
+ timeMs,
+ durationMs: videoInfo?.durationMs ?? 0,
+ cursor: smoothedCursorForHooks
+ ? {
+ cx: smoothedCursorForHooks.cx,
+ cy: smoothedCursorForHooks.cy,
+ interactionType: rawCursor?.interactionType,
+ }
+ : rawCursor,
+ smoothedCursor: smoothedCursorForHooks,
+ videoLayout:
+ maskRect.width > 0 && maskRect.height > 0
+ ? {
+ maskRect: {
+ x: maskRect.x,
+ y: maskRect.y,
+ width: maskRect.width,
+ height: maskRect.height,
+ },
+ borderRadius,
+ padding,
+ }
+ : undefined,
+ zoom: {
+ scale: animationState.scale,
+ focusX: animationState.focusX,
+ focusY: animationState.focusY,
+ progress: animationState.progress,
+ },
+ shadow: {
+ enabled: Boolean(showShadow) && shadowIntensity > 0,
+ intensity: shadowIntensity,
+ },
+ sceneTransform: {
+ scale: animationState.appliedScale,
+ x: animationState.x,
+ y: animationState.y,
+ },
+ };
+
+ ctx2d.save();
+ applyCanvasSceneTransform(ctx2d, {
+ scale: animationState.appliedScale,
+ x: animationState.x,
+ y: animationState.y,
+ });
+ executeExtensionRenderHooks("post-video", ctx2d, hookParams);
+ executeExtensionRenderHooks("post-zoom", ctx2d, hookParams);
+ executeExtensionRenderHooks("post-cursor", ctx2d, hookParams);
+
+ if (isSeekingRef.current) {
+ clearCursorEffects();
+ } else {
+ executeExtensionCursorEffects(
+ ctx2d,
+ timeMs,
+ effectsCanvas.width,
+ effectsCanvas.height,
+ {
+ zoom: hookParams.zoom,
+ sceneTransform: hookParams.sceneTransform,
+ videoLayout: hookParams.videoLayout,
+ },
+ );
+ }
+ ctx2d.restore();
+
+ executeExtensionRenderHooks("post-webcam", ctx2d, hookParams);
+ executeExtensionRenderHooks("post-annotations", ctx2d, hookParams);
+
+ executeExtensionRenderHooks("final", ctx2d, hookParams);
+ }
}
};
@@ -1499,7 +1888,16 @@ const VideoPlayback = forwardRef(
app.ticker.remove(ticker);
}
};
- }, [pixiReady, videoReady, clampFocusToStage, applyWebcamBubbleLayout]);
+ }, [
+ pixiReady,
+ videoReady,
+ clampFocusToStage,
+ applyWebcamBubbleLayout,
+ borderRadius,
+ padding,
+ showShadow,
+ shadowIntensity,
+ ]);
useEffect(() => {
const overlay = cursorOverlayRef.current;
@@ -1530,6 +1928,14 @@ const VideoPlayback = forwardRef(
) => {
const video = e.currentTarget;
onDurationChange(video.duration);
+
+ // Push video info to extension host for query APIs
+ extensionHost.setVideoInfo({
+ width: video.videoWidth,
+ height: video.videoHeight,
+ durationMs: Number.isFinite(video.duration) ? video.duration * 1000 : 0,
+ fps: 60, // Not available from HTMLVideoElement; default to 60
+ });
const targetTime = clampMediaTimeToDuration(
currentTime,
Number.isFinite(video.duration) ? video.duration : null,
@@ -1729,6 +2135,12 @@ const VideoPlayback = forwardRef(
: "none",
}}
/>
+ {/* Canvas overlay for extension cursor effects (drawn via Canvas 2D API) */}
+
{/* Only render overlay after PIXI and video are fully initialized */}
{pixiReady && videoReady && (
): Pro
: DEFAULT_CURSOR_SWAY,
borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius : 12.5,
padding: isFiniteNumber(editor.padding) ? clamp(editor.padding, 0, 100) : 20,
+ frame: typeof editor.frame === "string" ? editor.frame : null,
cropRegion: {
x: cropX,
y: cropY,