Group extension chains in the library and add shot-script queues.
Keep every saved segment, show the longest clip with an expand list, branch on extend, and parse shot 2/3 blocks into a shared-duration queue with live part status.
This commit is contained in:
+255
-55
@@ -58,16 +58,45 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="mb-2 block text-sm font-medium text-zinc-300">Motion & scene prompt</label>
|
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<label class="text-sm font-medium text-zinc-300">Motion & scene prompt</label>
|
||||||
|
<label class="flex cursor-pointer items-center gap-2 text-xs text-zinc-400">
|
||||||
|
<span class="relative inline-flex h-5 w-9 shrink-0 items-center">
|
||||||
|
<input v-model="shotScriptMode" type="checkbox" class="peer sr-only" role="switch">
|
||||||
|
<span class="absolute inset-0 rounded-full bg-zinc-700 transition peer-checked:bg-amber-400" />
|
||||||
|
<span class="absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white transition peer-checked:translate-x-4" />
|
||||||
|
</span>
|
||||||
|
Shot script
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p v-if="shotScriptMode" class="mb-2 text-xs text-zinc-500">
|
||||||
|
Paste a block. Text before the first marker is shot 1. Write <span class="font-medium text-zinc-300">shot 2</span>, <span class="font-medium text-zinc-300">shot 3</span>, and so on to queue extensions. Every shot uses the duration slider.
|
||||||
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="prompt"
|
v-model="prompt"
|
||||||
rows="6"
|
:rows="shotScriptMode ? 12 : 6"
|
||||||
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
class="w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||||
placeholder="Camera slowly pushes in. Soft daylight, fabric ripples, dust motes in the air. Audio: quiet room tone, distant birds."
|
:placeholder="shotScriptMode
|
||||||
|
? 'A woman walks into the village.\n\nshot 2\nShe crosses the stone bridge at dusk.\n\nshot 3\nShe opens the shop door and steps inside.'
|
||||||
|
: 'Camera slowly pushes in. Soft daylight, fabric ripples, dust motes in the air. Audio: quiet room tone, distant birds.'"
|
||||||
/>
|
/>
|
||||||
|
<div v-if="shotScriptMode" class="mt-3 space-y-2">
|
||||||
|
<p v-if="!parsedShots.length" class="text-xs text-zinc-500">Add a first shot, then <span class="text-zinc-300">shot 2</span> for an extension.</p>
|
||||||
|
<div
|
||||||
|
v-for="(shot, index) in parsedShots"
|
||||||
|
:key="shot.n + '-' + index"
|
||||||
|
class="rounded-xl border border-white/10 bg-zinc-950/50 px-3 py-2"
|
||||||
|
>
|
||||||
|
<p class="text-xs font-medium text-amber-100">
|
||||||
|
{{ index === 0 ? 'Initial' : `Extension ${index}` }}
|
||||||
|
<span class="font-normal text-zinc-500"> · shot {{ shot.n }} · {{ formatClipDuration(duration) }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 whitespace-pre-wrap text-xs text-zinc-300">{{ shot.prompt }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div v-if="!shotScriptMode" class="space-y-3">
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in extensionQueue"
|
v-for="(item, index) in extensionQueue"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
@@ -234,7 +263,9 @@
|
|||||||
<DurationField
|
<DurationField
|
||||||
v-model="duration"
|
v-model="duration"
|
||||||
label="Duration"
|
label="Duration"
|
||||||
hint="Type any length, use the slider, or drag up/down in the box."
|
:hint="shotScriptMode
|
||||||
|
? 'This length applies to the initial shot and every extension in the script.'
|
||||||
|
: 'Type any length, use the slider, or drag up/down in the box.'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label class="text-sm">
|
<label class="text-sm">
|
||||||
@@ -322,6 +353,19 @@
|
|||||||
Step {{ chainStep }}/{{ chainTotal }}{{ chainLabel ? `: ${chainLabel}` : '' }}
|
Step {{ chainStep }}/{{ chainTotal }}{{ chainLabel ? `: ${chainLabel}` : '' }}
|
||||||
<template v-if="overallProgress"> · {{ overallProgress }}% overall</template>
|
<template v-if="overallProgress"> · {{ overallProgress }}% overall</template>
|
||||||
</p>
|
</p>
|
||||||
|
<ol v-if="busy && activeChainPlan.length > 1" class="mb-3 space-y-1">
|
||||||
|
<li
|
||||||
|
v-for="(part, index) in activeChainPlan"
|
||||||
|
:key="index"
|
||||||
|
class="flex items-center gap-2 rounded-lg px-2 py-1 text-xs"
|
||||||
|
:class="chainPartClass(index)"
|
||||||
|
>
|
||||||
|
<span class="h-2 w-2 shrink-0 rounded-full" :class="chainPartDot(index)" />
|
||||||
|
<span class="shrink-0 font-medium">{{ part.label }}</span>
|
||||||
|
<span class="min-w-0 truncate text-zinc-400">{{ part.prompt }}</span>
|
||||||
|
<span class="shrink-0 text-zinc-500">{{ formatClipDuration(part.duration) }}</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
<div class="h-2 overflow-hidden rounded-full bg-zinc-800">
|
<div class="h-2 overflow-hidden rounded-full bg-zinc-800">
|
||||||
<div class="h-full rounded-full bg-amber-400 progress-glow transition-all duration-300" :style="{ width: progress + '%' }" />
|
<div class="h-full rounded-full bg-amber-400 progress-glow transition-all duration-300" :style="{ width: progress + '%' }" />
|
||||||
</div>
|
</div>
|
||||||
@@ -458,10 +502,92 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="!visibleClips.length" class="mt-4 text-sm text-zinc-500">Nothing in this folder yet. Generate into it from the left.</p>
|
<p v-if="!libraryGroups.length" class="mt-4 text-sm text-zinc-500">Nothing in this folder yet. Generate into it from the left.</p>
|
||||||
<ul class="mt-4 space-y-3">
|
<ul class="mt-4 space-y-3">
|
||||||
<li v-for="item in visibleClips" :key="item.id" class="flex gap-3 rounded-2xl border border-white/5 bg-zinc-950/40 p-3">
|
<li
|
||||||
<button type="button" class="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-zinc-900" @click="openClip(item)">
|
v-for="group in libraryGroups"
|
||||||
|
:key="group.key"
|
||||||
|
class="rounded-2xl border border-white/5 bg-zinc-950/40 p-3"
|
||||||
|
>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button type="button" class="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-zinc-900" @click="openClip(group.head)">
|
||||||
|
<span v-if="group.head.hideThumbnail" class="sensitive-glow block h-full w-full" />
|
||||||
|
<img v-else-if="group.head.hasThumbnail" :src="`/api/library/clips/${group.head.id}/thumb`" alt="" class="h-full w-full object-cover">
|
||||||
|
<video
|
||||||
|
v-else
|
||||||
|
:src="`/api/library/clips/${group.head.id}/video#t=0.1`"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
muted
|
||||||
|
playsinline
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<input
|
||||||
|
v-if="renamingId === group.head.id"
|
||||||
|
ref="renameInput"
|
||||||
|
v-model="renameDraft"
|
||||||
|
class="min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
|
||||||
|
maxlength="80"
|
||||||
|
@keydown.enter.prevent="saveRename(group.head)"
|
||||||
|
@keydown.escape.prevent="cancelRename"
|
||||||
|
@blur="saveRename(group.head)"
|
||||||
|
>
|
||||||
|
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ group.head.name || group.head.prompt }}</p>
|
||||||
|
<div class="group relative shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-6 w-6 items-center justify-center rounded-full text-zinc-500 hover:text-amber-200"
|
||||||
|
:aria-label="'Copy prompt for ' + (group.head.name || 'clip')"
|
||||||
|
title="Copy prompt"
|
||||||
|
@click.stop="copyPrompt(group.head.prompt)"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-4 w-4">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 16v-4M12 8h.01" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="pointer-events-none invisible absolute right-0 top-7 z-20 w-64 whitespace-pre-wrap rounded-xl border border-white/10 bg-zinc-950 p-3 text-xs leading-relaxed text-zinc-300 shadow-2xl group-hover:visible group-focus-within:visible">
|
||||||
|
{{ group.head.prompt }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-zinc-500">{{ clipMetaLine(group.head) }}</p>
|
||||||
|
<div class="mt-2 flex flex-wrap items-center gap-3">
|
||||||
|
<button v-if="group.parts.length > 1" type="button" class="text-xs text-amber-200" @click="toggleFamily(group.key)">
|
||||||
|
{{ expandedFamilies[group.key] ? 'Collapse' : `Expand ${group.parts.length} parts` }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="text-xs text-amber-300" @click="openClip(group.head)">Open</button>
|
||||||
|
<button type="button" class="text-xs text-amber-300" @click="openExtend(group.head)">Extend</button>
|
||||||
|
<button type="button" class="text-xs text-amber-300" @click="startRename(group.head)">Rename</button>
|
||||||
|
<button type="button" class="text-xs text-amber-300" @click="rerun(group.head)">Rerun</button>
|
||||||
|
<select
|
||||||
|
v-if="folders.length > 1"
|
||||||
|
class="max-w-[9.5rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-xs text-amber-300"
|
||||||
|
:value="''"
|
||||||
|
@change="onMoveClip(group.head, $event)"
|
||||||
|
>
|
||||||
|
<option disabled value="">Move to…</option>
|
||||||
|
<option
|
||||||
|
v-for="folder in folders.filter(folder => folder.id !== group.head.folderId)"
|
||||||
|
:key="folder.id"
|
||||||
|
:value="folder.id"
|
||||||
|
>
|
||||||
|
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="text-xs text-red-300" @click="removeClip(group.head)">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul v-if="expandedFamilies[group.key] && group.parts.length > 1" class="mt-3 space-y-2 border-t border-white/5 pt-3">
|
||||||
|
<li
|
||||||
|
v-for="item in group.parts.slice(1)"
|
||||||
|
:key="item.id"
|
||||||
|
class="flex gap-3 rounded-xl border border-white/5 bg-black/20 p-2"
|
||||||
|
>
|
||||||
|
<button type="button" class="relative h-12 w-12 shrink-0 overflow-hidden rounded-lg bg-zinc-900" @click="openClip(item)">
|
||||||
<span v-if="item.hideThumbnail" class="sensitive-glow block h-full w-full" />
|
<span v-if="item.hideThumbnail" class="sensitive-glow block h-full w-full" />
|
||||||
<img v-else-if="item.hasThumbnail" :src="`/api/library/clips/${item.id}/thumb`" alt="" class="h-full w-full object-cover">
|
<img v-else-if="item.hasThumbnail" :src="`/api/library/clips/${item.id}/thumb`" alt="" class="h-full w-full object-cover">
|
||||||
<video
|
<video
|
||||||
@@ -477,7 +603,6 @@
|
|||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<input
|
<input
|
||||||
v-if="renamingId === item.id"
|
v-if="renamingId === item.id"
|
||||||
ref="renameInput"
|
|
||||||
v-model="renameDraft"
|
v-model="renameDraft"
|
||||||
class="min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
|
class="min-w-0 flex-1 rounded-lg border border-amber-300/40 bg-zinc-950 px-2 py-1 text-sm outline-none ring-amber-300/40 focus:ring-2"
|
||||||
maxlength="80"
|
maxlength="80"
|
||||||
@@ -486,50 +611,20 @@
|
|||||||
@blur="saveRename(item)"
|
@blur="saveRename(item)"
|
||||||
>
|
>
|
||||||
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ item.name || item.prompt }}</p>
|
<p v-else class="min-w-0 flex-1 truncate text-sm font-medium">{{ item.name || item.prompt }}</p>
|
||||||
<div class="group relative shrink-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex h-6 w-6 items-center justify-center rounded-full text-zinc-500 hover:text-amber-200"
|
|
||||||
:aria-label="'Copy prompt for ' + (item.name || 'clip')"
|
|
||||||
title="Copy prompt"
|
|
||||||
@click.stop="copyPrompt(item.prompt)"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-4 w-4">
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<path d="M12 16v-4M12 8h.01" stroke-linecap="round" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div class="pointer-events-none invisible absolute right-0 top-7 z-20 w-64 whitespace-pre-wrap rounded-xl border border-white/10 bg-zinc-950 p-3 text-xs leading-relaxed text-zinc-300 shadow-2xl group-hover:visible group-focus-within:visible">
|
|
||||||
{{ item.prompt }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p class="text-xs text-zinc-500">{{ clipMetaLine(item) }}</p>
|
||||||
</div>
|
<div class="mt-1.5 flex flex-wrap items-center gap-3">
|
||||||
<p class="text-xs text-zinc-500">{{ item.width && item.height ? `${item.width}×${item.height}` : 'size unknown' }} · {{ item.steps || '—' }} steps · seed {{ item.seed || '—' }}</p>
|
|
||||||
<div class="mt-2 flex flex-wrap items-center gap-3">
|
|
||||||
<button type="button" class="text-xs text-amber-300" @click="openClip(item)">Open</button>
|
<button type="button" class="text-xs text-amber-300" @click="openClip(item)">Open</button>
|
||||||
<button type="button" class="text-xs text-amber-300" @click="openExtend(item)">Extend</button>
|
<button type="button" class="text-xs text-amber-300" @click="openExtend(item)">Extend</button>
|
||||||
<button type="button" class="text-xs text-amber-300" @click="startRename(item)">Rename</button>
|
<button type="button" class="text-xs text-amber-300" @click="startRename(item)">Rename</button>
|
||||||
<button type="button" class="text-xs text-amber-300" @click="rerun(item)">Rerun</button>
|
<button type="button" class="text-xs text-amber-300" @click="rerun(item)">Rerun</button>
|
||||||
<select
|
|
||||||
v-if="folders.length > 1"
|
|
||||||
class="max-w-[9.5rem] rounded-lg border border-white/10 bg-zinc-950 px-1.5 py-0.5 text-xs text-amber-300"
|
|
||||||
:value="''"
|
|
||||||
@change="onMoveClip(item, $event)"
|
|
||||||
>
|
|
||||||
<option disabled value="">Move to…</option>
|
|
||||||
<option
|
|
||||||
v-for="folder in folders.filter(folder => folder.id !== item.folderId)"
|
|
||||||
:key="folder.id"
|
|
||||||
:value="folder.id"
|
|
||||||
>
|
|
||||||
{{ folder.name }}{{ folder.protected && !folder.unlocked ? ' · locked' : '' }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<button type="button" class="text-xs text-red-300" @click="removeClip(item)">Delete</button>
|
<button type="button" class="text-xs text-red-300" @click="removeClip(item)">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -612,6 +707,10 @@ interface LibraryClip {
|
|||||||
fps?: number
|
fps?: number
|
||||||
samplerName?: string
|
samplerName?: string
|
||||||
scheduler?: string
|
scheduler?: string
|
||||||
|
duration?: number
|
||||||
|
familyId?: string
|
||||||
|
parentClipId?: string
|
||||||
|
chainIndex?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RetryDraft {
|
interface RetryDraft {
|
||||||
@@ -664,6 +763,7 @@ const file = ref<File | null>(null)
|
|||||||
const preview = ref('')
|
const preview = ref('')
|
||||||
const dragging = ref(false)
|
const dragging = ref(false)
|
||||||
const prompt = ref('')
|
const prompt = ref('')
|
||||||
|
const shotScriptMode = ref(false)
|
||||||
const extensionQueue = ref<QueuedExtension[]>([])
|
const extensionQueue = ref<QueuedExtension[]>([])
|
||||||
const clipName = ref('')
|
const clipName = ref('')
|
||||||
const outputDownloadName = ref('aigen')
|
const outputDownloadName = ref('aigen')
|
||||||
@@ -698,6 +798,9 @@ const chainStep = ref(1)
|
|||||||
const chainTotal = ref(1)
|
const chainTotal = ref(1)
|
||||||
const chainLabel = ref('')
|
const chainLabel = ref('')
|
||||||
const overallProgress = ref(0)
|
const overallProgress = ref(0)
|
||||||
|
const completedChainStep = ref(0)
|
||||||
|
const activeChainPlan = ref<{ label: string; prompt: string; duration: number }[]>([])
|
||||||
|
const expandedFamilies = ref<Record<string, boolean>>({})
|
||||||
const elapsedMs = ref(0)
|
const elapsedMs = ref(0)
|
||||||
const videoUrl = ref('')
|
const videoUrl = ref('')
|
||||||
const jobId = ref('')
|
const jobId = ref('')
|
||||||
@@ -818,15 +921,51 @@ const canExtend = computed(() => Boolean(
|
|||||||
&& (currentClipId.value || clipIdFromPlayer())
|
&& (currentClipId.value || clipIdFromPlayer())
|
||||||
))
|
))
|
||||||
const extendSaveName = computed(() => nextClipPartName(currentClip.value?.name || currentClip.value?.prompt || ''))
|
const extendSaveName = computed(() => nextClipPartName(currentClip.value?.name || currentClip.value?.prompt || ''))
|
||||||
const queueReady = computed(() => extensionQueue.value.every(item => item.prompt.trim()))
|
const queueReady = computed(() => {
|
||||||
|
if (shotScriptMode.value) return parsedShots.value.length > 0 && parsedShots.value.every(shot => shot.prompt.trim())
|
||||||
|
return extensionQueue.value.every(item => item.prompt.trim())
|
||||||
|
})
|
||||||
const generateLabel = computed(() => {
|
const generateLabel = computed(() => {
|
||||||
const n = extensionQueue.value.length
|
const n = shotScriptMode.value ? Math.max(0, parsedShots.value.length - 1) : extensionQueue.value.length
|
||||||
if (busy.value) return 'Generating…'
|
if (busy.value) return 'Generating…'
|
||||||
if (!n) return 'Generate Video'
|
if (!n) return 'Generate Video'
|
||||||
return n === 1 ? 'Generate Initial + 1 Extension' : `Generate Initial + ${n} Extensions`
|
return n === 1 ? 'Generate Initial + 1 Extension' : `Generate Initial + ${n} Extensions`
|
||||||
})
|
})
|
||||||
|
const parsedShots = computed(() => parseShotScript(prompt.value))
|
||||||
|
const plannedChain = computed(() => {
|
||||||
|
if (shotScriptMode.value) {
|
||||||
|
return parsedShots.value.map((shot, index) => ({
|
||||||
|
label: index === 0 ? 'Initial' : `Extension ${index}`,
|
||||||
|
prompt: shot.prompt,
|
||||||
|
duration: duration.value
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ label: 'Initial', prompt: prompt.value, duration: duration.value },
|
||||||
|
...extensionQueue.value.map((item, index) => ({
|
||||||
|
label: `Extension ${index + 1}`,
|
||||||
|
prompt: item.prompt,
|
||||||
|
duration: item.duration
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
const visibleClips = computed(() => clips.value.filter(clip => clip.folderId === browseFolderId.value))
|
const visibleClips = computed(() => clips.value.filter(clip => clip.folderId === browseFolderId.value))
|
||||||
|
const libraryGroups = computed(() => {
|
||||||
|
const groups = new Map<string, LibraryClip[]>()
|
||||||
|
for (const clip of visibleClips.value) {
|
||||||
|
const key = clipFamilyKey(clip)
|
||||||
|
const list = groups.get(key) || []
|
||||||
|
list.push(clip)
|
||||||
|
groups.set(key, list)
|
||||||
|
}
|
||||||
|
return [...groups.entries()]
|
||||||
|
.map(([key, parts]) => {
|
||||||
|
const sorted = [...parts].sort(sortClipParts)
|
||||||
|
return { key, head: sorted[0], parts: sorted }
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.head.createdAt - a.head.createdAt)
|
||||||
|
})
|
||||||
const currentFolder = computed(() => folders.value.find(folder => folder.id === folderId.value))
|
const currentFolder = computed(() => folders.value.find(folder => folder.id === folderId.value))
|
||||||
const browseFolder = computed(() => folders.value.find(folder => folder.id === browseFolderId.value))
|
const browseFolder = computed(() => folders.value.find(folder => folder.id === browseFolderId.value))
|
||||||
|
|
||||||
@@ -834,6 +973,59 @@ function clipsIn(id: string) {
|
|||||||
return clips.value.filter(clip => clip.folderId === id)
|
return clips.value.filter(clip => clip.folderId === id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clipFamilyKey(clip: LibraryClip) {
|
||||||
|
if (clip.familyId) return clip.familyId
|
||||||
|
const base = String(clip.name || '').trim().replace(/\s+p\d+$/i, '')
|
||||||
|
if (base) return `name:${clip.folderId}:${base.toLowerCase()}`
|
||||||
|
return `solo:${clip.id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortClipParts(a: LibraryClip, b: LibraryClip) {
|
||||||
|
const durationDelta = (Number(b.duration) || 0) - (Number(a.duration) || 0)
|
||||||
|
if (durationDelta) return durationDelta
|
||||||
|
const chainDelta = (b.chainIndex || 0) - (a.chainIndex || 0)
|
||||||
|
if (chainDelta) return chainDelta
|
||||||
|
return b.createdAt - a.createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatClipDuration(value?: number) {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return ''
|
||||||
|
const rounded = Math.round(value * 10) / 10
|
||||||
|
return Number.isInteger(rounded) ? `${rounded}s` : `${rounded.toFixed(1)}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function clipMetaLine(item: LibraryClip) {
|
||||||
|
const res = item.width && item.height ? `${item.width}×${item.height}` : 'size unknown'
|
||||||
|
const duration = formatClipDuration(item.duration)
|
||||||
|
const steps = `${item.steps || '—'} steps`
|
||||||
|
const seed = `seed ${item.seed || '—'}`
|
||||||
|
return duration ? `${res} · ${duration} · ${steps} · ${seed}` : `${res} · ${steps} · ${seed}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFamily(key: string) {
|
||||||
|
expandedFamilies.value = { ...expandedFamilies.value, [key]: !expandedFamilies.value[key] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function chainPartDone(index: number) {
|
||||||
|
return index < completedChainStep.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function chainPartCurrent(index: number) {
|
||||||
|
return busy.value && index === completedChainStep.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function chainPartClass(index: number) {
|
||||||
|
if (chainPartDone(index)) return 'border border-emerald-400/20 bg-emerald-400/10 text-emerald-100'
|
||||||
|
if (chainPartCurrent(index)) return 'border border-amber-300/40 bg-amber-400/10 text-amber-100'
|
||||||
|
return 'border border-white/5 text-zinc-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
function chainPartDot(index: number) {
|
||||||
|
if (chainPartDone(index)) return 'bg-emerald-400'
|
||||||
|
if (chainPartCurrent(index)) return 'bg-amber-300 animate-pulse'
|
||||||
|
return 'bg-zinc-600'
|
||||||
|
}
|
||||||
|
|
||||||
function applyThumbnailDefault() {
|
function applyThumbnailDefault() {
|
||||||
if (!showPrivacyToggles.value) {
|
if (!showPrivacyToggles.value) {
|
||||||
hideThumbnail.value = false
|
hideThumbnail.value = false
|
||||||
@@ -1300,6 +1492,8 @@ async function generateExtension() {
|
|||||||
chainTotal.value = 1
|
chainTotal.value = 1
|
||||||
chainLabel.value = ''
|
chainLabel.value = ''
|
||||||
overallProgress.value = 0
|
overallProgress.value = 0
|
||||||
|
completedChainStep.value = 0
|
||||||
|
activeChainPlan.value = []
|
||||||
statusMessage.value = 'Extracting last frame & stitching extension...'
|
statusMessage.value = 'Extracting last frame & stitching extension...'
|
||||||
startTimer()
|
startTimer()
|
||||||
try {
|
try {
|
||||||
@@ -1451,13 +1645,18 @@ function removeQueuedExtension(id: string) {
|
|||||||
async function generate() {
|
async function generate() {
|
||||||
if (!file.value || !prompt.value.trim()) return
|
if (!file.value || !prompt.value.trim()) return
|
||||||
if (!queueReady.value) {
|
if (!queueReady.value) {
|
||||||
toast('Each queued extension needs a prompt.')
|
toast(shotScriptMode.value ? 'Each shot in the script needs a prompt.' : 'Each queued extension needs a prompt.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!folderId.value) {
|
if (!folderId.value) {
|
||||||
toast('Choose a library folder before generating.')
|
toast('Choose a library folder before generating.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const shots = shotScriptMode.value ? parsedShots.value : null
|
||||||
|
const initialPrompt = (shots?.[0]?.prompt || prompt.value).trim()
|
||||||
|
const queued = shots
|
||||||
|
? shots.slice(1).map(shot => ({ prompt: shot.prompt.trim(), duration: duration.value }))
|
||||||
|
: extensionQueue.value.map(item => ({ prompt: item.prompt.trim(), duration: item.duration }))
|
||||||
statusBusy.value = false
|
statusBusy.value = false
|
||||||
busy.value = true
|
busy.value = true
|
||||||
listenGen += 1
|
listenGen += 1
|
||||||
@@ -1474,14 +1673,16 @@ async function generate() {
|
|||||||
playerSize.value = outputSize.value
|
playerSize.value = outputSize.value
|
||||||
statusMessage.value = 'Checking ComfyUI...'
|
statusMessage.value = 'Checking ComfyUI...'
|
||||||
chainStep.value = 1
|
chainStep.value = 1
|
||||||
chainTotal.value = 1 + extensionQueue.value.length
|
chainTotal.value = 1 + queued.length
|
||||||
chainLabel.value = extensionQueue.value.length ? 'Initial' : ''
|
chainLabel.value = queued.length ? 'Initial' : ''
|
||||||
overallProgress.value = 0
|
overallProgress.value = 0
|
||||||
|
completedChainStep.value = 0
|
||||||
|
activeChainPlan.value = plannedChain.value.map(part => ({ ...part }))
|
||||||
startTimer()
|
startTimer()
|
||||||
try {
|
try {
|
||||||
const body = new FormData()
|
const body = new FormData()
|
||||||
body.append('image', file.value)
|
body.append('image', file.value)
|
||||||
body.append('prompt', prompt.value.trim())
|
body.append('prompt', initialPrompt)
|
||||||
body.append('aspect', aspect.value)
|
body.append('aspect', aspect.value)
|
||||||
body.append('width', String(outputSize.value.width))
|
body.append('width', String(outputSize.value.width))
|
||||||
body.append('height', String(outputSize.value.height))
|
body.append('height', String(outputSize.value.height))
|
||||||
@@ -1499,13 +1700,10 @@ async function generate() {
|
|||||||
body.append('sound', String(withSound.value))
|
body.append('sound', String(withSound.value))
|
||||||
body.append('name', clipName.value.trim())
|
body.append('name', clipName.value.trim())
|
||||||
if (activeDraftId) body.append('draftId', activeDraftId)
|
if (activeDraftId) body.append('draftId', activeDraftId)
|
||||||
if (extensionQueue.value.length) {
|
if (queued.length) {
|
||||||
body.append('extensions', JSON.stringify(extensionQueue.value.map(item => ({
|
body.append('extensions', JSON.stringify(queued))
|
||||||
prompt: item.prompt.trim(),
|
|
||||||
duration: item.duration
|
|
||||||
}))))
|
|
||||||
}
|
}
|
||||||
outputDownloadName.value = clipFileName(clipName.value.trim() || prompt.value.trim().split(/[.!?\n]/)[0] || 'aigen')
|
outputDownloadName.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 }>('/api/generate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body
|
body
|
||||||
@@ -1557,6 +1755,7 @@ function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLock
|
|||||||
if (settledUi) return
|
if (settledUi) return
|
||||||
|
|
||||||
if (payload.type === 'checkpoint' && payload.clipId) {
|
if (payload.type === 'checkpoint' && payload.clipId) {
|
||||||
|
completedChainStep.value = Math.max(completedChainStep.value, Number(payload.chainStep || chainStep.value) || 0)
|
||||||
loadLibrary().catch(() => null)
|
loadLibrary().catch(() => null)
|
||||||
const locked = folderLocked || payload.folderLocked === true
|
const locked = folderLocked || payload.folderLocked === true
|
||||||
if (!watchingLibrary.value && !locked) {
|
if (!watchingLibrary.value && !locked) {
|
||||||
@@ -1640,6 +1839,7 @@ function applyJobEvent(payload: Record<string, any>, hidden: boolean, folderLock
|
|||||||
stopTimer()
|
stopTimer()
|
||||||
stopListen()
|
stopListen()
|
||||||
clearActiveJob()
|
clearActiveJob()
|
||||||
|
loadLibrary().catch(() => null)
|
||||||
void pollComfyHealth()
|
void pollComfyHealth()
|
||||||
toast(payload.error || payload.message || 'Generation failed')
|
toast(payload.error || payload.message || 'Generation failed')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
duration: durationSeconds,
|
duration: durationSeconds,
|
||||||
extendTmpDir: tmpDir,
|
extendTmpDir: tmpDir,
|
||||||
extendPart1Path: part1Path,
|
extendPart1Path: part1Path,
|
||||||
extendSourceClipId: source.id
|
extendSourceClipId: source.id,
|
||||||
|
familyId: crypto.randomUUID(),
|
||||||
|
parentClipId: source.id,
|
||||||
|
chainIndex: (source.chainIndex || 0) + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
emitJob(job, { type: 'status', message: 'Extracting last frame & stitching extension...', progress: 2 })
|
emitJob(job, { type: 'status', message: 'Extracting last frame & stitching extension...', progress: 2 })
|
||||||
@@ -226,7 +229,10 @@ async function runExtend(
|
|||||||
imageName: job.library.imageName,
|
imageName: job.library.imageName,
|
||||||
imageSubfolder: job.library.imageSubfolder,
|
imageSubfolder: job.library.imageSubfolder,
|
||||||
extendTmpDir: job.library.extendTmpDir,
|
extendTmpDir: job.library.extendTmpDir,
|
||||||
extendPart1Path: job.library.extendPart1Path
|
extendPart1Path: job.library.extendPart1Path,
|
||||||
|
familyId: job.library.familyId,
|
||||||
|
parentClipId: job.library.parentClipId,
|
||||||
|
chainIndex: job.library.chainIndex
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
emitJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
|
emitJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
|
||||||
|
|||||||
@@ -169,7 +169,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
chainIndex: 0,
|
chainIndex: 0,
|
||||||
chainStep: 1,
|
chainStep: 1,
|
||||||
chainTotal,
|
chainTotal,
|
||||||
chainLabel: extensions.length ? 'Initial' : undefined
|
chainLabel: extensions.length ? 'Initial' : undefined,
|
||||||
|
familyId: crypto.randomUUID()
|
||||||
}
|
}
|
||||||
emitChainJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
|
emitChainJob(job, { type: 'status', message: 'Checking ComfyUI...', progress: 1 })
|
||||||
|
|
||||||
@@ -341,7 +342,10 @@ async function queueMiniMax(
|
|||||||
imageName: job.library.imageName,
|
imageName: job.library.imageName,
|
||||||
imageSubfolder: job.library.imageSubfolder,
|
imageSubfolder: job.library.imageSubfolder,
|
||||||
extendTmpDir: job.library.extendTmpDir,
|
extendTmpDir: job.library.extendTmpDir,
|
||||||
extendPart1Path: job.library.extendPart1Path
|
extendPart1Path: job.library.extendPart1Path,
|
||||||
|
familyId: job.library.familyId,
|
||||||
|
parentClipId: job.library.parentClipId,
|
||||||
|
chainIndex: job.library.chainIndex
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
emitChainJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
|
emitChainJob(job, { type: 'status', message: 'Job queued on ComfyUI', progress: 8 })
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ function parseRate(rate: string) {
|
|||||||
return Number.isFinite(value) && value > 0 ? value : NaN
|
return Number.isFinite(value) && value > 0 ? value : NaN
|
||||||
}
|
}
|
||||||
|
|
||||||
async function probeDuration(filePath: string) {
|
export async function probeDuration(filePath: string) {
|
||||||
const raw = await runFfprobe([
|
const raw = await runFfprobe([
|
||||||
'-v', 'error',
|
'-v', 'error',
|
||||||
'-select_streams', 'v:0',
|
'-select_streams', 'v:0',
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ export interface Job {
|
|||||||
extendTmpDir?: string
|
extendTmpDir?: string
|
||||||
extendPart1Path?: string
|
extendPart1Path?: string
|
||||||
extendSourceClipId?: string
|
extendSourceClipId?: string
|
||||||
|
familyId?: string
|
||||||
|
parentClipId?: string
|
||||||
}
|
}
|
||||||
error?: string
|
error?: string
|
||||||
socketReady?: boolean
|
socketReady?: boolean
|
||||||
|
|||||||
+23
-2
@@ -34,6 +34,10 @@ export interface LibraryClip {
|
|||||||
fps?: number
|
fps?: number
|
||||||
samplerName?: string
|
samplerName?: string
|
||||||
scheduler?: string
|
scheduler?: string
|
||||||
|
duration?: number
|
||||||
|
familyId?: string
|
||||||
|
parentClipId?: string
|
||||||
|
chainIndex?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LibraryStill {
|
export interface LibraryStill {
|
||||||
@@ -800,6 +804,10 @@ export async function saveClip(params: {
|
|||||||
fps?: number
|
fps?: number
|
||||||
samplerName?: string
|
samplerName?: string
|
||||||
scheduler?: string
|
scheduler?: string
|
||||||
|
duration?: number
|
||||||
|
familyId?: string
|
||||||
|
parentClipId?: string
|
||||||
|
chainIndex?: number
|
||||||
}) {
|
}) {
|
||||||
const catalog = readCatalog(params.ownerKey)
|
const catalog = readCatalog(params.ownerKey)
|
||||||
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
|
const folder = catalog.folders.find(item => item.id === params.folderId) || catalog.folders[0]
|
||||||
@@ -821,10 +829,23 @@ export async function saveClip(params: {
|
|||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
fps: params.fps,
|
fps: params.fps,
|
||||||
samplerName: params.samplerName,
|
samplerName: params.samplerName,
|
||||||
scheduler: params.scheduler
|
scheduler: params.scheduler,
|
||||||
|
familyId: params.familyId,
|
||||||
|
parentClipId: params.parentClipId,
|
||||||
|
chainIndex: params.chainIndex
|
||||||
}
|
}
|
||||||
mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true })
|
mkdirSync(clipDir(params.ownerKey, clip.id), { recursive: true })
|
||||||
writeFileSync(clipVideoPath(params.ownerKey, clip.id), params.video)
|
const videoPath = clipVideoPath(params.ownerKey, clip.id)
|
||||||
|
writeFileSync(videoPath, params.video)
|
||||||
|
if (typeof params.duration === 'number' && params.duration > 0) {
|
||||||
|
clip.duration = params.duration
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
clip.duration = await probeDuration(videoPath)
|
||||||
|
} catch {
|
||||||
|
/* duration stays unset */
|
||||||
|
}
|
||||||
|
}
|
||||||
if (params.thumb?.length) {
|
if (params.thumb?.length) {
|
||||||
writeFileSync(join(clipDir(params.ownerKey, clip.id), 'thumb'), params.thumb)
|
writeFileSync(join(clipDir(params.ownerKey, clip.id), 'thumb'), params.thumb)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export interface PendingJob {
|
|||||||
imageSubfolder?: string
|
imageSubfolder?: string
|
||||||
extendTmpDir?: string
|
extendTmpDir?: string
|
||||||
extendPart1Path?: string
|
extendPart1Path?: string
|
||||||
|
familyId?: string
|
||||||
|
parentClipId?: string
|
||||||
|
chainIndex?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function pendingRoot() {
|
function pendingRoot() {
|
||||||
@@ -102,7 +105,10 @@ export async function completePendingIfReady(pending: PendingJob) {
|
|||||||
hideThumbnail: pending.hideThumbnail,
|
hideThumbnail: pending.hideThumbnail,
|
||||||
video: buffer,
|
video: buffer,
|
||||||
thumb: null,
|
thumb: null,
|
||||||
comfyFilename: video.filename
|
comfyFilename: video.filename,
|
||||||
|
familyId: pending.familyId,
|
||||||
|
parentClipId: pending.parentClipId,
|
||||||
|
chainIndex: pending.chainIndex
|
||||||
})
|
})
|
||||||
await purgeComfyArtifacts({ video, imageName: pending.imageName, imageSubfolder: pending.imageSubfolder, promptId: pending.promptId })
|
await purgeComfyArtifacts({ video, imageName: pending.imageName, imageSubfolder: pending.imageSubfolder, promptId: pending.promptId })
|
||||||
deletePendingJob(pending.jobId)
|
deletePendingJob(pending.jobId)
|
||||||
|
|||||||
@@ -178,12 +178,16 @@ export function watchComfyJob(job: Job, options: { persist?: boolean } = {}): Pr
|
|||||||
cfg: job.library.cfg,
|
cfg: job.library.cfg,
|
||||||
fps: job.library.fps,
|
fps: job.library.fps,
|
||||||
samplerName: job.library.samplerName,
|
samplerName: job.library.samplerName,
|
||||||
scheduler: job.library.scheduler
|
scheduler: job.library.scheduler,
|
||||||
|
familyId: job.library.familyId || (job.library.familyId = crypto.randomUUID()),
|
||||||
|
parentClipId: job.library.parentClipId,
|
||||||
|
chainIndex: job.library.chainIndex
|
||||||
})
|
})
|
||||||
job.clipId = clip.id
|
job.clipId = clip.id
|
||||||
job.hideThumbnail = clip.hideThumbnail
|
job.hideThumbnail = clip.hideThumbnail
|
||||||
job.library.thumb = undefined
|
job.library.thumb = undefined
|
||||||
job.library.name = nextClipPartName(clip.name)
|
job.library.name = nextClipPartName(clip.name)
|
||||||
|
job.library.parentClipId = clip.id
|
||||||
await purgeComfyArtifacts({
|
await purgeComfyArtifacts({
|
||||||
video,
|
video,
|
||||||
imageName: job.library.imageName,
|
imageName: job.library.imageName,
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
export interface ParsedShot {
|
||||||
|
n: number
|
||||||
|
prompt: string
|
||||||
|
role: 'initial' | 'extension'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseShotScript(text: string): ParsedShot[] {
|
||||||
|
const src = String(text || '').replace(/\r\n/g, '\n')
|
||||||
|
const marker = /shot\s+(\d+)\b/gi
|
||||||
|
const markers: { n: number; start: number; end: number }[] = []
|
||||||
|
for (const match of src.matchAll(marker)) {
|
||||||
|
markers.push({
|
||||||
|
n: Number(match[1]),
|
||||||
|
start: match.index || 0,
|
||||||
|
end: (match.index || 0) + match[0].length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const blocks: { n: number; prompt: string }[] = []
|
||||||
|
if (!markers.length) {
|
||||||
|
const prompt = src.trim()
|
||||||
|
return prompt ? [{ n: 1, prompt, role: 'initial' }] : []
|
||||||
|
}
|
||||||
|
const preamble = src.slice(0, markers[0].start).trim()
|
||||||
|
if (preamble) blocks.push({ n: 1, prompt: preamble })
|
||||||
|
for (let i = 0; i < markers.length; i++) {
|
||||||
|
const from = markers[i].end
|
||||||
|
const to = i + 1 < markers.length ? markers[i + 1].start : src.length
|
||||||
|
const prompt = src.slice(from, to).trim()
|
||||||
|
if (prompt) blocks.push({ n: markers[i].n, prompt })
|
||||||
|
}
|
||||||
|
const byN = new Map<number, string>()
|
||||||
|
for (const block of blocks) {
|
||||||
|
const prev = byN.get(block.n)
|
||||||
|
byN.set(block.n, prev ? `${prev}\n${block.prompt}` : block.prompt)
|
||||||
|
}
|
||||||
|
return [...byN.entries()]
|
||||||
|
.sort((a, b) => a[0] - b[0])
|
||||||
|
.map(([n, prompt], index) => ({
|
||||||
|
n,
|
||||||
|
prompt,
|
||||||
|
role: index === 0 ? 'initial' : 'extension'
|
||||||
|
}))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user