Browse Source
Data URLs force Chrome to re-parse base64 on each access. With autoPlay loop, this happens continuously and can freeze the main thread on systems with weak GPUs that fall back to software video decoding. Add useVideoBlobUrl hook that converts data URLs to blob URLs via fetch->blob->createObjectURL. Returns the data URL immediately as fallback to avoid flicker, then swaps to blob URL once ready (~50ms). Properly revokes blob URLs on input change and unmount. Applied to all 6 <video> elements across 5 components: - GenerateVideoNode (outputVideo) - VideoStitchNode (outputVideo) - OutputNode (contentSrc, inline + lightbox) - EaseCurveNode (outputVideo) - VideoTrimNode (previewUrl) Store data is unaffected — getConnectedInputs, executors, and imageStorage all read outputVideo from the Zustand store, not from the rendering layer. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>handoff-20260429-1057
6 changed files with 99 additions and 6 deletions
@ -0,0 +1,82 @@ |
|||
import { useEffect, useRef, useState } from "react"; |
|||
|
|||
/** |
|||
* Converts data URL video sources to blob URLs for efficient playback. |
|||
* |
|||
* Data URLs force Chrome to re-parse base64 on each access. With autoPlay loop, |
|||
* this happens continuously and can freeze the main thread on weak GPUs. |
|||
* Blob URLs back the same data with an in-memory Blob that Chrome's media |
|||
* pipeline handles natively. |
|||
* |
|||
* - If input is null, returns null |
|||
* - If input is already a blob URL or HTTP URL, returns it as-is |
|||
* - If input is a data URL, immediately returns it as fallback, then swaps |
|||
* to a blob URL once conversion completes (~50ms) |
|||
* - Revokes previous blob URLs on input change and unmount |
|||
*/ |
|||
export function useVideoBlobUrl(videoUrl: string | null): string | null { |
|||
const [blobUrl, setBlobUrl] = useState<string | null>(null); |
|||
const prevBlobUrlRef = useRef<string | null>(null); |
|||
const prevInputRef = useRef<string | null>(null); |
|||
|
|||
useEffect(() => { |
|||
// Input unchanged — skip
|
|||
if (videoUrl === prevInputRef.current) return; |
|||
prevInputRef.current = videoUrl; |
|||
|
|||
// Revoke previous blob URL
|
|||
if (prevBlobUrlRef.current) { |
|||
URL.revokeObjectURL(prevBlobUrlRef.current); |
|||
prevBlobUrlRef.current = null; |
|||
} |
|||
|
|||
// Null input
|
|||
if (!videoUrl) { |
|||
setBlobUrl(null); |
|||
return; |
|||
} |
|||
|
|||
// Already a blob URL or HTTP URL — pass through
|
|||
if (videoUrl.startsWith("blob:") || videoUrl.startsWith("http")) { |
|||
setBlobUrl(videoUrl); |
|||
return; |
|||
} |
|||
|
|||
// Data URL — return it immediately as fallback, then convert async
|
|||
if (videoUrl.startsWith("data:")) { |
|||
setBlobUrl(videoUrl); |
|||
|
|||
let cancelled = false; |
|||
fetch(videoUrl) |
|||
.then((r) => r.blob()) |
|||
.then((blob) => { |
|||
if (cancelled) return; |
|||
const url = URL.createObjectURL(blob); |
|||
prevBlobUrlRef.current = url; |
|||
setBlobUrl(url); |
|||
}) |
|||
.catch(() => { |
|||
// Conversion failed — keep using the data URL fallback
|
|||
}); |
|||
|
|||
return () => { |
|||
cancelled = true; |
|||
}; |
|||
} |
|||
|
|||
// Unknown format — pass through
|
|||
setBlobUrl(videoUrl); |
|||
}, [videoUrl]); |
|||
|
|||
// Cleanup on unmount
|
|||
useEffect(() => { |
|||
return () => { |
|||
if (prevBlobUrlRef.current) { |
|||
URL.revokeObjectURL(prevBlobUrlRef.current); |
|||
prevBlobUrlRef.current = null; |
|||
} |
|||
}; |
|||
}, []); |
|||
|
|||
return blobUrl; |
|||
} |
|||
Loading…
Reference in new issue