diff --git a/src/components/nodes/VideoStitchNode.tsx b/src/components/nodes/VideoStitchNode.tsx index 2f3ef73d..9ae54545 100644 --- a/src/components/nodes/VideoStitchNode.tsx +++ b/src/components/nodes/VideoStitchNode.tsx @@ -431,16 +431,16 @@ export function VideoStitchNode({ id, data, selected }: NodeProps - {/* Filmstrip UI */} -
+ {/* Filmstrip + controls area (shrink-0: only takes space it needs) */} +
{orderedClips.length === 0 ? ( -
+
Connect videos to stitch
) : ( <> {/* Filmstrip */} -
+
{orderedClips.map((clip) => { const thumbnail = thumbnails.get(clip.edgeId); return ( @@ -511,14 +511,6 @@ export function VideoStitchNode({ id, data, selected }: NodeProps - {/* Stitch button */} - )}
@@ -549,16 +541,16 @@ export function VideoStitchNode({ id, data, selected }: NodeProps )} - {/* Output preview */} + {/* Output preview (flex-1: grows with node) */} {nodeData.outputVideo && nodeData.status !== "loading" && ( -
+
)} + + {/* Controls row: Loop selector + Stitch button (below video, right-aligned) */} + {orderedClips.length > 0 && ( +
+
+ Loop + {([1, 2, 3] as const).map((count) => ( + + ))} +
+ + +
+ )}
); diff --git a/src/store/utils/nodeDefaults.ts b/src/store/utils/nodeDefaults.ts index 96133b82..f5657549 100644 --- a/src/store/utils/nodeDefaults.ts +++ b/src/store/utils/nodeDefaults.ts @@ -195,6 +195,7 @@ export const createDefaultNodeData = (type: NodeType): WorkflowNodeData => { clips: [], clipOrder: [], outputVideo: null, + loopCount: 1, status: "idle", error: null, progress: 0, diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index f7acc5b0..a8db1854 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -2036,6 +2036,15 @@ export const useWorkflowStore = create((set, get) => ({ inputs.videos.map((v) => fetch(v).then((r) => r.blob())) ); + // Duplicate blobs based on loopCount (2x or 3x repeats the sequence) + // Each copy must be a new Blob so BlobSource can read it independently + const loopCount = nodeData.loopCount || 1; + const loopedBlobs = loopCount > 1 + ? Array.from({ length: loopCount }, () => + videoBlobs.map((b) => new Blob([b], { type: b.type })) + ).flat() + : videoBlobs; + // Prepare audio if connected let audioData = null; if (inputs.audio.length > 0 && inputs.audio[0]) { @@ -2053,7 +2062,7 @@ export const useWorkflowStore = create((set, get) => ({ // Stitch videos const { stitchVideosAsync } = await import('@/hooks/useStitchVideos'); const outputBlob = await stitchVideosAsync( - videoBlobs, + loopedBlobs, audioData, (progress) => { updateNodeData(node.id, { progress: progress.progress }); @@ -2962,6 +2971,14 @@ export const useWorkflowStore = create((set, get) => ({ inputs.videos.map((v) => fetch(v).then((r) => r.blob())) ); + // Duplicate blobs based on loopCount (2x or 3x repeats the sequence) + const loopCount = nodeData.loopCount || 1; + const loopedBlobs = loopCount > 1 + ? Array.from({ length: loopCount }, () => + videoBlobs.map((b) => new Blob([b], { type: b.type })) + ).flat() + : videoBlobs; + let audioData = null; if (inputs.audio.length > 0 && inputs.audio[0]) { const { prepareAudioAsync } = await import('@/hooks/useAudioMixing'); @@ -2976,7 +2993,7 @@ export const useWorkflowStore = create((set, get) => ({ const { stitchVideosAsync } = await import('@/hooks/useStitchVideos'); const outputBlob = await stitchVideosAsync( - videoBlobs, + loopedBlobs, audioData, (progress) => { updateNodeData(nodeId, { progress: progress.progress }); diff --git a/src/types/nodes.ts b/src/types/nodes.ts index 9cf5b111..cf02c5b9 100644 --- a/src/types/nodes.ts +++ b/src/types/nodes.ts @@ -233,6 +233,7 @@ export interface VideoStitchNodeData extends BaseNodeData { clips: VideoStitchClip[]; // Ordered clip sequence for filmstrip clipOrder: string[]; // Edge IDs in user-defined order (drag reorder) outputVideo: string | null; // Stitched video blob URL or data URL + loopCount: 1 | 2 | 3; // How many times to repeat the clip sequence (1 = no loop) status: NodeStatus; error: string | null; progress: number; // 0-100 processing progress