Add an image batch-script popup that splits a pasted shot list onto the start still.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-31 00:00:34 -05:00
co-authored by Cursor
parent 177f7b6e96
commit 1bdb96cbb7
3 changed files with 247 additions and 11 deletions
+158
View File
@@ -0,0 +1,158 @@
<template>
<Teleport to="body">
<div
v-if="open"
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/70 p-4 sm:p-8"
role="dialog"
aria-modal="true"
aria-label="Batch image prompts"
@click.self="close"
>
<div class="glass flex w-full max-w-5xl flex-col rounded-3xl p-5 sm:p-6">
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="font-display text-2xl font-bold">Batch script</h2>
<p class="mt-1 text-sm text-zinc-400">
Paste a block. Put
<span class="font-medium text-zinc-300">shot 1</span>,
<span class="font-medium text-zinc-300">shot 2</span>
on their own lines —
<span class="font-medium text-zinc-300">image 1</span>
works the same. Each one is a new prompt on the start still, not the last output.
</p>
</div>
<button
type="button"
class="shrink-0 rounded-full border border-white/10 px-3 py-1 text-sm text-zinc-400 hover:text-white"
@click="close"
>
Close
</button>
</div>
<div class="mt-5 grid min-h-0 gap-4 lg:grid-cols-[minmax(0,1.2fr)_minmax(16rem,1fr)]">
<label class="block min-w-0 text-sm">
<span class="mb-2 block font-medium text-zinc-300">Shot list</span>
<textarea
ref="textareaEl"
:value="text"
rows="16"
class="max-h-[min(36rem,55vh)] min-h-[16rem] w-full overflow-y-auto 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 [overflow-wrap:anywhere] focus:ring-2"
:placeholder="placeholder"
@input="onInput"
@keydown.ctrl.enter.prevent="apply"
@keydown.meta.enter.prevent="apply"
/>
</label>
<div class="min-w-0 space-y-2">
<p class="text-sm font-medium text-zinc-300">
<span v-if="parsed.length">{{ parsed.length }} image{{ parsed.length === 1 ? '' : 's' }} from the start still</span>
<span v-else class="font-normal text-zinc-500">Images appear here once the first prompt is in.</span>
</p>
<div class="max-h-[min(36rem,55vh)] space-y-2 overflow-y-auto">
<div
v-for="(shot, index) in parsed"
: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">
Image {{ index + 1 }}
<span class="font-normal text-zinc-500"> · shot {{ shot.n }}</span>
</p>
<p class="mt-1 whitespace-pre-wrap text-xs text-zinc-300 [overflow-wrap:anywhere]">{{ shot.prompt }}</p>
</div>
</div>
</div>
</div>
<div class="mt-5 flex flex-wrap items-center justify-end gap-2">
<button
type="button"
class="rounded-xl border border-white/10 px-4 py-2 text-sm text-zinc-300 hover:text-white"
@click="close"
>
Cancel
</button>
<button
type="button"
class="rounded-xl bg-amber-400 px-4 py-2 text-sm font-semibold text-zinc-950 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!parsed.length"
@click="apply"
>
{{ applyLabel }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { parseImageBatchScript } from '~/utils/parseShots'
const props = withDefaults(defineProps<{
open: boolean
text?: string
}>(), {
text: ''
})
const emit = defineEmits<{
close: []
apply: []
'update:text': [value: string]
}>()
const textareaEl = ref<HTMLTextAreaElement | null>(null)
const placeholder = `shot 1
Change her jacket to black leather.
shot 2
Put her in a red dress.
shot 3
Make it night, city lights behind her.`
const parsed = computed(() => parseImageBatchScript(props.text))
const applyLabel = computed(() => {
const n = parsed.value.length
if (!n) return 'Apply'
return n === 1 ? 'Apply 1 prompt' : `Apply ${n} prompts`
})
function onInput(event: Event) {
emit('update:text', (event.target as HTMLTextAreaElement).value)
}
function close() {
emit('close')
}
function apply() {
if (!parsed.value.length) return
emit('apply')
}
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape') close()
}
watch(() => props.open, (open) => {
if (typeof document === 'undefined') return
document.body.style.overflow = open ? 'hidden' : ''
if (open) {
document.addEventListener('keydown', onKey)
nextTick(() => textareaEl.value?.focus())
} else {
document.removeEventListener('keydown', onKey)
}
})
onBeforeUnmount(() => {
if (typeof document !== 'undefined') document.body.style.overflow = ''
document.removeEventListener('keydown', onKey)
})
</script>
+80 -9
View File
@@ -359,6 +359,19 @@
<span class="text-[11px] leading-snug text-zinc-400">Each pass from the last output</span> <span class="text-[11px] leading-snug text-zinc-400">Each pass from the last output</span>
</button> </button>
</div> </div>
<button
v-if="imagePassMode === 'batch'"
type="button"
class="w-full rounded-2xl border border-dashed px-4 py-2.5 text-left text-sm transition"
:class="imageBatchScript.trim()
? 'border-amber-300/40 bg-amber-400/5 text-amber-100 hover:border-amber-300'
: 'border-white/15 text-zinc-300 hover:border-amber-300/40 hover:text-amber-100'"
:disabled="editBusy"
@click="openImageBatchModal"
>
<span class="block font-semibold">{{ imageBatchScript.trim() ? 'Edit batch script' : 'Paste batch script' }}</span>
<span class="text-[11px] leading-snug text-zinc-400">{{ imageBatchButtonHint }}</span>
</button>
<div <div
v-for="(item, index) in editPassQueue" v-for="(item, index) in editPassQueue"
:key="item.id" :key="item.id"
@@ -1879,6 +1892,12 @@
:placeholder="promptPlaceholder" :placeholder="promptPlaceholder"
@update:open="promptDrawerOpen = $event" @update:open="promptDrawerOpen = $event"
/> />
<ImageBatchModal
:open="imageBatchOpen"
v-model:text="imageBatchDraft"
@close="imageBatchOpen = false"
@apply="applyImageBatch"
/>
<ImagePickerModal <ImagePickerModal
:open="pickerOpen" :open="pickerOpen"
:folders="folders" :folders="folders"
@@ -1962,7 +1981,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { parseEditPasses, looksLikeShotScript } from '~/utils/parseRecommend' import { parseEditPasses, looksLikeShotScript } from '~/utils/parseRecommend'
import { parseShotScript } from '~/utils/parseShots' import { parseImageBatchScript, parseShotScript } from '~/utils/parseShots'
import type { DirectorScript } from '~/utils/directorScripts' import type { DirectorScript } from '~/utils/directorScripts'
import { import {
composeShotPrompt, composeShotPrompt,
@@ -2259,10 +2278,14 @@ const recommendJobId = ref('')
const recommendApplied = ref(false) const recommendApplied = ref(false)
const promptBeforeRecommend = ref('') const promptBeforeRecommend = ref('')
const editPassesBeforeRecommend = ref<QueuedEditPass[]>([]) const editPassesBeforeRecommend = ref<QueuedEditPass[]>([])
const imageBatchBeforeRecommend = ref('')
const shotScriptBeforeRecommend = ref(false) const shotScriptBeforeRecommend = ref(false)
const extensionQueue = ref<QueuedExtension[]>([]) const extensionQueue = ref<QueuedExtension[]>([])
const editPassQueue = ref<QueuedEditPass[]>([]) const editPassQueue = ref<QueuedEditPass[]>([])
const imagePassMode = ref<ImagePassMode>('batch') const imagePassMode = ref<ImagePassMode>('batch')
const imageBatchOpen = ref(false)
const imageBatchScript = ref('')
const imageBatchDraft = ref('')
const clipName = ref('') const clipName = ref('')
const videoDownloadName = ref('output') const videoDownloadName = ref('output')
const editDownloadName = ref('edited') const editDownloadName = ref('edited')
@@ -2832,9 +2855,14 @@ const extraPassButtonLabel = computed(() => {
if (imagePassMode.value === 'batch') return '+ Prompt' if (imagePassMode.value === 'batch') return '+ Prompt'
return studioMode.value === 'editv2' && v2Mode.value === 'compose' ? '+ Compose pass' : '+ Edit pass' return studioMode.value === 'editv2' && v2Mode.value === 'compose' ? '+ Compose pass' : '+ Edit pass'
}) })
const imageBatchButtonHint = computed(() => {
const n = 1 + editPassQueue.value.length
if (imageBatchScript.value.trim() && n > 1) return `${n} prompts on the start still. Same format as a video shot list.`
return 'Paste shot 1, shot 2, shot 3. Each image is a new prompt on the original still.'
})
const extraPassHint = computed(() => { const extraPassHint = computed(() => {
if (imagePassMode.value === 'batch') { if (imagePassMode.value === 'batch') {
return 'Every prompt runs from the start still. Outputs do not feed the next prompt.' return 'Every prompt runs from the start still. Paste a shot list, or add prompts one at a time. Outputs do not feed the next prompt.'
} }
if (studioMode.value === 'editv2' && v2Mode.value === 'compose') { if (studioMode.value === 'editv2' && v2Mode.value === 'compose') {
return 'Each extra pass is another Compose on the last output, with the same still B. You cannot switch graphs between passes.' return 'Each extra pass is another Compose on the last output, with the same still B. You cannot switch graphs between passes.'
@@ -3992,7 +4020,7 @@ function applyGenerationPreset(preset: GenerationPreset) {
const settings = preset.settings as ImageV2PresetSettings const settings = preset.settings as ImageV2PresetSettings
persistImageV2LoraStack(imageV2LoraStack.value, v2Engine.value) persistImageV2LoraStack(imageV2LoraStack.value, v2Engine.value)
v2Mode.value = settings.mode === 'compose' ? 'compose' : settings.mode === 'refine' ? 'refine' : settings.mode === 'generate' ? 'generate' : 'edit' v2Mode.value = settings.mode === 'compose' ? 'compose' : settings.mode === 'refine' ? 'refine' : settings.mode === 'generate' ? 'generate' : 'edit'
if (v2Mode.value !== 'edit' && v2Mode.value !== 'compose') editPassQueue.value = [] if (v2Mode.value !== 'edit' && v2Mode.value !== 'compose') clearEditPasses()
v2Engine.value = parseImageV2Engine(settings.engine, 'flux') v2Engine.value = parseImageV2Engine(settings.engine, 'flux')
v2Task.value = v2Mode.value === 'compose' ? (settings.task || 'scene') : v2Mode.value === 'refine' ? 'refine' : v2Mode.value === 'generate' ? 't2i' : 'scene' v2Task.value = v2Mode.value === 'compose' ? (settings.task || 'scene') : v2Mode.value === 'refine' ? 'refine' : v2Mode.value === 'generate' ? 't2i' : 'scene'
if (typeof settings.prompt === 'string') prompt.value = settings.prompt if (typeof settings.prompt === 'string') prompt.value = settings.prompt
@@ -4267,12 +4295,21 @@ function applyRecommend() {
if (!text) return if (!text) return
promptBeforeRecommend.value = prompt.value promptBeforeRecommend.value = prompt.value
editPassesBeforeRecommend.value = editPassQueue.value.map(item => ({ ...item })) editPassesBeforeRecommend.value = editPassQueue.value.map(item => ({ ...item }))
imageBatchBeforeRecommend.value = imageBatchScript.value
shotScriptBeforeRecommend.value = shotScriptMode.value shotScriptBeforeRecommend.value = shotScriptMode.value
if (studioMode.value === 'edit' || v2PassChaining.value) { if (studioMode.value === 'edit' || v2PassChaining.value) {
const passes = parseEditPasses(text) const fromPasses = parseEditPasses(text)
if (passes && passes.length > 1) { const fromScript = parseImageBatchScript(text)
prompt.value = passes[0] const parts = fromPasses && fromPasses.length > 1
editPassQueue.value = passes.slice(1).map(item => ({ id: crypto.randomUUID(), prompt: item })) ? fromPasses
: fromScript.length > 1
? fromScript.map(item => item.prompt)
: null
if (parts && parts.length > 1) {
prompt.value = parts[0]
editPassQueue.value = parts.slice(1).map(item => ({ id: crypto.randomUUID(), prompt: item }))
imagePassMode.value = 'batch'
imageBatchScript.value = fromPasses ? '' : text
} else { } else {
prompt.value = text prompt.value = text
} }
@@ -4289,6 +4326,7 @@ function applyRecommend() {
function revertRecommend() { function revertRecommend() {
prompt.value = promptBeforeRecommend.value prompt.value = promptBeforeRecommend.value
editPassQueue.value = editPassesBeforeRecommend.value.map(item => ({ ...item })) editPassQueue.value = editPassesBeforeRecommend.value.map(item => ({ ...item }))
imageBatchScript.value = imageBatchBeforeRecommend.value
shotScriptMode.value = shotScriptBeforeRecommend.value shotScriptMode.value = shotScriptBeforeRecommend.value
recommendApplied.value = false recommendApplied.value = false
} }
@@ -5825,6 +5863,39 @@ function removeEditPass(id: string) {
editPassQueue.value = editPassQueue.value.filter(item => item.id !== id) editPassQueue.value = editPassQueue.value.filter(item => item.id !== id)
} }
function clearEditPasses() {
editPassQueue.value = []
imageBatchScript.value = ''
}
function reconstructImageBatchScript() {
if (imageBatchScript.value.trim()) return imageBatchScript.value
const first = prompt.value.trim()
const rest = editPassQueue.value.map(item => item.prompt.trim()).filter(Boolean)
if (!first && !rest.length) return ''
if (!rest.length) return first
return [`shot 1\n${first}`, ...rest.map((item, index) => `shot ${index + 2}\n${item}`)].join('\n\n')
}
function openImageBatchModal() {
imageBatchDraft.value = reconstructImageBatchScript()
imageBatchOpen.value = true
}
function applyImageBatch() {
const shots = parseImageBatchScript(imageBatchDraft.value)
if (!shots.length) {
toast('Paste at least one prompt. Put shot 1, shot 2 on their own lines.')
return
}
imagePassMode.value = 'batch'
imageBatchScript.value = imageBatchDraft.value
prompt.value = shots[0].prompt
editPassQueue.value = shots.slice(1).map(item => ({ id: crypto.randomUUID(), prompt: item.prompt }))
imageBatchOpen.value = false
toast(shots.length === 1 ? 'Loaded 1 prompt' : `Loaded ${shots.length} prompts on the start still`)
}
function imagePassUnit() { function imagePassUnit() {
return imagePassMode.value === 'batch' ? 'Batch' : 'Pass' return imagePassMode.value === 'batch' ? 'Batch' : 'Pass'
} }
@@ -6023,7 +6094,7 @@ async function ensureRefineCanvas() {
async function sendToEdit() { async function sendToEdit() {
if (!editResultUrl.value) return if (!editResultUrl.value) return
editPassQueue.value = [] clearEditPasses()
studioMode.value = 'editv2' studioMode.value = 'editv2'
outputFocus.value = 'edit' outputFocus.value = 'edit'
try { try {
@@ -6037,7 +6108,7 @@ async function sendToEdit() {
async function sendToRefine() { async function sendToRefine() {
if (!editResultUrl.value) return if (!editResultUrl.value) return
editPassQueue.value = [] clearEditPasses()
studioMode.value = 'editv2' studioMode.value = 'editv2'
outputFocus.value = 'edit' outputFocus.value = 'edit'
try { try {
+9 -2
View File
@@ -4,9 +4,8 @@ export interface ParsedShot {
role: 'initial' | 'extension' role: 'initial' | 'extension'
} }
export function parseShotScript(text: string): ParsedShot[] { function parseMarkedBlocks(text: string, marker: RegExp): ParsedShot[] {
const src = String(text || '').replace(/\r\n/g, '\n') const src = String(text || '').replace(/\r\n/g, '\n')
const marker = /^[ \t]*shot\s+(\d+)\b/gim
const markers: { n: number; start: number; end: number }[] = [] const markers: { n: number; start: number; end: number }[] = []
for (const match of src.matchAll(marker)) { for (const match of src.matchAll(marker)) {
markers.push({ markers.push({
@@ -41,3 +40,11 @@ export function parseShotScript(text: string): ParsedShot[] {
role: index === 0 ? 'initial' : 'extension' role: index === 0 ? 'initial' : 'extension'
})) }))
} }
export function parseShotScript(text: string): ParsedShot[] {
return parseMarkedBlocks(text, /^[ \t]*shot\s+(\d+)\b/gim)
}
export function parseImageBatchScript(text: string): ParsedShot[] {
return parseMarkedBlocks(text, /^[ \t]*(?:shot|image|prompt)\s+(\d+)\b/gim)
}