Browse Source

视频 截取帧的功能

feature/071
yun 5 days ago
parent
commit
433013673c
  1. 89
      src/components/composer/NodeHighDefinitionPanel.tsx
  2. 478
      src/components/media/CustomVideoPlayer.tsx
  3. 54
      src/components/nodes/GenerateVideoNode.tsx
  4. 131
      src/components/nodes/VideoInputNode.tsx
  5. 44
      src/store/execution/__tests__/derivedImageExecutor.test.ts
  6. 4
      src/store/execution/__tests__/nanoBananaExecutor.test.ts
  7. 13
      src/store/execution/derivedImageExecutor.ts
  8. 2
      src/types/nodes.ts
  9. 2
      src/utils/__tests__/highDefinitionNodes.test.ts
  10. 4
      src/utils/highDefinitionNodes.ts
  11. 6
      src/utils/smartImageMode.ts
  12. 113
      src/utils/videoFrameCapture.ts
  13. 52
      src/utils/videoFrameNode.ts

89
src/components/composer/NodeHighDefinitionPanel.tsx

@ -2,24 +2,34 @@
import { useCallback, useMemo } from "react";
import { Button, Select } from "antd";
import { ArrowUpOutlined } from "@ant-design/icons";
import { ArrowUpOutlined, ThunderboltFilled } from "@ant-design/icons";
import { PopiModelSelect } from "@/components/nodes/PopiModelSelect";
import { useModelStore } from "@/store/modelStore";
import { useWorkflowStore } from "@/store/workflowStore";
import type { SmartImageNodeData, WorkflowNode } from "@/types";
import { buildHighDefinitionParameters, chooseHighDefinitionModel, HIGH_DEFINITION_BATCH_SIZE, HIGH_DEFINITION_RESOLUTION, HIGH_DEFINITION_SUBTYPE } from "@/utils/highDefinitionNodes";
import { buildHighDefinitionParameters, chooseHighDefinitionModel, HIGH_DEFINITION_BATCH_SIZE, HIGH_DEFINITION_MODEL_MODE, HIGH_DEFINITION_SCALE, HIGH_DEFINITION_SUBTYPE } from "@/utils/highDefinitionNodes";
import { toSelectedModel } from "@/utils/selectedModel";
interface NodeHighDefinitionPanelProps {
node: WorkflowNode;
}
const RESOLUTION_OPTIONS = [
{ label: "4K", value: "4K" },
const SCALE_OPTIONS = [
{ label: "2", value: 2 },
{ label: "4", value: 4 },
{ label: "6", value: 6 },
];
function readResolution(parameters: Record<string, unknown> | undefined): string {
return typeof parameters?.resolution === "string" && parameters.resolution ? parameters.resolution : HIGH_DEFINITION_RESOLUTION;
const MODEL_MODE_OPTIONS = [
{ label: "通用", value: HIGH_DEFINITION_MODEL_MODE },
];
function readScale(parameters: Record<string, unknown> | undefined): number {
return typeof parameters?.scale === "number" && Number.isFinite(parameters.scale) ? parameters.scale : HIGH_DEFINITION_SCALE;
}
function readModelMode(parameters: Record<string, unknown> | undefined): string {
return typeof parameters?.modelMode === "string" && parameters.modelMode ? parameters.modelMode : HIGH_DEFINITION_MODEL_MODE;
}
export function NodeHighDefinitionPanel({ node }: NodeHighDefinitionPanelProps) {
@ -33,7 +43,8 @@ export function NodeHighDefinitionPanel({ node }: NodeHighDefinitionPanelProps)
[data.selectedModel, highDefinitionModels]
);
const parameters = buildHighDefinitionParameters(data.parameters);
const resolution = readResolution(parameters);
const scale = readScale(parameters);
const modelMode = readModelMode(parameters);
const updateParameters = useCallback((patch: Record<string, unknown>) => {
updateNodeData(node.id, {
@ -61,24 +72,15 @@ export function NodeHighDefinitionPanel({ node }: NodeHighDefinitionPanelProps)
<section
data-testid="node-high-definition-panel"
data-composer-popup-root="true"
className="nodrag nopan nowheel pointer-events-auto w-[420px] rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-2)] p-4 text-[var(--text-primary)] shadow-[var(--shadow-panel)]"
className="nodrag nopan nowheel pointer-events-auto w-[420px] rounded-[10px] border border-[var(--border-subtle)] bg-[var(--surface-2)] px-4 pb-2 pt-4 text-[var(--text-primary)] shadow-[var(--shadow-panel)]"
onPointerDown={(event) => event.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between gap-3">
<div className="min-w-0">
<h3 className="m-0 text-sm font-semibold leading-5"></h3>
</div>
<Button
type="primary"
icon={<ArrowUpOutlined />}
loading={isRunning}
disabled={!selectedModel.modelId && highDefinitionModels.length === 0}
onClick={handleRun}
/>
<div className="mb-4 min-w-0">
<h3 className="m-0 text-base font-semibold leading-6"></h3>
</div>
<div className="grid grid-cols-[92px_minmax(0,1fr)] items-center gap-x-3 gap-y-3 text-sm">
<span className="text-[var(--text-secondary)]"></span>
<div className="grid grid-cols-[116px_minmax(0,1fr)] items-center gap-x-3 gap-y-2 text-sm">
<span className="text-[var(--text-primary)]"></span>
<PopiModelSelect
kind="highDefinition"
value={selectedModel}
@ -94,13 +96,50 @@ export function NodeHighDefinitionPanel({ node }: NodeHighDefinitionPanelProps)
className="w-full"
/>
<span className="text-[var(--text-secondary)]"></span>
<span className="text-[var(--text-primary)]"></span>
<Select
value={resolution}
options={RESOLUTION_OPTIONS}
value={modelMode}
options={MODEL_MODE_OPTIONS}
size="middle"
popupMatchSelectWidth={false}
onChange={(value) => updateParameters({ resolution: value })}
suffixIcon={<span className="text-[10px] text-[var(--text-muted)]"></span>}
onChange={(value) => updateParameters({ modelMode: value })}
className="w-full [&_.ant-select-selector]:!rounded-lg [&_.ant-select-selector]:!border-[var(--border-subtle)] [&_.ant-select-selector]:!bg-[var(--surface-1)]"
/>
<span className="text-[var(--text-primary)]"></span>
<div className="grid grid-cols-3 gap-1">
{SCALE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
aria-pressed={scale === option.value}
className={`h-8 rounded-lg border text-sm leading-5 transition-colors ${
scale === option.value
? "border-[var(--border-strong)] bg-[var(--surface-hover)] text-[var(--text-primary)]"
: "border-[var(--border-subtle)] bg-[var(--surface-1)] text-[var(--text-secondary)] hover:border-[var(--border-strong)] hover:text-[var(--text-primary)]"
}`}
onClick={() => updateParameters({ scale: option.value })}
>
{option.label}
</button>
))}
</div>
</div>
<div className="mt-3 flex items-center justify-end gap-3">
<div className="flex items-center gap-1 text-xs text-[var(--text-muted)]">
<ThunderboltFilled className="text-[10px]" />
<span>15</span>
</div>
<Button
type="primary"
shape="circle"
icon={<ArrowUpOutlined />}
loading={isRunning}
disabled={!selectedModel.modelId && highDefinitionModels.length === 0}
onClick={handleRun}
className="!h-8 !w-8"
/>
</div>
</section>

478
src/components/media/CustomVideoPlayer.tsx

@ -0,0 +1,478 @@
"use client";
import { Popover } from "antd";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { preventVideoDoubleClick } from "@/hooks/usePreventVideoFullscreen";
import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl";
import {
captureFrameFromUrlAtTime,
captureFrameFromVideoElement,
type CapturedVideoFrame,
} from "@/utils/videoFrameCapture";
interface CustomVideoPlayerProps {
src: string;
poster?: string | null;
selected?: boolean;
isRunning?: boolean;
dimensions?: { width: number; height: number } | null;
className?: string;
videoClassName?: string;
loop?: boolean;
muted?: boolean;
captureTitle?: string;
onLoadedMetadata?: (metadata: {
duration: number | null;
dimensions: { width: number; height: number } | null;
}) => void;
onCaptureFrame?: (frame: CapturedVideoFrame & { time: number }) => Promise<void> | void;
onError?: () => void;
}
type CaptureFrameMode = "first" | "last" | "current";
function formatTime(seconds: number) {
if (!Number.isFinite(seconds) || seconds <= 0) return "0:00";
const minutes = Math.floor(seconds / 60);
const remaining = Math.floor(seconds % 60).toString().padStart(2, "0");
return `${minutes}:${remaining}`;
}
export function CustomVideoPlayer({
src,
poster,
selected = false,
isRunning = false,
dimensions: _dimensions,
className = "",
videoClassName = "",
loop = true,
muted = true,
captureTitle = "截帧",
onLoadedMetadata,
onCaptureFrame,
onError,
}: CustomVideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const playbackUrl = useVideoBlobUrl(src);
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [isHovered, setIsHovered] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [isManuallyPaused, setIsManuallyPaused] = useState(false);
const [isScrubbing, setIsScrubbing] = useState(false);
const [isCaptureLoading, setIsCaptureLoading] = useState(false);
const [hasLoadedFrame, setHasLoadedFrame] = useState(false);
const [volume, setVolume] = useState(muted ? 0 : 1);
const [isMuted, setIsMuted] = useState(muted);
const shouldShowPoster = Boolean(
poster && (isRunning || (!isHovered && !selected && !isManuallyPaused && !isScrubbing))
);
const progress = duration > 0 ? Math.min(100, Math.max(0, (currentTime / duration) * 100)) : 0;
const pauseVideo = useCallback(() => {
const video = videoRef.current;
if (!video) return;
video.pause();
setIsPlaying(false);
}, []);
const playVideo = useCallback(() => {
const video = videoRef.current;
if (!video || isRunning) return;
video.play()
.then(() => {
setHasLoadedFrame(true);
setIsPlaying(true);
})
.catch(() => {
setIsPlaying(false);
});
}, [isRunning]);
useEffect(() => {
setCurrentTime(0);
setDuration(0);
setIsPlaying(false);
setIsManuallyPaused(false);
setIsScrubbing(false);
setHasLoadedFrame(false);
}, [src]);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
video.volume = Math.min(1, Math.max(0, volume));
video.muted = isMuted || volume === 0;
}, [isMuted, volume]);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
if (isRunning) {
video.pause();
try {
video.currentTime = 0;
} catch {
// Metadata may not be ready yet.
}
setCurrentTime(0);
setIsPlaying(false);
setHasLoadedFrame(false);
return;
}
if (isScrubbing || isManuallyPaused) {
video.pause();
setIsPlaying(false);
return;
}
if (isHovered || selected) {
playVideo();
return;
}
video.pause();
try {
video.currentTime = 0;
} catch {
// Metadata may not be ready yet.
}
setCurrentTime(0);
setIsPlaying(false);
setHasLoadedFrame(false);
}, [isHovered, isManuallyPaused, isRunning, isScrubbing, playVideo, selected]);
const seekToRatio = useCallback((clientX: number, target: HTMLElement) => {
const video = videoRef.current;
if (!video || duration <= 0) return;
const rect = target.getBoundingClientRect();
const ratio = rect.width > 0 ? Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)) : 0;
const nextTime = ratio * duration;
video.currentTime = nextTime;
setCurrentTime(nextTime);
setHasLoadedFrame(true);
}, [duration]);
const handleProgressPointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
setIsScrubbing(true);
setIsManuallyPaused(true);
pauseVideo();
seekToRatio(event.clientX, event.currentTarget);
}, [pauseVideo, seekToRatio]);
const handleProgressPointerMove = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (!isScrubbing) return;
event.preventDefault();
event.stopPropagation();
seekToRatio(event.clientX, event.currentTarget);
}, [isScrubbing, seekToRatio]);
const handleProgressPointerUp = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (!isScrubbing) return;
event.preventDefault();
event.stopPropagation();
seekToRatio(event.clientX, event.currentTarget);
setIsScrubbing(false);
setIsManuallyPaused(true);
pauseVideo();
}, [isScrubbing, pauseVideo, seekToRatio]);
const handleTogglePlayback = useCallback((event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (isRunning) return;
if (isPlaying) {
setIsManuallyPaused(true);
pauseVideo();
return;
}
setIsManuallyPaused(false);
playVideo();
}, [isPlaying, isRunning, pauseVideo, playVideo]);
const handleVolumeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
event.preventDefault();
event.stopPropagation();
const nextVolume = Number(event.currentTarget.value) / 100;
setVolume(nextVolume);
setIsMuted(nextVolume === 0);
}, []);
const handleCaptureFrame = useCallback(async (mode: CaptureFrameMode, event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
const video = videoRef.current;
if (!video || !onCaptureFrame || isCaptureLoading) return;
setIsCaptureLoading(true);
try {
const videoDuration = Number.isFinite(video.duration) ? video.duration : duration;
const time = mode === "first"
? 0.001
: mode === "last"
? Math.max(0, videoDuration - 0.1)
: video.currentTime;
let frame: CapturedVideoFrame;
if (mode === "current") {
try {
frame = await captureFrameFromVideoElement(video);
} catch (error) {
frame = await captureFrameFromUrlAtTime(src, time);
}
} else {
frame = await captureFrameFromUrlAtTime(src, time);
}
await onCaptureFrame({ ...frame, time });
} finally {
setIsCaptureLoading(false);
}
}, [duration, isCaptureLoading, onCaptureFrame, src]);
const playPauseLabel = useMemo(() => isPlaying ? "暂停" : "播放", [isPlaying]);
const volumeLabel = useMemo(() => {
if (isMuted || volume === 0) return "音量 0%";
return `音量 ${Math.round(volume * 100)}%`;
}, [isMuted, volume]);
const volumePopoverContent = useMemo(() => (
<div
className="flex flex-col items-center gap-1.5 px-1.5 py-2 text-white"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<span className="text-[10px] tabular-nums text-white/80">{Math.round(volume * 100)}</span>
<div className="relative flex h-24 w-5 items-center justify-center">
<div
className="absolute bottom-2 left-1/2 w-1 -translate-x-1/2 rounded-full bg-white"
style={{ height: `${Math.max(4, Math.round(volume * 80))}px` }}
/>
<input
aria-label="音量"
type="range"
min={0}
max={100}
value={Math.round(volume * 100)}
onChange={handleVolumeChange}
className="relative h-1 w-20 -rotate-90 cursor-pointer opacity-0"
/>
</div>
</div>
), [handleVolumeChange, volume]);
const capturePopoverContent = useMemo(() => (
<div className="overflow-hidden py-1">
{[
["first", "截取首帧"],
["last", "截取尾帧"],
["current", "截取当前帧"],
].map(([mode, label]) => (
<button
key={mode}
type="button"
onClick={(event) => handleCaptureFrame(mode as CaptureFrameMode, event)}
disabled={isCaptureLoading || isRunning || !hasLoadedFrame}
className="block w-full whitespace-nowrap px-3 py-2 text-left text-xs text-white/90 transition-colors hover:bg-white/12 disabled:cursor-not-allowed disabled:opacity-45"
>
{label}
</button>
))}
</div>
), [handleCaptureFrame, hasLoadedFrame, isCaptureLoading, isRunning]);
return (
<div
className={`relative h-full w-full overflow-hidden ${className}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onDoubleClickCapture={preventVideoDoubleClick}
onDoubleClick={preventVideoDoubleClick}
>
<video
ref={videoRef}
src={playbackUrl ?? undefined}
poster={poster ?? undefined}
className={videoClassName}
preload="auto"
loop={loop}
muted={muted}
playsInline
disablePictureInPicture
onDoubleClickCapture={preventVideoDoubleClick}
onDoubleClick={preventVideoDoubleClick}
onLoadedMetadata={(event) => {
const video = event.currentTarget;
const nextDuration = Number.isFinite(video.duration) ? video.duration : 0;
setDuration(nextDuration);
onLoadedMetadata?.({
duration: nextDuration || null,
dimensions: video.videoWidth && video.videoHeight
? { width: video.videoWidth, height: video.videoHeight }
: null,
});
}}
onLoadedData={() => setHasLoadedFrame(true)}
onTimeUpdate={(event) => {
if (!isScrubbing) setCurrentTime(event.currentTarget.currentTime);
}}
onPlay={() => {
setHasLoadedFrame(true);
setIsPlaying(true);
}}
onPause={() => setIsPlaying(false)}
onError={onError}
/>
{poster && shouldShowPoster && (
<img
src={poster}
alt=""
className="pointer-events-none absolute inset-0 h-full w-full object-contain"
/>
)}
{isRunning && (
<div className="pointer-events-none absolute inset-0 bg-black/20" />
)}
{!isRunning && !isPlaying && (
<button
type="button"
onClick={handleTogglePlayback}
className="nodrag nopan absolute left-1/2 top-1/2 flex h-12 w-12 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-black/55 text-white shadow-lg transition-colors hover:bg-black/70"
title={playPauseLabel}
>
<svg className="ml-0.5 h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
</button>
)}
<div className="nodrag nopan nowheel absolute bottom-0 left-0 right-0 flex items-center gap-2 px-3 pb-2 pt-5 text-white">
<button
type="button"
onClick={handleTogglePlayback}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-white/90 transition-colors hover:bg-white/15 hover:text-white"
title={playPauseLabel}
>
{isPlaying ? (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
</svg>
) : (
<svg className="ml-0.5 h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
<span className="w-9 shrink-0 text-[11px] tabular-nums text-white/85">{formatTime(currentTime)}</span>
<div
role="slider"
aria-valuemin={0}
aria-valuemax={Math.max(0, duration)}
aria-valuenow={Math.min(currentTime, duration)}
tabIndex={0}
className="relative h-5 min-w-0 flex-1 cursor-pointer touch-none"
onPointerDown={handleProgressPointerDown}
onPointerMove={handleProgressPointerMove}
onPointerUp={handleProgressPointerUp}
onPointerCancel={handleProgressPointerUp}
>
<div className="absolute left-0 right-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-white/30" />
<div
className="absolute left-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-white"
style={{ width: `${progress}%` }}
/>
<div
className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow"
style={{ left: `${progress}%` }}
/>
</div>
<span className="w-9 shrink-0 text-right text-[11px] tabular-nums text-white/85">{formatTime(duration)}</span>
<Popover
trigger="hover"
placement="top"
arrow={false}
color="rgba(0,0,0,0.28)"
content={volumePopoverContent}
overlayInnerStyle={{
padding: 0,
borderRadius: 8,
overflow: "hidden",
background: "rgba(0,0,0,0.28)",
boxShadow: "0 8px 22px rgba(0,0,0,0.16)",
minWidth: 0,
}}
>
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-white/90 transition-colors hover:bg-white/15 hover:text-white"
title={volumeLabel}
>
{isMuted || volume === 0 ? (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 10v4h4l5 4V6l-5 4H4Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="m17 9 4 4m0-4-4 4" />
</svg>
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 10v4h4l5 4V6l-5 4H4Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M16 9.5a4 4 0 0 1 0 5M18.5 7a7 7 0 0 1 0 10" />
</svg>
)}
</button>
</Popover>
{onCaptureFrame && (
<Popover
trigger="hover"
placement="topRight"
arrow={false}
color="rgba(0,0,0,0.28)"
content={capturePopoverContent}
overlayInnerStyle={{
padding: 0,
borderRadius: 8,
overflow: "hidden",
background: "rgba(0,0,0,0.28)",
boxShadow: "0 8px 22px rgba(0,0,0,0.16)",
minWidth: 0,
}}
>
<button
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
disabled={isCaptureLoading || isRunning || !hasLoadedFrame}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-white/90 transition-colors hover:bg-white/15 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
title={captureTitle}
>
{isCaptureLoading ? (
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 8.5A2.5 2.5 0 0 1 7.5 6H9l1.2-1.5h3.6L15 6h1.5A2.5 2.5 0 0 1 19 8.5v7A2.5 2.5 0 0 1 16.5 18h-9A2.5 2.5 0 0 1 5 15.5v-7Z" />
<circle cx="12" cy="12" r="2.5" />
</svg>
)}
</button>
</Popover>
)}
</div>
</div>
);
}

54
src/components/nodes/GenerateVideoNode.tsx

@ -13,8 +13,6 @@ import { ProviderModel } from "@/lib/providers/types";
import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog";
import { useToast } from "@/components/Toast";
import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl";
import { useVideoAutoplay } from "@/hooks/useVideoAutoplay";
import { preventVideoDoubleClick, usePreventVideoFullscreen } from "@/hooks/usePreventVideoFullscreen";
import { useInlineParameters } from "@/hooks/useInlineParameters";
import { useSelectedModelSchema } from "@/hooks/useSelectedModelSchema";
import { InlineParameterPanel } from "./InlineParameterPanel";
@ -34,6 +32,8 @@ import { NodeActionCapsule, type NodeActionCapsuleAction } from "./NodeActionCap
import { SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "@/utils/nodeHandles";
import { SaveToAssetLibraryModal } from "@/components/SaveToAssetLibraryModal";
import { asMediaDimensions, getAutoMediaElementClassName, getAutoMediaFrameClassName } from "./mediaAutoSize";
import { CustomVideoPlayer } from "@/components/media/CustomVideoPlayer";
import { createVideoFrameSmartImageNode } from "@/utils/videoFrameNode";
const VIDEO_LOAD_RETRY_DELAYS_MS = [600, 1600];
@ -63,8 +63,10 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV
const [videoRetryNonce, setVideoRetryNonce] = useState(0);
const videoBlobUrl = useVideoBlobUrl(nodeData.outputVideo ?? null);
const videoAutoplayRef = useVideoAutoplay(id, selected);
usePreventVideoFullscreen(videoAutoplayRef);
const addNode = useWorkflowStore((state) => state.addNode);
const getNodeById = useWorkflowStore((state) => state.getNodeById);
const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
useEffect(() => {
setVideoRetryNonce(0);
@ -159,7 +161,6 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV
refreshKey: nodeData.modelSchemaRequestId,
});
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const isRunning = useWorkflowStore((state) => state.runningNodeIds.has(id));
const handleRegenerate = useCallback(() => {
regenerateNode(id);
@ -315,6 +316,21 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV
const hasCarouselVideos = (nodeData.videoHistory || []).length > 1;
const handleCaptureFrame = useCallback(async (frame: {
dataUrl: string;
dimensions: { width: number; height: number };
}) => {
const currentNode = getNodeById(id);
if (!currentNode) return;
createVideoFrameSmartImageNode({
sourceNode: currentNode,
frame,
addNode,
selectSingleNode,
regenerateNode,
});
}, [addNode, getNodeById, id, regenerateNode, selectSingleNode]);
// Track previous status to detect error transitions
const prevStatusRef = useRef(nodeData.status);
@ -439,30 +455,24 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV
<div
className={nodeData.outputVideo ? getAutoMediaFrameClassName("bg-black") : "relative w-full h-full min-h-0 overflow-hidden rounded-lg"}
onDoubleClickCapture={preventVideoDoubleClick}
onDoubleClick={preventVideoDoubleClick}
>
{/* Preview area */}
{nodeData.outputVideo ? (
<>
<video
ref={videoAutoplayRef}
<CustomVideoPlayer
key={`${nodeData.videoHistory?.[nodeData.selectedVideoHistoryIndex || 0]?.id ?? nodeData.outputVideo}-${videoRetryNonce}`}
src={videoBlobUrl ?? undefined}
controls
controlsList="nofullscreen noremoteplayback"
disablePictureInPicture
src={nodeData.outputVideo}
poster={nodeData.outputVideoCover}
selected={selected}
isRunning={isRunning || nodeData.status === "loading"}
loop
muted
className={getAutoMediaElementClassName(asMediaDimensions(nodeData.dimensions))}
playsInline
onDoubleClickCapture={preventVideoDoubleClick}
onDoubleClick={preventVideoDoubleClick}
onLoadedMetadata={(event) => {
className="absolute inset-0 h-full w-full"
videoClassName={getAutoMediaElementClassName(asMediaDimensions(nodeData.dimensions))}
onCaptureFrame={handleCaptureFrame}
onLoadedMetadata={({ dimensions }) => {
setVideoRetryNonce(0);
const video = event.currentTarget;
if (video.videoWidth > 0 && video.videoHeight > 0) {
const dimensions = { width: video.videoWidth, height: video.videoHeight };
if (dimensions) {
const currentDimensions = asMediaDimensions(nodeData.dimensions);
if (currentDimensions?.width !== dimensions.width || currentDimensions?.height !== dimensions.height) {
updateMediaNodeData(id, { dimensions }, dimensions);
@ -472,7 +482,7 @@ export function GenerateVideoNodeView({ id, data, selected }: GenerateVideoNodeV
onError={() => {
const retryIndex = videoRetryNonce;
const retryDelay = VIDEO_LOAD_RETRY_DELAYS_MS[retryIndex];
if (retryDelay !== undefined && videoBlobUrl) {
if (retryDelay !== undefined && nodeData.outputVideo) {
window.setTimeout(() => {
setVideoRetryNonce((current) => current === retryIndex ? current + 1 : current);
}, retryDelay);

131
src/components/nodes/VideoInputNode.tsx

@ -8,8 +8,6 @@ import { BaseNode } from "./BaseNode";
import { NodeHandle } from "./NodeHandle";
import { useWorkflowStore } from "@/store/workflowStore";
import { VideoInputNodeData } from "@/types";
import { useVideoBlobUrl } from "@/hooks/useVideoBlobUrl";
import { preventVideoDoubleClick, usePreventVideoFullscreen } from "@/hooks/usePreventVideoFullscreen";
import { downloadMedia } from "@/utils/downloadMedia";
import { useI18n } from "@/i18n";
import { MediaPreviewModal } from "@/components/MediaPreviewModal";
@ -22,6 +20,8 @@ import { useSelectedNodeCount } from "@/hooks/useSelectedNodeCount";
import { NodeActionCapsule, type NodeActionCapsuleAction } from "./NodeActionCapsule";
import { SaveToAssetLibraryModal } from "@/components/SaveToAssetLibraryModal";
import { getAutoMediaElementClassName, getAutoMediaFrameClassName } from "./mediaAutoSize";
import { CustomVideoPlayer } from "@/components/media/CustomVideoPlayer";
import { createVideoFrameSmartImageNode } from "@/utils/videoFrameNode";
type VideoInputNodeType = Node<VideoInputNodeData, "videoInput">;
const VIDEO_INPUT_DIMENSIONS = defaultNodeDimensions.videoInput;
@ -37,16 +37,6 @@ const ACCEPTED_FORMATS = "video/mp4,video/webm,video/quicktime";
const ACCEPTED_MIME_TYPES = ACCEPTED_FORMATS.split(",");
const VIDEO_LOAD_RETRY_DELAYS_MS = [600, 1600];
function pauseVideoAtStart(video: HTMLVideoElement | null) {
if (!video) return;
video.pause();
try {
video.currentTime = 0;
} catch {
// Some browsers reject seeking before metadata is available.
}
}
function UploadIcon() {
return (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
@ -88,39 +78,24 @@ export function VideoInputNodeView({ id, data, selected }: VideoInputNodeViewPro
const nodeData = data;
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const updateMediaNodeData = useWorkflowStore((state) => state.updateMediaNodeData);
const addNode = useWorkflowStore((state) => state.addNode);
const getNodeById = useWorkflowStore((state) => state.getNodeById);
const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
const regenerateNode = useWorkflowStore((state) => state.regenerateNode);
const fileInputRef = useRef<HTMLInputElement>(null);
const videoPreviewRef = useRef<HTMLVideoElement | null>(null);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false);
const [isSaveToAssetLibraryOpen, setIsSaveToAssetLibraryOpen] = useState(false);
const [videoRetryNonce, setVideoRetryNonce] = useState(0);
const [hasRequestedVideoPlayback, setHasRequestedVideoPlayback] = useState(false);
const [isVideoFrameReady, setIsVideoFrameReady] = useState(false);
const isUploading = nodeData.uploadStatus === "loading";
const previewVideoPoster =
nodeData.previewVideoPoster && nodeData.previewVideoPoster !== nodeData.video
? nodeData.previewVideoPoster
: undefined;
const shouldRenderVideoElement = Boolean(nodeData.video && (!previewVideoPoster || hasRequestedVideoPlayback));
// Use blob URL for efficient playback of large base64 videos
const playbackUrl = useVideoBlobUrl(shouldRenderVideoElement ? nodeData.video ?? null : null);
usePreventVideoFullscreen(videoPreviewRef);
useEffect(() => {
setVideoRetryNonce(0);
setHasRequestedVideoPlayback(false);
setIsVideoFrameReady(false);
}, [nodeData.video, previewVideoPoster]);
useEffect(() => {
if (!selected) {
setHasRequestedVideoPlayback(false);
setIsVideoFrameReady(false);
}
}, [selected]);
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@ -309,6 +284,21 @@ export function VideoInputNodeView({ id, data, selected }: VideoInputNodeViewPro
}, [id, updateMediaNodeData, updateNodeData]);
const hasVideoAsset = Boolean(nodeData.video || previewVideoPoster);
const handleCaptureFrame = useCallback(async (frame: {
dataUrl: string;
dimensions: { width: number; height: number };
}) => {
const currentNode = getNodeById(id);
if (!currentNode) return;
createVideoFrameSmartImageNode({
sourceNode: currentNode,
frame,
addNode,
selectSingleNode,
regenerateNode,
});
}, [addNode, getNodeById, id, regenerateNode, selectSingleNode]);
const selectedNodeCount = useSelectedNodeCount();
const showSelectedActions = Boolean(selected && selectedNodeCount === 1);
const selectedActions: NodeActionCapsuleAction[] = hasVideoAsset
@ -408,45 +398,20 @@ export function VideoInputNodeView({ id, data, selected }: VideoInputNodeViewPro
<div
aria-label={`${nodeData.filename || t("videoInput.upload")} preview`}
className={getAutoMediaFrameClassName("bg-black")}
onMouseEnter={() => {
if (nodeData.video) {
setIsVideoFrameReady(false);
setHasRequestedVideoPlayback(true);
videoPreviewRef.current?.play().catch(() => {});
}
}}
onMouseLeave={() => {
pauseVideoAtStart(videoPreviewRef.current);
setHasRequestedVideoPlayback(false);
setIsVideoFrameReady(false);
}}
onDoubleClickCapture={preventVideoDoubleClick}
onDoubleClick={preventVideoDoubleClick}
>
{shouldRenderVideoElement ? (
<video
ref={videoPreviewRef}
key={`${playbackUrl ?? previewVideoPoster ?? "empty"}-${videoRetryNonce}`}
src={playbackUrl ?? undefined}
{nodeData.video ? (
<CustomVideoPlayer
key={`${nodeData.video ?? previewVideoPoster ?? "empty"}-${videoRetryNonce}`}
src={nodeData.video}
poster={previewVideoPoster}
controls={Boolean(nodeData.video)}
controlsList="nofullscreen noremoteplayback"
disablePictureInPicture
className={`${getAutoMediaElementClassName(nodeData.dimensions, "transition-opacity duration-150")} ${
!previewVideoPoster || isVideoFrameReady ? "opacity-100" : "opacity-0"
}`}
preload="auto"
selected={selected}
isRunning={isUploading || Boolean(nodeData.assetDetailLoading)}
className="absolute inset-0 h-full w-full"
videoClassName={getAutoMediaElementClassName(nodeData.dimensions, "transition-opacity duration-150")}
muted
playsInline
onDoubleClickCapture={preventVideoDoubleClick}
onDoubleClick={preventVideoDoubleClick}
onLoadedMetadata={(event) => {
onCaptureFrame={handleCaptureFrame}
onLoadedMetadata={({ duration, dimensions }) => {
setVideoRetryNonce(0);
const video = event.currentTarget;
const duration = Number.isFinite(video.duration) ? video.duration : null;
const dimensions = video.videoWidth && video.videoHeight
? { width: video.videoWidth, height: video.videoHeight }
: null;
if (
dimensions &&
(nodeData.dimensions?.width !== dimensions.width || nodeData.dimensions?.height !== dimensions.height)
@ -459,49 +424,23 @@ export function VideoInputNodeView({ id, data, selected }: VideoInputNodeViewPro
updateNodeData(id, { duration });
}
}}
onLoadedData={(event) => {
setIsVideoFrameReady(true);
if (hasRequestedVideoPlayback) {
event.currentTarget.play().catch(() => {});
}
}}
onCanPlay={(event) => {
setIsVideoFrameReady(true);
if (hasRequestedVideoPlayback) {
event.currentTarget.play().catch(() => {});
}
}}
onError={() => {
const retryIndex = videoRetryNonce;
const retryDelay = VIDEO_LOAD_RETRY_DELAYS_MS[retryIndex];
if (retryDelay !== undefined && playbackUrl) {
if (retryDelay !== undefined && nodeData.video) {
window.setTimeout(() => {
setVideoRetryNonce((current) => current === retryIndex ? current + 1 : current);
}, retryDelay);
}
}}
/>
) : null}
{previewVideoPoster && (
) : previewVideoPoster ? (
<img
src={previewVideoPoster}
alt={`${nodeData.filename || t("videoInput.upload")} poster`}
className={shouldRenderVideoElement
? `absolute inset-0 h-full w-full rounded-lg object-contain transition-opacity duration-150 ${
isVideoFrameReady ? "opacity-0" : "opacity-100"
}`
: getAutoMediaElementClassName(nodeData.dimensions, "transition-opacity duration-150 opacity-100")}
className={getAutoMediaElementClassName(nodeData.dimensions, "transition-opacity duration-150 opacity-100")}
/>
)}
{!isVideoFrameReady && nodeData.video && previewVideoPoster && (
<span className="pointer-events-none absolute inset-0 flex items-center justify-center bg-black/10">
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-black/55 text-white shadow-lg">
<svg className="ml-0.5 h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
</span>
</span>
)}
) : null}
{nodeData.isOptional && (
<span className="absolute bottom-2 left-2 text-[9px] font-medium text-neutral-300 bg-black/50 px-1.5 py-0.5 rounded">
{t("common.optional")}
@ -548,7 +487,7 @@ export function VideoInputNodeView({ id, data, selected }: VideoInputNodeViewPro
{isPreviewOpen && nodeData.video && (
<MediaPreviewModal
type="video"
src={playbackUrl ?? nodeData.video}
src={nodeData.video}
downloadSrc={nodeData.video}
alt={`${nodeData.filename || t("videoInput.upload")} preview`}
onClose={() => setIsPreviewOpen(false)}

44
src/store/execution/__tests__/derivedImageExecutor.test.ts

@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { executeDerivedImage, isDerivedImageExecutionNode } from "../derivedImageExecutor";
import type { NodeExecutionContext } from "../types";
import type { WorkflowNode } from "@/types";
@ -53,9 +53,14 @@ describe("derivedImageExecutor", () => {
vi.clearAllMocks();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("recognizes crop and splitGrid derived image nodes", () => {
expect(isDerivedImageExecutionNode({ derivedImage: { operation: "crop" } })).toBe(true);
expect(isDerivedImageExecutionNode({ derivedImage: { operation: "splitGrid" } })).toBe(true);
expect(isDerivedImageExecutionNode({ derivedImage: { operation: "videoFrame" } })).toBe(true);
expect(isDerivedImageExecutionNode({ derivedImage: { operation: "multiAngle" } })).toBe(false);
expect(isDerivedImageExecutionNode({ derivedImage: { operation: "highDefinition" } })).toBe(false);
});
@ -119,6 +124,43 @@ describe("derivedImageExecutor", () => {
});
});
it("uploads captured video frame data from the image node itself", async () => {
const blob = new Blob(["frame"], { type: "image/png" });
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
blob: vi.fn().mockResolvedValue(blob),
});
vi.stubGlobal("fetch", fetchMock);
vi.mocked(uploadBlobToGatewayMedia).mockResolvedValue({
url: "https://media.example/video-frame.png",
previewUrl: "https://media.example/video-frame-thumb.png",
filename: "video-frame.png",
contentType: "image/png",
});
const node = makeNode({
image: "data:image/png;base64,frame",
filename: "video-frame.png",
dimensions: { width: 1280, height: 720 },
derivedImage: {
sourceNodeId: "video-1",
operation: "videoFrame",
},
});
const ctx = makeCtx(node, []);
await executeDerivedImage(ctx);
expect(fetchMock).toHaveBeenCalledWith("data:image/png;base64,frame");
expect(uploadBlobToGatewayMedia).toHaveBeenCalledWith(blob, "video-frame.png", "image/png", "image");
expect(ctx.updateMediaNodeData).toHaveBeenCalledWith("derived-1", expect.objectContaining({
image: "https://media.example/video-frame.png",
previewImage: "https://media.example/video-frame-thumb.png",
dimensions: { width: 1280, height: 720 },
status: "complete",
error: null,
}), { width: 1280, height: 720 });
});
it("uses grid task coordinates to find the selected cell", async () => {
const blob = new Blob(["grid"], { type: "image/png" });
const drawImage = vi.fn();

4
src/store/execution/__tests__/nanoBananaExecutor.test.ts

@ -468,6 +468,8 @@ describe("executeNanoBanana", () => {
parameters: {
aiPlatform: "GATEWAY",
resolution: "4K",
modelMode: "general",
scale: 2,
batchSize: 1,
},
derivedImage: { sourceNodeId: "input-1", operation: "highDefinition" },
@ -520,6 +522,8 @@ describe("executeNanoBanana", () => {
parameters: {
aiPlatform: "GATEWAY",
resolution: "4K",
modelMode: "general",
scale: 2,
batchSize: 1,
},
});

13
src/store/execution/derivedImageExecutor.ts

@ -31,6 +31,14 @@ function isSplitGridTask(value: unknown): value is DerivedImageSplitGridTask {
);
}
async function dataUrlToBlob(dataUrl: string): Promise<Blob> {
const response = await fetch(dataUrl);
if (!response.ok) {
throw new Error("Captured frame could not be read.");
}
return response.blob();
}
async function createSplitGridCellBlob(
sourceImage: string,
task: DerivedImageSplitGridTask
@ -88,7 +96,7 @@ export async function executeDerivedImage(ctx: NodeExecutionContext): Promise<vo
if (!derivedImage) return;
const { images } = ctx.getConnectedInputs(ctx.node.id);
const sourceImage = images[0];
const sourceImage = derivedImage.operation === "videoFrame" ? nodeData.image : images[0];
if (!sourceImage) {
const message = "Missing source image.";
ctx.updateNodeData(ctx.node.id, { status: "error", error: message });
@ -124,6 +132,9 @@ export async function executeDerivedImage(ctx: NodeExecutionContext): Promise<vo
const result = await createSplitGridCellBlob(sourceImage, derivedImage.task);
blob = result.blob;
dimensions = result.dimensions;
} else if (derivedImage.operation === "videoFrame") {
blob = await dataUrlToBlob(sourceImage);
dimensions = nodeData.dimensions ?? { width: 0, height: 0 };
} else {
return;
}

2
src/types/nodes.ts

@ -102,7 +102,7 @@ export interface ImageInputNodeData extends BaseNodeData {
isOptional?: boolean;
derivedImage?: {
sourceNodeId: string;
operation: "crop" | "multiAngle" | "splitGrid" | "highDefinition" | "outpainting" | "inpainting";
operation: "crop" | "multiAngle" | "splitGrid" | "highDefinition" | "outpainting" | "inpainting" | "videoFrame";
task?: DerivedImageTask;
};
}

2
src/utils/__tests__/highDefinitionNodes.test.ts

@ -36,6 +36,8 @@ describe("highDefinitionNodes", () => {
parameters: expect.objectContaining({
aiPlatform: "GATEWAY",
resolution: "4K",
modelMode: "general",
scale: 2,
batchSize: 1,
}),
derivedImage: {

4
src/utils/highDefinitionNodes.ts

@ -7,6 +7,8 @@ import { getImageDimensions } from "@/utils/nodeDimensions";
export const HIGH_DEFINITION_SUBTYPE = 107;
export const HIGH_DEFINITION_RESOLUTION = "4K";
export const HIGH_DEFINITION_MODEL_MODE = "general";
export const HIGH_DEFINITION_SCALE = 2;
export const HIGH_DEFINITION_BATCH_SIZE = 1;
export const HIGH_DEFINITION_MODEL_CODE = "jimeng_i2i_seed3_tilesr_cvtob";
@ -45,6 +47,8 @@ export function buildHighDefinitionParameters(
...parameters,
aiPlatform: "GATEWAY",
resolution: typeof parameters.resolution === "string" && parameters.resolution ? parameters.resolution : HIGH_DEFINITION_RESOLUTION,
modelMode: typeof parameters.modelMode === "string" && parameters.modelMode ? parameters.modelMode : HIGH_DEFINITION_MODEL_MODE,
scale: typeof parameters.scale === "number" && Number.isFinite(parameters.scale) ? parameters.scale : HIGH_DEFINITION_SCALE,
batchSize: HIGH_DEFINITION_BATCH_SIZE,
};
}

6
src/utils/smartImageMode.ts

@ -2,9 +2,9 @@ import type { WorkflowEdge } from "@/types";
import { SINGLE_INPUT_HANDLE_ID } from "@/utils/nodeHandles";
export type SmartImageMode = "neutral" | "image" | "generate";
export type SmartImageAutoTaskOperation = "crop" | "splitGrid" | "multiAngle" | "highDefinition" | "outpainting" | "inpainting";
export const SMART_IMAGE_AUTO_TASK_OPERATIONS: SmartImageAutoTaskOperation[] = ["crop", "splitGrid", "multiAngle", "highDefinition", "outpainting", "inpainting"];
export const SMART_IMAGE_LOCAL_DERIVED_TASK_OPERATIONS: SmartImageAutoTaskOperation[] = ["crop", "splitGrid"];
export type SmartImageAutoTaskOperation = "crop" | "splitGrid" | "multiAngle" | "highDefinition" | "outpainting" | "inpainting" | "videoFrame";
export const SMART_IMAGE_AUTO_TASK_OPERATIONS: SmartImageAutoTaskOperation[] = ["crop", "splitGrid", "multiAngle", "highDefinition", "outpainting", "inpainting", "videoFrame"];
export const SMART_IMAGE_LOCAL_DERIVED_TASK_OPERATIONS: SmartImageAutoTaskOperation[] = ["crop", "splitGrid", "videoFrame"];
export const SMART_IMAGE_AI_DERIVED_TASK_OPERATIONS: SmartImageAutoTaskOperation[] = ["multiAngle", "highDefinition", "outpainting", "inpainting"];
export const SMART_IMAGE_ONE_SHOT_GENERATION_OPERATIONS: SmartImageAutoTaskOperation[] = ["inpainting"];

113
src/utils/videoFrameCapture.ts

@ -0,0 +1,113 @@
export interface CapturedVideoFrame {
blob: Blob;
dataUrl: string;
dimensions: { width: number; height: number };
}
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
return;
}
reject(new Error("Frame capture returned no image"));
}, "image/png");
});
}
function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result);
return;
}
reject(new Error("Frame capture could not be encoded"));
};
reader.onerror = () => reject(new Error("Frame capture could not be encoded"));
reader.readAsDataURL(blob);
});
}
export async function captureFrameFromVideoElement(video: HTMLVideoElement): Promise<CapturedVideoFrame> {
if (!video.videoWidth || !video.videoHeight) {
throw new Error("Video frame is not ready");
}
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Could not get canvas 2d context");
}
context.drawImage(video, 0, 0, canvas.width, canvas.height);
const blob = await canvasToBlob(canvas);
const dataUrl = await blobToDataUrl(blob);
return {
blob,
dataUrl,
dimensions: { width: canvas.width, height: canvas.height },
};
}
function getFetchableMediaUrl(url: string): string {
if (url.startsWith("http://") || url.startsWith("https://")) {
return `/api/proxy-media?url=${encodeURIComponent(url)}`;
}
return url;
}
function waitForVideoEvent(video: HTMLVideoElement, eventName: "loadedmetadata" | "seeked"): Promise<void> {
return new Promise((resolve, reject) => {
const timeoutId = window.setTimeout(() => {
cleanup();
reject(new Error("Frame capture timed out"));
}, 30_000);
const cleanup = () => {
window.clearTimeout(timeoutId);
video.removeEventListener(eventName, handleEvent);
video.removeEventListener("error", handleError);
};
const handleEvent = () => {
cleanup();
resolve();
};
const handleError = () => {
cleanup();
reject(new Error("Failed to load video for frame capture"));
};
video.addEventListener(eventName, handleEvent);
video.addEventListener("error", handleError);
});
}
export async function captureFrameFromUrlAtTime(src: string, time: number): Promise<CapturedVideoFrame> {
const response = await fetch(getFetchableMediaUrl(src));
if (!response.ok) {
throw new Error(`Frame capture video fetch failed: HTTP ${response.status}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const video = document.createElement("video");
video.preload = "auto";
video.muted = true;
video.playsInline = true;
try {
video.src = blobUrl;
await waitForVideoEvent(video, "loadedmetadata");
const duration = Number.isFinite(video.duration) ? video.duration : 0;
video.currentTime = Math.min(Math.max(0, time), Math.max(0, duration - 0.001));
await waitForVideoEvent(video, "seeked");
return captureFrameFromVideoElement(video);
} finally {
URL.revokeObjectURL(blobUrl);
}
}

52
src/utils/videoFrameNode.ts

@ -0,0 +1,52 @@
import type { NodeType, SmartImageNodeData, WorkflowNode } from "@/types";
import type { CapturedVideoFrame } from "@/utils/videoFrameCapture";
type AddNode = (
type: NodeType,
position: { x: number; y: number },
initialData?: Partial<SmartImageNodeData>
) => string;
function getNodeWidth(node: WorkflowNode): number {
if (typeof node.width === "number") return node.width;
if (typeof node.style?.width === "number") return node.style.width;
return 300;
}
export function createVideoFrameSmartImageNode({
sourceNode,
frame,
addNode,
selectSingleNode,
regenerateNode,
}: {
sourceNode: WorkflowNode;
frame: Pick<CapturedVideoFrame, "dataUrl" | "dimensions">;
addNode: AddNode;
selectSingleNode: (nodeId: string) => void;
regenerateNode: (nodeId: string) => Promise<void>;
}): string {
const sourcePosition = sourceNode.position ?? { x: 0, y: 0 };
const nodeId = addNode("smartImage", {
x: sourcePosition.x + getNodeWidth(sourceNode) + 220,
y: sourcePosition.y,
}, {
image: frame.dataUrl,
imageRef: undefined,
assetId: undefined,
previewImage: frame.dataUrl,
filename: `video-frame-${Date.now()}.png`,
dimensions: frame.dimensions,
customTitle: "视频截帧",
derivedImage: {
sourceNodeId: sourceNode.id,
operation: "videoFrame",
},
status: "idle",
error: null,
});
selectSingleNode(nodeId);
void regenerateNode(nodeId);
return nodeId;
}
Loading…
Cancel
Save