From 2bb30395d8349aa13bbbef7e80751dc4d43d59be Mon Sep 17 00:00:00 2001 From: Towsty Date: Thu, 27 Aug 2026 14:57:50 -0500 Subject: [PATCH] Tick off saved shots in studio and Queue so an interrupted run shows where it stopped and which scene to continue. Co-authored-by: Cursor --- pages/index.vue | 113 ++++++++++++++++++++++++++++-- pages/queue.vue | 180 +++++++++++++++++++++++++++++++++++++----------- 2 files changed, 248 insertions(+), 45 deletions(-) diff --git a/pages/index.vue b/pages/index.vue index f3676e2..12af030 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -616,19 +616,23 @@ Step {{ editChainStep }}/{{ editChainTotal }}{{ editChainLabel ? `: ${editChainLabel}` : '' }}

-
    +
    1. - + {{ shotCheckMark(part) }} {{ part.label }} {{ part.prompt }} {{ formatClipDuration(part.duration) }}
    +

    + {{ shotChecklistResume }} + Open Queue +

    1. +} | null>(null) const pickerOpen = ref(false) const pickerSlot = ref<'main' | 'editRef' | number>('main') const libraryFilter = ref<'all' | 'images' | 'videos'>('all') @@ -1663,6 +1677,36 @@ const plannedChain = computed(() => { })) ] }) +const shotChecklist = computed(() => { + if (liveQueue.value?.segments?.length) { + return liveQueue.value.segments.map(segment => ({ + label: `Shot ${segment.index + 1}`, + prompt: segment.prompt, + duration: segment.duration, + status: segment.status, + clipId: segment.clipId + })) + } + return activeChainPlan.value.map((part, index) => ({ + label: part.label, + prompt: part.prompt, + duration: part.duration, + status: index < completedChainStep.value + ? 'complete' + : (videoBusy.value && index === completedChainStep.value ? 'running' : (index === completedChainStep.value ? 'pending' : 'pending')), + clipId: undefined as string | undefined + })) +}) +const showShotChecklist = computed(() => ( + shotChecklist.value.length > 1 && (videoBusy.value || shotChecklist.value.some(part => part.status !== 'complete')) +)) +const shotChecklistResume = computed(() => { + if (videoBusy.value) return '' + const next = shotChecklist.value.find(part => part.status === 'pending' || part.status === 'error' || part.status === 'running') + const done = shotChecklist.value.filter(part => part.status === 'complete').length + if (!next || !done) return '' + return `Left off after shot ${done} of ${shotChecklist.value.length}. Continue with ${next.label}.` +}) const visibleClips = computed(() => clips.value.filter(clip => clip.folderId === browseFolderId.value && clip.hasVideo !== false)) const visibleOutputStills = computed(() => stills.value.filter(still => still.folderId === browseFolderId.value && still.role === 'output')) @@ -1862,6 +1906,27 @@ function chainPartDot(index: number) { return 'bg-zinc-600' } +function shotCheckClass(part: { status: string }) { + if (part.status === 'complete') return 'border border-emerald-400/20 bg-emerald-400/10 text-emerald-100' + if (part.status === 'running') return 'border border-amber-300/40 bg-amber-400/10 text-amber-100' + if (part.status === 'error') return 'border border-red-400/30 bg-red-400/10 text-red-200' + return 'border border-white/5 text-zinc-400' +} + +function shotCheckMarkClass(part: { status: string }) { + if (part.status === 'complete') return 'bg-emerald-400 text-zinc-950' + if (part.status === 'running') return 'bg-amber-400 text-zinc-950' + if (part.status === 'error') return 'bg-red-400 text-zinc-950' + return 'border border-white/20 text-zinc-500' +} + +function shotCheckMark(part: { status: string; label: string }) { + if (part.status === 'complete') return '✓' + if (part.status === 'running') return '▶' + if (part.status === 'error') return '!' + return part.label.replace(/\D/g, '') || '•' +} + function editChainPartDone(index: number) { return index < editCompletedChainStep.value } @@ -1917,6 +1982,7 @@ async function loadLibrary() { ]) applyLibrary(data) queuePendingCount.value = queued.pendingCount || 0 + await refreshLiveQueue() } function applyComfyHealth(health: { @@ -1957,6 +2023,7 @@ onMounted(async () => { autoplayEnabled.value = localStorage.getItem('aigen-autoplay') === 'true' videoWorkflow.value = localStorage.getItem('aigen-video-workflow') === 'v2' ? 'v2' : 'v1' useIdentityRefs.value = localStorage.getItem('aigen-use-identity-refs') === 'true' + activeQueueId.value = localStorage.getItem(QUEUE_STORE) || '' } catch { /* ignore */ } applyInstancePrivacy() window.addEventListener('pointerdown', onSitePointerDown, true) @@ -1973,8 +2040,10 @@ onMounted(async () => { applyInstancePrivacy() healthTimer = setInterval(() => { void pollComfyHealth() + if (activeQueueId.value) void refreshLiveQueue() }, 5000) await resumeActiveJob() + await refreshLiveQueue() }) onBeforeUnmount(() => { @@ -2228,6 +2297,33 @@ function persistActiveJob(kind: 'video' | 'edit', id: string, hidden: boolean, f } catch { /* ignore */ } } +function persistActiveQueue(id: string) { + activeQueueId.value = id + try { + localStorage.setItem(QUEUE_STORE, id) + } catch { /* ignore */ } +} + +async function refreshLiveQueue() { + const id = activeQueueId.value + if (!id) return + const queue = await $fetch<{ + id: string + status: string + pendingCount: number + completedCount: number + totalCount: number + segments: Array<{ index: number; prompt: string; duration: number; status: string; clipId?: string }> + }>(`/api/queues/${id}`).catch(() => null) + if (!queue) return + liveQueue.value = queue + queuePendingCount.value = queue.status === 'complete' ? queuePendingCount.value : queue.pendingCount + completedChainStep.value = queue.completedCount + if (queue.status === 'complete' && !videoBusy.value) { + try { localStorage.removeItem(QUEUE_STORE) } catch { /* ignore */ } + } +} + function clearActiveJob(kind?: 'video' | 'edit') { try { if (!kind || kind === 'video') localStorage.removeItem(JOB_STORE) @@ -3142,7 +3238,7 @@ async function generate() { body.append('queueAutoRun', String(queueAutoRun.value)) } videoDownloadName.value = clipFileName(clipName.value.trim() || initialPrompt.split(/[.!?\n]/)[0] || 'aigen') - const started = await $fetch<{ jobId: string; seed: number; width: number; height: number; steps: number; turbo: boolean; hideThumbnail?: boolean; folderLocked?: boolean }>('/api/generate', { + const started = await $fetch<{ jobId: string; seed: number; width: number; height: number; steps: number; turbo: boolean; hideThumbnail?: boolean; folderLocked?: boolean; queueId?: string }>('/api/generate', { method: 'POST', body }) @@ -3151,6 +3247,10 @@ async function generate() { playerSize.value = { width: started.width, height: started.height } videoSettledUi = false persistActiveJob('video', started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true) + if (started.queueId) { + persistActiveQueue(started.queueId) + void refreshLiveQueue() + } listen('video', started.jobId, started.hideThumbnail === true || hideThumbnail.value, started.folderLocked === true) void pollComfyHealth() } catch (error: any) { @@ -3278,6 +3378,7 @@ function applyEditEvent(payload: Record, hidden: boolean, folderLoc } function applyVideoEvent(payload: Record, hidden: boolean, folderLocked: boolean) { + if (payload.queueId) persistActiveQueue(String(payload.queueId)) if (payload.message) statusMessage.value = payload.message statusBusy.value = payload.type === 'busy' || payload.busy === true if (typeof payload.progress === 'number') progress.value = payload.progress @@ -3297,6 +3398,7 @@ function applyVideoEvent(payload: Record, hidden: boolean, folderLo if (payload.type === 'checkpoint' && payload.clipId) { completedChainStep.value = Math.max(completedChainStep.value, Number(payload.chainStep || chainStep.value) || 0) loadLibrary().catch(() => null) + void refreshLiveQueue() const locked = folderLocked || payload.folderLocked === true if (!watchingLibrary.value && !locked) { currentClipId.value = payload.clipId @@ -3340,6 +3442,7 @@ function applyVideoEvent(payload: Record, hidden: boolean, folderLo progress.value = Number(payload.progress || progress.value || 100) toast(`Shot ${step} of ${total} saved. ${left} shot${left === 1 ? '' : 's'} waiting on Queue — edit prompts or process next.`) queuePendingCount.value = left + void refreshLiveQueue() videoBusy.value = false statusBusy.value = false stopTimer('video') diff --git a/pages/queue.vue b/pages/queue.vue index 8809d18..98775af 100644 --- a/pages/queue.vue +++ b/pages/queue.vue @@ -23,7 +23,7 @@

      Queue

      - Multi-shot batches wait here after the first clip. Edit remaining prompts, process the next shot, the next few, or run the rest overnight. + Each shot ticks off when it saves. If a run stops, continue from the next unchecked scene.

      {{ pendingCount }} shot{{ pendingCount === 1 ? '' : 's' }} waiting

      @@ -42,12 +42,8 @@

      {{ queue.name }}

      -

      - Shot {{ queue.completedCount }} of {{ queue.totalCount }} - · processing - · paused - · finished - · needs attention +

      + {{ progressLine(queue) }}

      {{ liveMessage[queue.id] }}

      {{ queue.lastError }}

      @@ -59,7 +55,7 @@ :disabled="busyId === queue.id || queue.status === 'running' || queue.status === 'complete' || queue.pendingCount === 0" @click="process(queue, 1)" > - Process next + {{ continueLabel(queue) }}
      -
      -
      +
    2. -
      -

      - {{ segment.index === 0 ? 'Shot 1 · initial' : `Shot ${segment.index + 1}` }} - · {{ segment.status }} -

      - {{ segment.duration }}s +
      + {{ markLabel(queue, segment) }} +
      +
      +

      + Shot {{ segment.index + 1 }} + {{ segmentCaption(queue, segment) }} +

      + {{ segment.duration }}s +
      +

      {{ segment.prompt }}

      +