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()} {renderHandles()}
<div className="flex-1 flex flex-col min-h-0 gap-2"> <div className="flex-1 flex flex-col min-h-0 gap-2">
{/* Filmstrip UI */} {/* Filmstrip + controls area (shrink-0: only takes space it needs) */}
<div className="flex-1 flex flex-col min-h-0 gap-2"> <div className="shrink-0 flex flex-col gap-2">
{orderedClips.length === 0 ? ( {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> <span className="text-[10px] text-neutral-500">Connect videos to stitch</span>
</div> </div>
) : ( ) : (
<> <>
{/* Filmstrip */} {/* 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) => { {orderedClips.map((clip) => {
const thumbnail = thumbnails.get(clip.edgeId); const thumbnail = thumbnails.get(clip.edgeId);
return ( return (
@ -511,14 +511,6 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
})} })}
</div> </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> </div>
@ -549,16 +541,16 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
</div> </div>
)} )}
{/* Output preview */} {/* Output preview (flex-1: grows with node) */}
{nodeData.outputVideo && nodeData.status !== "loading" && ( {nodeData.outputVideo && nodeData.status !== "loading" && (
<div className="relative w-full"> <div className="relative flex-1 min-h-0">
<video <video
src={nodeData.outputVideo} src={nodeData.outputVideo}
controls controls
autoPlay autoPlay
loop loop
muted muted
className="w-full h-auto max-h-40 object-contain rounded" className="w-full h-full object-contain rounded"
playsInline playsInline
/> />
<button <button
@ -572,6 +564,36 @@ export function VideoStitchNode({ id, data, selected }: NodeProps<VideoStitchNod
</button> </button>
</div> </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> </div>
</BaseNode> </BaseNode>
); );

1
src/store/utils/nodeDefaults.ts

@ -195,6 +195,7 @@ export const createDefaultNodeData = (type: NodeType): WorkflowNodeData => {
clips: [], clips: [],
clipOrder: [], clipOrder: [],
outputVideo: null, outputVideo: null,
loopCount: 1,
status: "idle", status: "idle",
error: null, error: null,
progress: 0, 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())) 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 // Prepare audio if connected
let audioData = null; let audioData = null;
if (inputs.audio.length > 0 && inputs.audio[0]) { if (inputs.audio.length > 0 && inputs.audio[0]) {
@ -2053,7 +2062,7 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
// Stitch videos // Stitch videos
const { stitchVideosAsync } = await import('@/hooks/useStitchVideos'); const { stitchVideosAsync } = await import('@/hooks/useStitchVideos');
const outputBlob = await stitchVideosAsync( const outputBlob = await stitchVideosAsync(
videoBlobs, loopedBlobs,
audioData, audioData,
(progress) => { (progress) => {
updateNodeData(node.id, { progress: progress.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())) 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; let audioData = null;
if (inputs.audio.length > 0 && inputs.audio[0]) { if (inputs.audio.length > 0 && inputs.audio[0]) {
const { prepareAudioAsync } = await import('@/hooks/useAudioMixing'); const { prepareAudioAsync } = await import('@/hooks/useAudioMixing');
@ -2976,7 +2993,7 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
const { stitchVideosAsync } = await import('@/hooks/useStitchVideos'); const { stitchVideosAsync } = await import('@/hooks/useStitchVideos');
const outputBlob = await stitchVideosAsync( const outputBlob = await stitchVideosAsync(
videoBlobs, loopedBlobs,
audioData, audioData,
(progress) => { (progress) => {
updateNodeData(nodeId, { progress: progress.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 clips: VideoStitchClip[]; // Ordered clip sequence for filmstrip
clipOrder: string[]; // Edge IDs in user-defined order (drag reorder) clipOrder: string[]; // Edge IDs in user-defined order (drag reorder)
outputVideo: string | null; // Stitched video blob URL or data URL 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; status: NodeStatus;
error: string | null; error: string | null;
progress: number; // 0-100 processing progress progress: number; // 0-100 processing progress

Loading…
Cancel
Save