Browse Source

fix: convert video data URLs to blob URLs for playback performance

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
shrimbly 5 months ago
parent
commit
ff88fe276e
  1. 4
      src/components/nodes/EaseCurveNode.tsx
  2. 4
      src/components/nodes/GenerateVideoNode.tsx
  3. 7
      src/components/nodes/OutputNode.tsx
  4. 4
      src/components/nodes/VideoStitchNode.tsx
  5. 4
      src/components/nodes/VideoTrimNode.tsx
  6. 82
      src/hooks/useVideoBlobUrl.ts

4
src/components/nodes/EaseCurveNode.tsx

@ -8,6 +8,7 @@ import { useCommentNavigation } from "@/hooks/useCommentNavigation";
import { useWorkflowStore } from "@/store/workflowStore";
import { EaseCurveNodeData } from "@/types";
import { checkEncoderSupport } from "@/hooks/useStitchVideos";
import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl";
import { CubicBezierEditor } from "@/components/CubicBezierEditor";
import {
EASING_PRESETS,
@ -46,6 +47,7 @@ export function EaseCurveNode({ id, data, selected }: NodeProps<EaseCurveNodeTyp
const edges = useWorkflowStore((state) => state.edges);
const removeEdge = useWorkflowStore((state) => state.removeEdge);
const { setNodes } = useReactFlow();
const videoBlobUrl = useVideoBlobUrl(nodeData.outputVideo ?? null);
const [activeTab, setActiveTab] = useState<"editor" | "video">("editor");
const [showPresets, setShowPresets] = useState(false);
@ -493,7 +495,7 @@ export function EaseCurveNode({ id, data, selected }: NodeProps<EaseCurveNodeTyp
{nodeData.outputVideo ? (
<div className="relative flex-1 min-h-0">
<video
src={nodeData.outputVideo}
src={videoBlobUrl ?? undefined}
controls
autoPlay
loop

4
src/components/nodes/GenerateVideoNode.tsx

@ -13,6 +13,7 @@ import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog";
import { useToast } from "@/components/Toast";
import { getVideoDimensions, calculateNodeSizePreservingHeight } from "@/utils/nodeDimensions";
import { ProviderBadge } from "./ProviderBadge";
import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl";
// Video generation capabilities
const VIDEO_CAPABILITIES: ModelCapability[] = ["text-to-video", "image-to-video"];
@ -31,6 +32,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
const [modelsFetchError, setModelsFetchError] = useState<string | null>(null);
const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false);
const [isLoadingCarouselVideo, setIsLoadingCarouselVideo] = useState(false);
const videoBlobUrl = useVideoBlobUrl(nodeData.outputVideo ?? null);
// Get the current selected provider (default to fal since Gemini doesn't do video)
const currentProvider: ProviderType = nodeData.selectedModel?.provider || "fal";
@ -559,7 +561,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
<div className="relative w-full flex-1 min-h-0">
<video
key={nodeData.videoHistory?.[nodeData.selectedVideoHistoryIndex || 0]?.id}
src={nodeData.outputVideo}
src={videoBlobUrl ?? undefined}
controls
autoPlay
loop

7
src/components/nodes/OutputNode.tsx

@ -6,6 +6,7 @@ import { BaseNode } from "./BaseNode";
import { useCommentNavigation } from "@/hooks/useCommentNavigation";
import { useWorkflowStore } from "@/store/workflowStore";
import { OutputNodeData } from "@/types";
import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl";
type OutputNodeType = Node<OutputNodeData, "output">;
@ -40,6 +41,8 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
return nodeData.image;
}, [nodeData.audio, nodeData.video, nodeData.image]);
const videoBlobUrl = useVideoBlobUrl(isVideo ? contentSrc ?? null : null);
const handleDownload = useCallback(async () => {
if (!contentSrc) return;
@ -123,7 +126,7 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
>
{isVideo ? (
<video
src={contentSrc}
src={videoBlobUrl ?? undefined}
controls
loop
muted
@ -180,7 +183,7 @@ export function OutputNode({ id, data, selected }: NodeProps<OutputNodeType>) {
<div className="relative max-w-full max-h-full">
{isVideo ? (
<video
src={contentSrc}
src={videoBlobUrl ?? undefined}
controls
loop
autoPlay

4
src/components/nodes/VideoStitchNode.tsx

@ -7,6 +7,7 @@ import { useCommentNavigation } from "@/hooks/useCommentNavigation";
import { useWorkflowStore } from "@/store/workflowStore";
import { VideoStitchNodeData } from "@/types";
import { checkEncoderSupport } from "@/hooks/useStitchVideos";
import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl";
type VideoStitchNodeType = Node<VideoStitchNodeData, "videoStitch">;
@ -20,6 +21,7 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const isRunning = useWorkflowStore((state) => state.isRunning);
const removeEdge = useWorkflowStore((state) => state.removeEdge);
const videoBlobUrl = useVideoBlobUrl(nodeData.outputVideo ?? null);
// Check encoder support on mount
useEffect(() => {
@ -549,7 +551,7 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
{nodeData.outputVideo && nodeData.status !== "loading" && (
<div className="relative flex-1 min-h-0">
<video
src={nodeData.outputVideo}
src={videoBlobUrl ?? undefined}
controls
autoPlay
loop

4
src/components/nodes/VideoTrimNode.tsx

@ -7,6 +7,7 @@ import { useCommentNavigation } from "@/hooks/useCommentNavigation";
import { useWorkflowStore } from "@/store/workflowStore";
import { VideoTrimNodeData } from "@/types";
import { checkEncoderSupport } from "@/hooks/useStitchVideos";
import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl";
type VideoTrimNodeType = Node<VideoTrimNodeData, "videoTrim">;
@ -154,6 +155,7 @@ export function VideoTrimNode({ id, data, selected }: NodeProps<VideoTrimNodeTyp
// Which video URL to show in preview
const previewUrl = showOutput && nodeData.outputVideo ? nodeData.outputVideo : sourceVideoUrl;
const previewBlobUrl = useVideoBlobUrl(previewUrl);
// Compute slider thumb position percentages for the visual range highlight
const startPct = duration > 0 ? (startTime / duration) * 100 : 0;
@ -285,7 +287,7 @@ export function VideoTrimNode({ id, data, selected }: NodeProps<VideoTrimNodeTyp
{previewUrl ? (
<video
key={previewUrl}
src={previewUrl}
src={previewBlobUrl ?? undefined}
controls
playsInline
muted

82
src/hooks/useVideoBlobUrl.ts

@ -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…
Cancel
Save