Browse Source

Merge pull request #58 from shrimbly/fix/video-stitch-loop-count-v2

fix: apply loopCount in videoStitch regenerateNode path
handoff-20260429-1057
Willie 5 months ago
committed by GitHub
parent
commit
8d6edce1f7
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 52
      src/components/nodes/VideoStitchNode.tsx
  2. 1
      src/store/utils/nodeDefaults.ts
  3. 21
      src/store/workflowStore.ts
  4. 1
      src/types/nodes.ts

52
src/components/nodes/VideoStitchNode.tsx

@ -431,16 +431,16 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
{renderHandles()}
<div className="flex-1 flex flex-col min-h-0 gap-2">
{/* Filmstrip UI */}
<div className="flex-1 flex flex-col min-h-0 gap-2">
{/* Filmstrip + controls area (shrink-0: only takes space it needs) */}
<div className="shrink-0 flex flex-col gap-2">
{orderedClips.length === 0 ? (
<div className="flex-1 flex items-center justify-center border border-dashed border-neutral-600 rounded">
<div className="h-16 flex items-center justify-center border border-dashed border-neutral-600 rounded">
<span className="text-[10px] text-neutral-500">Connect videos to stitch</span>
</div>
) : (
<>
{/* Filmstrip */}
<div className="flex-1 overflow-y-auto nowheel grid grid-cols-4 content-start gap-2 p-2 bg-neutral-900/50 rounded">
<div className="overflow-y-auto nowheel grid grid-cols-4 content-start gap-2 p-2 bg-neutral-900/50 rounded">
{orderedClips.map((clip) => {
const thumbnail = thumbnails.get(clip.edgeId);
return (
@ -511,14 +511,6 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
})}
</div>
{/* Stitch button */}
<button
onClick={handleStitch}
disabled={orderedClips.length < 2 || nodeData.status === "loading" || isRunning}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:bg-neutral-700 disabled:text-neutral-500 disabled:cursor-not-allowed rounded text-white text-xs font-medium transition-colors"
>
{nodeData.status === "loading" ? "Processing..." : "Stitch"}
</button>
</>
)}
</div>
@ -549,16 +541,16 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
</div>
)}
{/* Output preview */}
{/* Output preview (flex-1: grows with node) */}
{nodeData.outputVideo && nodeData.status !== "loading" && (
<div className="relative w-full">
<div className="relative flex-1 min-h-0">
<video
src={nodeData.outputVideo}
controls
autoPlay
loop
muted
className="w-full h-auto max-h-40 object-contain rounded"
className="w-full h-full object-contain rounded"
playsInline
/>
<button
@ -572,6 +564,36 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
</button>
</div>
)}
{/* Controls row: Loop selector + Stitch button (below video, right-aligned) */}
{orderedClips.length > 0 && (
<div className="shrink-0 flex items-center justify-end gap-2">
<div className="flex items-center gap-1">
<span className="text-[10px] text-neutral-400">Loop</span>
{([1, 2, 3] as const).map((count) => (
<button
key={count}
onClick={() => updateNodeData(id, { loopCount: count })}
className={`nodrag px-1.5 py-0.5 rounded text-[10px] font-medium transition-colors ${
(nodeData.loopCount || 1) === count
? "bg-blue-600 text-white"
: "bg-neutral-700 text-neutral-400 hover:bg-neutral-600 hover:text-neutral-300"
}`}
>
{count}x
</button>
))}
</div>
<button
onClick={handleStitch}
disabled={orderedClips.length < 2 || nodeData.status === "loading" || isRunning}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 disabled:bg-neutral-700 disabled:text-neutral-500 disabled:cursor-not-allowed rounded text-white text-xs font-medium transition-colors"
>
{nodeData.status === "loading" ? "Processing..." : "Stitch"}
</button>
</div>
)}
</div>
</BaseNode>
);

1
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,

21
src/store/workflowStore.ts

@ -2036,6 +2036,15 @@ export const useWorkflowStore = create<WorkflowStore>((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<WorkflowStore>((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<WorkflowStore>((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<WorkflowStore>((set, get) => ({
const { stitchVideosAsync } = await import('@/hooks/useStitchVideos');
const outputBlob = await stitchVideosAsync(
videoBlobs,
loopedBlobs,
audioData,
(progress) => {
updateNodeData(nodeId, { progress: progress.progress });

1
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

Loading…
Cancel
Save