Merge pull request #366 from webadderallorg/codex/video-wallpaper-sync-update-preview

Fix video wallpaper sync and streamline updater UI
This commit is contained in:
webadderall authored and GitHub committed 2026-04-27 17:00:13 +10:00
commit 580dc442b6
14 files changed
+414 -71

No files matched your search

-20
View File
@@ -292,15 +292,6 @@ export function getUpdateStatusSummary() {
return updateStatusSummary;
}
async function showNoUpdatesDialog(getMainWindow: () => BrowserWindow | null) {
await showMessageBox(getMainWindow, {
type: "info",
title: "No Updates Available",
message: "Recordly is up to date.",
detail: `You are running version ${app.getVersion()}.`,
});
}
async function showUpdateErrorDialog(getMainWindow: () => BrowserWindow | null, error: unknown) {
await showMessageBox(getMainWindow, {
type: "error",
@@ -647,13 +638,6 @@ export async function checkForAppUpdates(
if (updateCheckInProgress) {
writeUpdaterLog("Skipped update check because a previous check is still running.");
if (options?.manual) {
await showMessageBox(getMainWindow, {
type: "info",
title: "Update Check In Progress",
message: "Recordly is already checking for updates.",
});
}
return;
}
@@ -758,11 +742,7 @@ export function setupAutoUpdates(
detail: `Recordly ${app.getVersion()} is up to date.`,
});
clearVisibleUpdateToast(sendToRenderer);
const shouldReport = manualCheckRequested;
manualCheckRequested = false;
if (shouldReport) {
void showNoUpdatesDialog(getMainWindow);
}
});
autoUpdater.on("download-progress", (progress) => {
+19
View File
@@ -72,6 +72,7 @@ const COUNTDOWN_OPTIONS = [0, 3, 5, 10];
const WEBCAM_PREVIEW_DRAG_THRESHOLD = 6;
const DEFAULT_WEBCAM_PREVIEW_OFFSET = { x: 0, y: 0 };
const DEFAULT_RECORDING_HUD_OFFSET = { x: 0, y: 0 };
const SHOW_DEV_UPDATE_PREVIEW = import.meta.env.DEV;
function IconButton({
onClick,
@@ -1472,6 +1473,24 @@ export function LaunchWindow() {
>
{t("recording.openProject")}
</DropdownItem>
{SHOW_DEV_UPDATE_PREVIEW ? (
<DropdownItem
icon={<RefreshCw size={16} />}
onClick={() => {
setActiveDropdown("none");
void window.electronAPI
.previewUpdateToast()
.catch((error) => {
console.warn(
"Failed to preview update toast:",
error,
);
});
}}
>
{t("recording.previewUpdateUi", "Preview Update UI")}
</DropdownItem>
) : null}
<div className={styles.ddLabel} style={{ marginTop: 4 }}>
{t("recording.language")}
</div>
+50 -15
View File
@@ -13,7 +13,12 @@ import {
import { Switch } from "@/components/ui/switch";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useTheme } from "@/contexts/ThemeContext";
import { getAssetPath, getRenderableAssetUrl, getWallpaperThumbnailUrl } from "@/lib/assetPath";
import {
getAssetPath,
getRenderableAssetUrl,
getRenderableVideoUrl,
getWallpaperThumbnailUrl,
} from "@/lib/assetPath";
import type { ExtensionSettingField } from "@/lib/extensions";
import { extensionHost, type FrameInstance } from "@/lib/extensions";
import { cn } from "@/lib/utils";
@@ -147,6 +152,48 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
);
}
function WallpaperVideoPreview({ src }: { src: string }) {
const [resolvedSrc, setResolvedSrc] = useState(src);
useEffect(() => {
let cancelled = false;
setResolvedSrc(src);
void (async () => {
try {
const nextSrc = await getRenderableVideoUrl(src);
if (!cancelled) {
setResolvedSrc(nextSrc);
}
} catch {
if (!cancelled) {
setResolvedSrc(src);
}
}
})();
return () => {
cancelled = true;
};
}, [src]);
return (
<video
src={resolvedSrc}
muted
playsInline
preload="metadata"
className="h-full w-full select-none object-cover [transform:translateZ(0)]"
draggable={false}
onMouseEnter={(e) => e.currentTarget.play().catch(() => undefined)}
onMouseLeave={(e) => {
e.currentTarget.pause();
e.currentTarget.currentTime = 0;
}}
/>
);
}
/**
* Renders extension-contributed settings fields (toggle, slider, select, color, text).
*/
@@ -847,7 +894,7 @@ export function SettingsPanel({
const assetUrl = await getAssetPath(wallpaper.relativePath);
// Use tiny thumbnails for the grid; full-res loads on selection
if (isVideoWallpaperSource(wallpaper.publicPath)) {
return getRenderableAssetUrl(assetUrl);
return getRenderableVideoUrl(assetUrl);
}
return getWallpaperThumbnailUrl(assetUrl);
}),
@@ -1260,19 +1307,7 @@ export function SettingsPanel({
>
<div className="absolute inset-[1px] overflow-hidden rounded-[8px] bg-editor-dialog">
{isVideoWallpaperSource(wallpaperUrl) ? (
<video
src={wallpaperUrl}
muted
playsInline
preload="metadata"
className="h-full w-full select-none object-cover [transform:translateZ(0)]"
draggable={false}
onMouseEnter={(e) => e.currentTarget.play().catch(() => undefined)}
onMouseLeave={(e) => {
e.currentTarget.pause();
e.currentTarget.currentTime = 0;
}}
/>
<WallpaperVideoPreview src={wallpaperUrl} />
) : (
<img
src={wallpaperUrl}
+55 -9
View File
@@ -18,8 +18,8 @@ import {
useRef,
useState,
} from "react";
import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath";
import { clampMediaTimeToDuration } from "@/lib/mediaTiming";
import { getAssetPath, getRenderableAssetUrl, getRenderableVideoUrl } from "@/lib/assetPath";
import { clampMediaTimeToDuration, getMediaSyncPlaybackRate } from "@/lib/mediaTiming";
import {
DEFAULT_WALLPAPER_PATH,
DEFAULT_WALLPAPER_RELATIVE_PATH,
@@ -398,6 +398,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const trimRegionsRef = useRef<TrimRegion[]>([]);
const speedRegionsRef = useRef<SpeedRegion[]>([]);
const lastWebcamSyncTimeRef = useRef<number | null>(null);
const lastBackgroundSyncTimeRef = useRef<number | null>(null);
const bgVideoRef = useRef<HTMLVideoElement | null>(null);
const zoomMotionBlurRef = useRef(zoomMotionBlur);
const connectZoomsRef = useRef(connectZooms);
@@ -1006,13 +1007,57 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}
}, [isPlaying]);
// Sync background video wallpaper with timeline scrubbing
// Keep video wallpapers locked to the same source timestamp as the main clip.
useEffect(() => {
const bgVideo = bgVideoRef.current;
if (!bgVideo) return;
if (!isPlaying && bgVideo.duration && Number.isFinite(bgVideo.duration)) {
bgVideo.currentTime = currentTime % bgVideo.duration;
const clipTimelineTime = currentTime;
const videoDuration =
Number.isFinite(bgVideo.duration) && bgVideo.duration > 0 ? bgVideo.duration : null;
const targetTime = videoDuration
? clipTimelineTime % videoDuration
: clampMediaTimeToDuration(clipTimelineTime, videoDuration);
const activeSpeedRegion = speedRegionsRef.current.find(
(region) => currentTime * 1000 >= region.startMs && currentTime * 1000 < region.endMs,
);
const targetPlaybackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1;
const syncedPlaybackRate = getMediaSyncPlaybackRate({
basePlaybackRate: targetPlaybackRate,
currentTime: bgVideo.currentTime,
targetTime,
toleranceSeconds: 0.02,
correctionWindowSeconds: 1.5,
maxAdjustment: 0.12,
});
if (Math.abs(bgVideo.playbackRate - syncedPlaybackRate) > 0.001) {
bgVideo.playbackRate = syncedPlaybackRate;
}
const previousTimelineTime = lastBackgroundSyncTimeRef.current;
const timelineJumped =
previousTimelineTime === null ||
Math.abs(clipTimelineTime - previousTimelineTime) > 0.25;
const driftThreshold = isPlaying ? 0.35 : 0.01;
if (timelineJumped || Math.abs(bgVideo.currentTime - targetTime) > driftThreshold) {
try {
bgVideo.currentTime = targetTime;
} catch {
// no-op
}
}
if (isPlaying) {
const playPromise = bgVideo.play();
if (playPromise) {
playPromise.catch(() => undefined);
}
} else {
bgVideo.pause();
}
lastBackgroundSyncTimeRef.current = clipTimelineTime;
}, [currentTime, isPlaying]);
useEffect(() => {
@@ -1275,6 +1320,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
lastWebcamSyncTimeRef.current = null;
}, [webcamVideoPath]);
useEffect(() => {
lastBackgroundSyncTimeRef.current = null;
}, [wallpaper]);
useEffect(() => {
const overlayEl = overlayRef.current;
if (!overlayEl) return;
@@ -2119,10 +2168,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
}
if (isVideoWallpaperSource(wallpaper)) {
let videoSrc = wallpaper;
if (wallpaper.startsWith("/") && !wallpaper.startsWith("//")) {
videoSrc = await getAssetPath(wallpaper.replace(/^\//, ""));
}
const videoSrc = await getRenderableVideoUrl(wallpaper);
if (mounted) {
setResolvedWallpaper(videoSrc);
setResolvedWallpaperKind("video");
@@ -63,6 +63,8 @@ import TimelineWrapper from "./TimelineWrapper";
import {
getTimelineContentMinHeightPx,
getTimelineRowsMinHeightPx,
getTimelineViewportStretchFactor,
TIMELINE_AXIS_HEIGHT_PX,
} from "./timelineLayout";
import { type AudioPeaksData, useAudioPeaks } from "./useAudioPeaks";
import { buildInteractionZoomSuggestions } from "./zoomSuggestionUtils";
@@ -709,13 +711,14 @@ function Timeline({
const timelineRowCount = 2 + annotationRowIds.length + audioRowIds.length;
const timelineRowsMinHeightPx = getTimelineRowsMinHeightPx(timelineRowCount);
const timelineContentMinHeightPx = getTimelineContentMinHeightPx(timelineRowCount);
const timelineViewportStretchFactor = getTimelineViewportStretchFactor(timelineRowCount);
return (
<div
ref={setRefs}
style={{
...style,
minHeight: `max(100%, ${timelineContentMinHeightPx}px)`,
height: `max(100%, ${timelineContentMinHeightPx}px, calc(${TIMELINE_AXIS_HEIGHT_PX}px + (100% - ${TIMELINE_AXIS_HEIGHT_PX}px) * ${timelineViewportStretchFactor}))`,
}}
className="select-none bg-editor-bg relative cursor-pointer group flex flex-col"
onClick={handleTimelineClick}
@@ -2059,7 +2062,7 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
}
return (
<div className="flex-1 min-h-0 flex flex-col bg-editor-bg overflow-auto">
<div className="flex-1 min-h-0 flex flex-col bg-editor-bg overflow-hidden">
{hideToolbar ? null : (
<div className="flex items-center gap-2 px-4 py-2 border-b border-foreground/10 bg-editor-panel">
<div className="flex items-center gap-1">
@@ -375,7 +375,7 @@ export default function TimelineWrapper({
autoScroll={{ enabled: false }}
resizeHandleWidth={28}
>
<div className="relative min-h-full">
<div className="relative h-full min-h-0">
{children}
{/* Floating tooltip shown during drag/resize */}
<div
@@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest";
import {
getTimelineContentMinHeightPx,
getTimelineRowsMinHeightPx,
getTimelineViewportStretchFactor,
TIMELINE_AXIS_HEIGHT_PX,
TIMELINE_ROW_MIN_HEIGHT_PX,
TIMELINE_VISIBLE_ROW_COUNT,
} from "./timelineLayout";
describe("timelineLayout", () => {
@@ -28,4 +30,12 @@ describe("timelineLayout", () => {
TIMELINE_AXIS_HEIGHT_PX + 2 * TIMELINE_ROW_MIN_HEIGHT_PX,
);
});
it("stretches content height to keep a two-row viewport", () => {
expect(TIMELINE_VISIBLE_ROW_COUNT).toBe(2);
expect(getTimelineViewportStretchFactor(2)).toBe(1);
expect(getTimelineViewportStretchFactor(4)).toBe(2);
expect(getTimelineViewportStretchFactor(5)).toBe(2.5);
expect(getTimelineViewportStretchFactor(0)).toBe(1);
});
});
@@ -1,5 +1,6 @@
export const TIMELINE_AXIS_HEIGHT_PX = 32;
export const TIMELINE_ROW_MIN_HEIGHT_PX = 28;
export const TIMELINE_VISIBLE_ROW_COUNT = 2;
function normalizeRowCount(rowCount: number) {
if (!Number.isFinite(rowCount)) {
@@ -16,3 +17,13 @@ export function getTimelineRowsMinHeightPx(rowCount: number) {
export function getTimelineContentMinHeightPx(rowCount: number) {
return TIMELINE_AXIS_HEIGHT_PX + getTimelineRowsMinHeightPx(rowCount);
}
export function getTimelineViewportStretchFactor(rowCount: number) {
const normalizedRowCount = normalizeRowCount(rowCount);
if (normalizedRowCount <= 0) {
return 1;
}
return Math.max(1, normalizedRowCount / TIMELINE_VISIBLE_ROW_COUNT);
}
+65
View File
@@ -0,0 +1,65 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getExportableVideoUrl, getRenderableVideoUrl } from "./assetPath";
describe("getRenderableVideoUrl", () => {
beforeEach(() => {
vi.unstubAllGlobals();
});
it("uses the local media server for absolute local video paths when available", async () => {
vi.stubGlobal("window", {
electronAPI: {
getLocalMediaUrl: vi.fn(async (filePath: string) => ({
success: true,
url: `http://127.0.0.1:4321/video?path=${encodeURIComponent(filePath)}`,
})),
},
});
await expect(getRenderableVideoUrl("/Users/egg/Desktop/bg.mp4")).resolves.toBe(
"http://127.0.0.1:4321/video?path=%2FUsers%2Fegg%2FDesktop%2Fbg.mp4",
);
});
it("falls back to a file URL for absolute local video paths", async () => {
await expect(getRenderableVideoUrl("/Users/egg/Desktop/bg.mp4")).resolves.toBe(
"file:///Users/egg/Desktop/bg.mp4",
);
});
it("keeps bundled wallpaper paths routed through the app asset directory", async () => {
vi.stubGlobal("window", {
location: {
protocol: "http:",
},
});
await expect(getRenderableVideoUrl("/wallpapers/wispysky.mp4")).resolves.toBe(
"/wallpapers/wispysky.mp4",
);
});
});
describe("getExportableVideoUrl", () => {
beforeEach(() => {
vi.unstubAllGlobals();
});
it("uses a direct file URL for absolute local video paths", async () => {
await expect(getExportableVideoUrl("/Users/egg/Desktop/bg.mp4")).resolves.toBe(
"file:///Users/egg/Desktop/bg.mp4",
);
});
it("keeps bundled wallpaper paths routed through the app asset directory", async () => {
vi.stubGlobal("window", {
location: {
protocol: "http:",
},
});
await expect(getExportableVideoUrl("/wallpapers/wispysky.mp4")).resolves.toBe(
"/wallpapers/wispysky.mp4",
);
});
});
+104
View File
@@ -11,6 +11,47 @@ function ensureTrailingSlash(value: string): string {
return value.endsWith("/") ? value : `${value}/`;
}
const WINDOWS_ABSOLUTE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/;
function encodeFilePathSegments(pathname: string, keepWindowsDrive = false): string {
return pathname
.split("/")
.map((segment, index) => {
if (!segment) return "";
if (keepWindowsDrive && index === 1 && /^[A-Za-z]:$/.test(segment)) {
return segment;
}
return encodeURIComponent(segment);
})
.join("/");
}
function toFileUrl(filePath: string): string {
const normalized = filePath.replace(/\\/g, "/");
if (WINDOWS_ABSOLUTE_PATH_PATTERN.test(normalized)) {
return `file://${encodeFilePathSegments(`/${normalized}`, true)}`;
}
if (normalized.startsWith("//")) {
const [host, ...pathParts] = normalized.replace(/^\/+/, "").split("/");
const encodedPath = pathParts.map((part) => encodeURIComponent(part)).join("/");
return encodedPath ? `file://${host}/${encodedPath}` : `file://${host}/`;
}
const absolutePath = normalized.startsWith("/") ? normalized : `/${normalized}`;
return `file://${encodeFilePathSegments(absolutePath)}`;
}
export function isAbsoluteLocalAssetPath(asset: string): boolean {
return (
asset.startsWith("/") ||
WINDOWS_ABSOLUTE_PATH_PATTERN.test(asset) ||
WINDOWS_UNC_PATH_PATTERN.test(asset)
);
}
export async function getAssetPath(relativePath: string): Promise<string> {
const encodedRelativePath = encodeRelativeAssetPath(relativePath);
const isWebContext =
@@ -121,6 +162,69 @@ export async function getRenderableAssetUrl(asset: string): Promise<string> {
}
}
async function resolveLocalMediaUrl(filePath: string): Promise<string> {
if (typeof window !== "undefined" && window.electronAPI?.getLocalMediaUrl) {
try {
const result = await window.electronAPI.getLocalMediaUrl(filePath);
if (result.success && result.url) {
return result.url;
}
} catch {
// Fall through to a file URL when the media server is unavailable.
}
}
return toFileUrl(filePath);
}
function isBundledAssetPath(asset: string): boolean {
return asset.startsWith("/wallpapers/") || asset.startsWith("/app-icons/");
}
export async function getRenderableVideoUrl(asset: string): Promise<string> {
if (
!asset ||
asset.startsWith("blob:") ||
asset.startsWith("data:") ||
asset.startsWith("file://") ||
asset.startsWith("http")
) {
return asset;
}
if (isAbsoluteLocalAssetPath(asset) && !isBundledAssetPath(asset)) {
return resolveLocalMediaUrl(asset);
}
if (asset.startsWith("/") && !asset.startsWith("//")) {
return getAssetPath(asset.replace(/^\/+/, ""));
}
return asset;
}
export async function getExportableVideoUrl(asset: string): Promise<string> {
if (
!asset ||
asset.startsWith("blob:") ||
asset.startsWith("data:") ||
asset.startsWith("file://") ||
asset.startsWith("http")
) {
return asset;
}
if (isAbsoluteLocalAssetPath(asset) && !isBundledAssetPath(asset)) {
return toFileUrl(asset);
}
if (asset.startsWith("/") && !asset.startsWith("//")) {
return getAssetPath(asset.replace(/^\/+/, ""));
}
return asset;
}
// ---------------------------------------------------------------------------
// Wallpaper thumbnail helper — generates a tiny JPEG thumbnail via the main
// process (nativeImage resize) and returns a data URL for fast grid rendering.
+1
View File
@@ -18,6 +18,7 @@ vi.mock("pixi-filters/motion-blur", () => ({
vi.mock("@/lib/assetPath", () => ({
getAssetPath: vi.fn(async (value: string) => value),
getExportableVideoUrl: vi.fn(async (value: string) => value),
getRenderableAssetUrl: vi.fn((value: string) => value),
}));
+34 -4
View File
@@ -51,7 +51,11 @@ import {
getWebcamOverlaySizePx,
} from "@/components/video-editor/webcamOverlay";
import { getWebcamMediaTargetTimeSeconds } from "@/components/video-editor/videoPlayback/webcamSync";
import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath";
import {
getAssetPath,
getExportableVideoUrl,
getRenderableAssetUrl,
} from "@/lib/assetPath";
import { extensionHost } from "@/lib/extensions";
import {
mapCursorToCanvasNormalized,
@@ -374,17 +378,39 @@ export class FrameRenderer {
videoSrc = await getAssetPath(wallpaper.replace(/^\//, ""));
}
const backgroundSource = await resolveMediaElementSource(videoSrc);
this.cleanupBackgroundSource = backgroundSource.revoke;
const video = document.createElement("video");
video.muted = true;
video.loop = true;
video.playsInline = true;
video.preload = "auto";
video.src = videoSrc;
video.src = backgroundSource.src;
video.load();
await new Promise<void>((resolve, reject) => {
video.onloadeddata = () => resolve();
video.onerror = () =>
const onReady = () => {
if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
return;
}
cleanup();
resolve();
};
const onError = () => {
cleanup();
reject(new Error(`Failed to load video wallpaper: ${wallpaper}`));
};
const cleanup = () => {
video.removeEventListener("loadeddata", onReady);
video.removeEventListener("canplay", onReady);
video.removeEventListener("error", onError);
};
video.addEventListener("loadeddata", onReady);
video.addEventListener("canplay", onReady);
video.addEventListener("error", onError);
onReady();
});
this.backgroundVideoElement = video;
@@ -580,6 +606,10 @@ export class FrameRenderer {
return wallpaper;
}
if (isVideoWallpaperSource(wallpaper)) {
return getExportableVideoUrl(wallpaper);
}
if (
wallpaper.startsWith("#") ||
wallpaper.startsWith("linear-gradient") ||
@@ -36,6 +36,7 @@ vi.mock("pixi-filters/motion-blur", () => ({
vi.mock("@/lib/assetPath", () => ({
getAssetPath: vi.fn(async (value: string) => value),
getExportableVideoUrl: vi.fn(async (value: string) => value),
getRenderableAssetUrl: vi.fn((value: string) => value),
}));
+58 -20
View File
@@ -57,7 +57,11 @@ import {
getWebcamOverlaySizePx,
} from "@/components/video-editor/webcamOverlay";
import { getWebcamMediaTargetTimeSeconds } from "@/components/video-editor/videoPlayback/webcamSync";
import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath";
import {
getAssetPath,
getExportableVideoUrl,
getRenderableAssetUrl,
} from "@/lib/assetPath";
import { extensionHost } from "@/lib/extensions";
import {
mapCursorToCanvasNormalized,
@@ -332,6 +336,7 @@ export class FrameRenderer {
private backgroundForwardFrameSource: ForwardFrameSource | null = null;
private backgroundDecodedFrame: VideoFrame | null = null;
private backgroundVideoElement: HTMLVideoElement | null = null;
private cleanupBackgroundSource: (() => void) | null = null;
private videoShadowLayers: ShadowLayer[] = [];
private webcamShadowLayers: ShadowLayer[] = [];
private webcamSprite: Sprite | null = null;
@@ -344,6 +349,8 @@ export class FrameRenderer {
private webcamFrameCacheCtx: CanvasRenderingContext2D | null = null;
private sceneVideoFrameStagingCanvas: HTMLCanvasElement | null = null;
private sceneVideoFrameStagingCtx: CanvasRenderingContext2D | null = null;
private backgroundVideoFrameStagingCanvas: HTMLCanvasElement | null = null;
private backgroundVideoFrameStagingCtx: CanvasRenderingContext2D | null = null;
private webcamVideoFrameStagingCanvas: HTMLCanvasElement | null = null;
private webcamVideoFrameStagingCtx: CanvasRenderingContext2D | null = null;
private captionMeasureCanvas: HTMLCanvasElement | null = null;
@@ -708,7 +715,7 @@ export class FrameRenderer {
}
private ensureVideoFrameStagingCanvas(
kind: "scene" | "webcam",
kind: "scene" | "background" | "webcam",
width: number,
height: number,
): { canvas: HTMLCanvasElement; context: CanvasRenderingContext2D } | null {
@@ -717,9 +724,15 @@ export class FrameRenderer {
const currentCanvas =
kind === "scene"
? this.sceneVideoFrameStagingCanvas
: this.webcamVideoFrameStagingCanvas;
: kind === "background"
? this.backgroundVideoFrameStagingCanvas
: this.webcamVideoFrameStagingCanvas;
const currentContext =
kind === "scene" ? this.sceneVideoFrameStagingCtx : this.webcamVideoFrameStagingCtx;
kind === "scene"
? this.sceneVideoFrameStagingCtx
: kind === "background"
? this.backgroundVideoFrameStagingCtx
: this.webcamVideoFrameStagingCtx;
if (
currentCanvas &&
@@ -741,6 +754,9 @@ export class FrameRenderer {
if (kind === "scene") {
this.sceneVideoFrameStagingCanvas = canvas;
this.sceneVideoFrameStagingCtx = context;
} else if (kind === "background") {
this.backgroundVideoFrameStagingCanvas = canvas;
this.backgroundVideoFrameStagingCtx = context;
} else {
this.webcamVideoFrameStagingCanvas = canvas;
this.webcamVideoFrameStagingCtx = context;
@@ -751,7 +767,7 @@ export class FrameRenderer {
private stageVideoFrameForTexture(
frame: VideoFrame,
kind: "scene" | "webcam",
kind: "scene" | "background" | "webcam",
fallbackWidth: number,
fallbackHeight: number,
): CanvasImageSource | VideoFrame {
@@ -814,6 +830,8 @@ export class FrameRenderer {
void this.backgroundForwardFrameSource?.destroy();
this.backgroundForwardFrameSource = null;
this.closeBackgroundDecodedFrame();
this.cleanupBackgroundSource?.();
this.cleanupBackgroundSource = null;
if (this.backgroundVideoElement) {
this.backgroundVideoElement.pause();
@@ -844,29 +862,39 @@ export class FrameRenderer {
videoSrc = await getAssetPath(wallpaper.replace(/^\//, ""));
}
try {
const frameSource = new ForwardFrameSource();
await frameSource.initialize(videoSrc);
this.backgroundForwardFrameSource = frameSource;
return;
} catch (error) {
console.warn(
"[FrameRenderer] Decoder-backed wallpaper source unavailable during export; falling back to media element sync:",
error,
);
}
const backgroundSource = await resolveMediaElementSource(videoSrc);
this.cleanupBackgroundSource = backgroundSource.revoke;
const video = document.createElement("video");
video.muted = true;
video.loop = true;
video.playsInline = true;
video.preload = "auto";
video.src = videoSrc;
video.src = backgroundSource.src;
video.load();
await new Promise<void>((resolve, reject) => {
video.onloadeddata = () => resolve();
video.onerror = () =>
const onReady = () => {
if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
return;
}
cleanup();
resolve();
};
const onError = () => {
cleanup();
reject(new Error(`Failed to load video wallpaper: ${wallpaper}`));
};
const cleanup = () => {
video.removeEventListener("loadeddata", onReady);
video.removeEventListener("canplay", onReady);
video.removeEventListener("error", onError);
};
video.addEventListener("loadeddata", onReady);
video.addEventListener("canplay", onReady);
video.addEventListener("error", onError);
onReady();
});
this.backgroundVideoElement = video;
@@ -1434,8 +1462,14 @@ export class FrameRenderer {
this.closeBackgroundDecodedFrame();
this.backgroundDecodedFrame = decodedFrame;
if (decodedFrame) {
this.ensureBackgroundSprite(
const resolvedBackgroundSource = this.stageVideoFrameForTexture(
decodedFrame,
"background",
decodedFrame.displayWidth,
decodedFrame.displayHeight,
);
this.ensureBackgroundSprite(
resolvedBackgroundSource,
decodedFrame.displayWidth,
decodedFrame.displayHeight,
);
@@ -1487,6 +1521,10 @@ export class FrameRenderer {
return wallpaper;
}
if (isVideoWallpaperSource(wallpaper)) {
return getExportableVideoUrl(wallpaper);
}
if (
wallpaper.startsWith("#") ||
wallpaper.startsWith("linear-gradient") ||