diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 0e8fe195..9e22b900 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -9,10 +9,13 @@ import { useCallback, } from "react"; import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath"; +import type { SceneFrameLayout } from "@/lib/sceneFrames"; +import { resolveMediaElementSource } from "@/lib/exporter/localMediaSource"; import { clampMediaTimeToDuration } from "@/lib/mediaTiming"; import { DEFAULT_WALLPAPER_PATH, DEFAULT_WALLPAPER_RELATIVE_PATH, + isVideoWallpaperSource, } from "@/lib/wallpapers"; import { Application, @@ -89,6 +92,10 @@ import { DEFAULT_CONNECTED_ZOOM_DURATION_MS, DEFAULT_CONNECTED_ZOOM_EASING, DEFAULT_CONNECTED_ZOOM_GAP_MS, + DEFAULT_SCENE_FRAME_OPACITY, + DEFAULT_SCENE_FRAME_STYLE, + DEFAULT_SCENE_FRAME_TEXT, + DEFAULT_SCENE_FRAME_THICKNESS, DEFAULT_WEBCAM_CORNER_RADIUS, DEFAULT_WEBCAM_REACT_TO_ZOOM, DEFAULT_WEBCAM_SHADOW, @@ -99,6 +106,7 @@ import { DEFAULT_ZOOM_OUT_DURATION_MS, DEFAULT_ZOOM_OUT_EASING, getDefaultCaptionFontFamily, + type SceneFrameStyle, } from "./types"; import { getWebcamOverlayPosition, getWebcamOverlaySizePx } from "./webcamOverlay"; import { getSquircleSvgPath } from "@/lib/geometry/squircle"; @@ -171,6 +179,10 @@ interface VideoPlaybackProps { zoomInEasing?: ZoomTransitionEasing; zoomOutEasing?: ZoomTransitionEasing; connectedZoomEasing?: ZoomTransitionEasing; + sceneFrameStyle?: SceneFrameStyle; + sceneFrameText?: string; + sceneFrameOpacity?: number; + sceneFrameThickness?: number; borderRadius?: number; padding?: number; cropRegion?: import("./types").CropRegion; @@ -243,6 +255,10 @@ const VideoPlayback = forwardRef( zoomInEasing = DEFAULT_ZOOM_IN_EASING, zoomOutEasing = DEFAULT_ZOOM_OUT_EASING, connectedZoomEasing = DEFAULT_CONNECTED_ZOOM_EASING, + sceneFrameStyle = DEFAULT_SCENE_FRAME_STYLE, + sceneFrameText = DEFAULT_SCENE_FRAME_TEXT, + sceneFrameOpacity = DEFAULT_SCENE_FRAME_OPACITY, + sceneFrameThickness = DEFAULT_SCENE_FRAME_THICKNESS, borderRadius = 0, padding = 50, cropRegion, @@ -287,6 +303,7 @@ const VideoPlayback = forwardRef( const webcamBubbleRef = useRef(null); const webcamBubbleInnerRef = useRef(null); const captionBoxRef = useRef(null); + const [sceneFrameLayout, setSceneFrameLayout] = useState(null); const currentTimeRef = useRef(0); const zoomRegionsRef = useRef([]); const selectedZoomIdRef = useRef(null); @@ -546,6 +563,8 @@ const VideoPlayback = forwardRef( videoElement, cropRegion, lockedVideoDimensions: lockedVideoDimensionsRef.current, + sceneFrameStyle, + sceneFrameThickness, borderRadius, padding, }); @@ -557,6 +576,7 @@ const VideoPlayback = forwardRef( baseOffsetRef.current = result.baseOffset; baseMaskRef.current = result.maskRect; cropBoundsRef.current = result.cropBounds; + setSceneFrameLayout(result.sceneFrameLayout); // Reset camera container to identity cameraContainer.scale.set(1); @@ -571,8 +591,18 @@ const VideoPlayback = forwardRef( updateOverlayForRegion(activeRegion); applyWebcamBubbleLayout(animationStateRef.current.appliedScale || 1); + } else { + setSceneFrameLayout(null); } - }, [updateOverlayForRegion, cropRegion, borderRadius, padding, applyWebcamBubbleLayout]); + }, [ + updateOverlayForRegion, + cropRegion, + sceneFrameStyle, + sceneFrameThickness, + borderRadius, + padding, + applyWebcamBubbleLayout, + ]); useEffect(() => { const video = videoRef.current; @@ -945,7 +975,7 @@ const VideoPlayback = forwardRef( } const targetTime = clampMediaTimeToDuration( - currentTime, + Math.max(0, currentTime - (webcam.timeOffsetMs ?? 0) / 1000), Number.isFinite(webcamVideo.duration) ? webcamVideo.duration : null, ); @@ -1464,17 +1494,22 @@ const VideoPlayback = forwardRef( videoReadyRafRef.current = requestAnimationFrame(waitForRenderableFrame); }; - const [resolvedWallpaper, setResolvedWallpaper] = useState( - null, - ); + const [resolvedWallpaper, setResolvedWallpaper] = useState(null); + const [resolvedWallpaperKind, setResolvedWallpaperKind] = useState< + "image" | "video" | "style" + >("image"); useEffect(() => { let mounted = true; + let revokeResolvedWallpaper = () => {}; (async () => { try { if (!wallpaper) { const def = await getAssetPath(DEFAULT_WALLPAPER_RELATIVE_PATH); - if (mounted) setResolvedWallpaper(def); + if (mounted) { + setResolvedWallpaper(def); + setResolvedWallpaperKind("image"); + } return; } @@ -1483,13 +1518,29 @@ const VideoPlayback = forwardRef( wallpaper.startsWith("linear-gradient") || wallpaper.startsWith("radial-gradient") ) { - if (mounted) setResolvedWallpaper(wallpaper); + if (mounted) { + setResolvedWallpaper(wallpaper); + setResolvedWallpaperKind("style"); + } + return; + } + + if (isVideoWallpaperSource(wallpaper)) { + const resolved = await resolveMediaElementSource(wallpaper); + revokeResolvedWallpaper = resolved.revoke; + if (mounted) { + setResolvedWallpaper(resolved.src); + setResolvedWallpaperKind("video"); + } return; } // If it's a data URL (custom uploaded image), use as-is if (wallpaper.startsWith("data:")) { - if (mounted) setResolvedWallpaper(wallpaper); + if (mounted) { + setResolvedWallpaper(wallpaper); + setResolvedWallpaperKind("image"); + } return; } @@ -1499,20 +1550,31 @@ const VideoPlayback = forwardRef( wallpaper.startsWith("/") ) { const renderable = await getRenderableAssetUrl(wallpaper); - if (mounted) setResolvedWallpaper(renderable); + if (mounted) { + setResolvedWallpaper(renderable); + setResolvedWallpaperKind("image"); + } return; } const p = await getRenderableAssetUrl( await getAssetPath(wallpaper.replace(/^\//, "")), ); - if (mounted) setResolvedWallpaper(p); + if (mounted) { + setResolvedWallpaper(p); + setResolvedWallpaperKind("image"); + } } catch (err) { - if (mounted) + if (mounted) { setResolvedWallpaper(wallpaper || DEFAULT_WALLPAPER_PATH); + setResolvedWallpaperKind( + isVideoWallpaperSource(wallpaper || "") ? "video" : "image", + ); + } } })(); return () => { mounted = false; + revokeResolvedWallpaper(); }; }, [wallpaper]); @@ -1525,7 +1587,7 @@ const VideoPlayback = forwardRef( }; }, []); - const isImageUrl = Boolean( + const isImageUrl = resolvedWallpaperKind === "image" && Boolean( resolvedWallpaper && (resolvedWallpaper.startsWith("file://") || resolvedWallpaper.startsWith("http") || @@ -1563,13 +1625,121 @@ const VideoPlayback = forwardRef( }} > {/* Background layer */} -
0 ? `blur(${backgroundBlur}px)` : "none", - }} - /> + {resolvedWallpaperKind === "video" && resolvedWallpaper ? ( +