Copy generated stills into Input without loading the current frame.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -122,6 +122,19 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<span v-else />
|
<span v-else />
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
v-if="tab === 'output'"
|
||||||
|
type="button"
|
||||||
|
class="rounded-full bg-zinc-950/80 p-1.5 text-amber-100 hover:bg-zinc-900 disabled:opacity-50"
|
||||||
|
title="Save as input"
|
||||||
|
:disabled="keepingId === still.id"
|
||||||
|
@click.stop="keepAsInput(still)"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v9m0 0l-3.5-3.5M12 13.5l3.5-3.5M5 19.5h14" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-full bg-amber-400 p-1.5 text-zinc-950 hover:bg-amber-300"
|
class="rounded-full bg-amber-400 p-1.5 text-zinc-950 hover:bg-amber-300"
|
||||||
@@ -134,6 +147,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="flex items-center justify-between gap-2 px-3 py-2">
|
<div class="flex items-center justify-between gap-2 px-3 py-2">
|
||||||
<span class="truncate text-xs text-zinc-400">{{ still.name || still.filename }}</span>
|
<span class="truncate text-xs text-zinc-400">{{ still.name || still.filename }}</span>
|
||||||
<button type="button" class="text-[10px] text-red-300 hover:text-red-200" @click.stop="removeStill(still)">Delete</button>
|
<button type="button" class="text-[10px] text-red-300 hover:text-red-200" @click.stop="removeStill(still)">Delete</button>
|
||||||
@@ -195,6 +209,7 @@ const tab = ref<'input' | 'output'>('input')
|
|||||||
const revealAll = ref(false)
|
const revealAll = ref(false)
|
||||||
const revealedIds = ref<string[]>([])
|
const revealedIds = ref<string[]>([])
|
||||||
const picking = ref(false)
|
const picking = ref(false)
|
||||||
|
const keepingId = ref('')
|
||||||
|
|
||||||
function onKey(event: KeyboardEvent) {
|
function onKey(event: KeyboardEvent) {
|
||||||
if (!props.open) return
|
if (!props.open) return
|
||||||
@@ -223,7 +238,7 @@ const pickerHint = computed(() => {
|
|||||||
if (props.stillOnly) return 'Pick a still, then type a short name on the strip (or pick a GLOBAL LOCKS key). That name is what the shot prompt inherits.'
|
if (props.stillOnly) return 'Pick a still, then type a short name on the strip (or pick a GLOBAL LOCKS key). That name is what the shot prompt inherits.'
|
||||||
return tab.value === 'input'
|
return tab.value === 'input'
|
||||||
? 'Uploaded stills only. Generated frames and edits live under Output.'
|
? 'Uploaded stills only. Generated frames and edits live under Output.'
|
||||||
: 'Generated stills from image edits. Video frames stay with their clips.'
|
: 'Generated stills. Checkmark loads the current frame. Arrow copies the image into Input for later. Video frames stay with their clips.'
|
||||||
})
|
})
|
||||||
const hasHiddenItems = computed(() => visibleStills.value.some(still => stillHidden(still)))
|
const hasHiddenItems = computed(() => visibleStills.value.some(still => stillHidden(still)))
|
||||||
|
|
||||||
@@ -301,6 +316,19 @@ function onDrop(event: DragEvent) {
|
|||||||
if (next) void ingest(next)
|
if (next) void ingest(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function keepAsInput(still: PickerStill) {
|
||||||
|
if (keepingId.value) return
|
||||||
|
keepingId.value = still.id
|
||||||
|
try {
|
||||||
|
emit('updated', await $fetch(`/api/library/stills/${still.id}/keep-input`, { method: 'POST' }))
|
||||||
|
tab.value = 'input'
|
||||||
|
} catch (error: any) {
|
||||||
|
emit('error', error?.data?.statusMessage || 'Could not save that still as input')
|
||||||
|
} finally {
|
||||||
|
keepingId.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function pickStill(still: PickerStill) {
|
async function pickStill(still: PickerStill) {
|
||||||
if (picking.value) return
|
if (picking.value) return
|
||||||
picking.value = true
|
picking.value = true
|
||||||
|
|||||||
@@ -1384,6 +1384,15 @@
|
|||||||
>
|
>
|
||||||
Use the image I just generated as input
|
Use the image I just generated as input
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canUseGeneratedStill"
|
||||||
|
type="button"
|
||||||
|
class="inline-flex rounded-2xl border border-white/15 px-4 py-2 text-sm hover:border-amber-300/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="keepingInputBusy"
|
||||||
|
@click="saveEditAsInput"
|
||||||
|
>
|
||||||
|
{{ keepingInputBusy ? 'Saving…' : 'Save as input' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="extendOpen" class="mt-4 space-y-4 rounded-2xl border border-white/10 bg-zinc-950/50 p-4">
|
<div v-if="extendOpen" class="mt-4 space-y-4 rounded-2xl border border-white/10 bg-zinc-950/50 p-4">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
@@ -1532,6 +1541,14 @@
|
|||||||
>
|
>
|
||||||
Use as input still
|
Use as input still
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex rounded-2xl border border-white/15 px-4 py-2 text-sm hover:border-amber-300/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="keepingInputBusy"
|
||||||
|
@click="saveEditAsInput"
|
||||||
|
>
|
||||||
|
{{ keepingInputBusy ? 'Saving…' : 'Save as input' }}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70"
|
class="inline-flex rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 hover:border-amber-300/70"
|
||||||
@@ -1903,6 +1920,13 @@
|
|||||||
</button>
|
</button>
|
||||||
<button type="button" class="text-xs text-amber-300" @click="openStill(entry.stillGroup.head)">Open</button>
|
<button type="button" class="text-xs text-amber-300" @click="openStill(entry.stillGroup.head)">Open</button>
|
||||||
<button type="button" class="text-xs text-amber-300" @click="useStillAsInput(entry.stillGroup.head)">Use as input</button>
|
<button type="button" class="text-xs text-amber-300" @click="useStillAsInput(entry.stillGroup.head)">Use as input</button>
|
||||||
|
<button
|
||||||
|
v-if="entry.stillGroup.head.role !== 'input'"
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-amber-300"
|
||||||
|
:disabled="keepingInputBusy"
|
||||||
|
@click="saveStillAsInput(entry.stillGroup.head)"
|
||||||
|
>Save as input</button>
|
||||||
<button type="button" class="text-xs text-zinc-400" @click="toggleDetails(entry.key)">
|
<button type="button" class="text-xs text-zinc-400" @click="toggleDetails(entry.key)">
|
||||||
{{ expandedDetails[entry.key] ? 'Hide details' : 'Details' }}
|
{{ expandedDetails[entry.key] ? 'Hide details' : 'Details' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -1972,6 +1996,13 @@
|
|||||||
<div class="mt-1.5 flex flex-wrap items-center gap-3">
|
<div class="mt-1.5 flex flex-wrap items-center gap-3">
|
||||||
<button type="button" class="text-xs text-amber-300" @click="openStill(item)">Open</button>
|
<button type="button" class="text-xs text-amber-300" @click="openStill(item)">Open</button>
|
||||||
<button type="button" class="text-xs text-amber-300" @click="useStillAsInput(item)">Use as input</button>
|
<button type="button" class="text-xs text-amber-300" @click="useStillAsInput(item)">Use as input</button>
|
||||||
|
<button
|
||||||
|
v-if="item.role !== 'input'"
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-amber-300"
|
||||||
|
:disabled="keepingInputBusy"
|
||||||
|
@click="saveStillAsInput(item)"
|
||||||
|
>Save as input</button>
|
||||||
<button type="button" class="text-xs text-zinc-400" @click="toggleDetails(item.id)">
|
<button type="button" class="text-xs text-zinc-400" @click="toggleDetails(item.id)">
|
||||||
{{ expandedDetails[item.id] ? 'Hide details' : 'Details' }}
|
{{ expandedDetails[item.id] ? 'Hide details' : 'Details' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -2395,6 +2426,7 @@ const editLightbox = ref<HTMLElement | null>(null)
|
|||||||
const editLightboxUrl = ref('')
|
const editLightboxUrl = ref('')
|
||||||
const file = ref<File | null>(null)
|
const file = ref<File | null>(null)
|
||||||
const inputStillId = ref('')
|
const inputStillId = ref('')
|
||||||
|
const keepingInputBusy = ref(false)
|
||||||
const renamingInputStill = ref(false)
|
const renamingInputStill = ref(false)
|
||||||
const inputStillNameDraft = ref('')
|
const inputStillNameDraft = ref('')
|
||||||
const inputStillRenameEl = ref<HTMLInputElement | null>(null)
|
const inputStillRenameEl = ref<HTMLInputElement | null>(null)
|
||||||
@@ -5850,6 +5882,44 @@ function stillUseAsInputMessage(still: LibraryStill) {
|
|||||||
return 'Loaded as input. It stays in Output, not the upload library.'
|
return 'Loaded as input. It stays in Output, not the upload library.'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveStillAsInput(still: Pick<LibraryStill, 'id'>) {
|
||||||
|
if (keepingInputBusy.value) return
|
||||||
|
keepingInputBusy.value = true
|
||||||
|
try {
|
||||||
|
applyLibrary(await $fetch(`/api/library/stills/${still.id}/keep-input`, { method: 'POST' }))
|
||||||
|
toast('Saved to Input. Pick it later from Choose an image → Input.')
|
||||||
|
} catch (error: any) {
|
||||||
|
toast(error?.data?.statusMessage || 'Could not save that still as input')
|
||||||
|
} finally {
|
||||||
|
keepingInputBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEditAsInput() {
|
||||||
|
const stillId = stillIdFromLibraryUrl(editResultUrl.value)
|
||||||
|
if (stillId) {
|
||||||
|
await saveStillAsInput({ id: stillId })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!editResultUrl.value) return
|
||||||
|
if (keepingInputBusy.value) return
|
||||||
|
keepingInputBusy.value = true
|
||||||
|
try {
|
||||||
|
const blob = await $fetch<Blob>(editResultUrl.value, { responseType: 'blob' })
|
||||||
|
if (!blob?.size) throw new Error('empty still')
|
||||||
|
const body = new FormData()
|
||||||
|
body.append('image', new File([blob], `${editDownloadName.value || 'edited'}.png`, { type: blob.type || 'image/png' }))
|
||||||
|
body.append('folderId', folderId.value)
|
||||||
|
body.append('hideInput', String(hideInputPreview.value))
|
||||||
|
applyLibrary(await $fetch('/api/library/stills', { method: 'POST', body }))
|
||||||
|
toast('Saved to Input. Pick it later from Choose an image → Input.')
|
||||||
|
} catch (error: any) {
|
||||||
|
toast(error?.data?.statusMessage || 'Could not save that still as input')
|
||||||
|
} finally {
|
||||||
|
keepingInputBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function useStillAsInput(still: LibraryStill) {
|
async function useStillAsInput(still: LibraryStill) {
|
||||||
try {
|
try {
|
||||||
applyStillSettings(still)
|
applyStillSettings(still)
|
||||||
|
|||||||
@@ -245,6 +245,13 @@
|
|||||||
<div class="mt-4 flex flex-wrap gap-2">
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
<button type="button" class="rounded-full border border-amber-300/40 px-3 py-1.5 text-xs text-amber-100" @click="openInStudio(focused)">Open in Studio</button>
|
<button type="button" class="rounded-full border border-amber-300/40 px-3 py-1.5 text-xs text-amber-100" @click="openInStudio(focused)">Open in Studio</button>
|
||||||
<button type="button" class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white" @click="useInStudio(focused)">Use as input</button>
|
<button type="button" class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white" @click="useInStudio(focused)">Use as input</button>
|
||||||
|
<button
|
||||||
|
v-if="focused.kind === 'still' && focusedStillRole !== 'input'"
|
||||||
|
type="button"
|
||||||
|
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white"
|
||||||
|
:disabled="keepingInputBusy"
|
||||||
|
@click="saveFocusedAsInput"
|
||||||
|
>{{ keepingInputBusy ? 'Saving…' : 'Save as input' }}</button>
|
||||||
<a
|
<a
|
||||||
v-if="downloadHref"
|
v-if="downloadHref"
|
||||||
:href="downloadHref"
|
:href="downloadHref"
|
||||||
@@ -374,6 +381,7 @@ const renameInput = ref<HTMLInputElement | null>(null)
|
|||||||
const deleteBusy = ref(false)
|
const deleteBusy = ref(false)
|
||||||
const detailsHidden = ref(false)
|
const detailsHidden = ref(false)
|
||||||
const pendingDelete = ref<{ title: string; name: string; detail?: string; consequence: string; keys: string[] } | null>(null)
|
const pendingDelete = ref<{ title: string; name: string; detail?: string; consequence: string; keys: string[] } | null>(null)
|
||||||
|
const keepingInputBusy = ref(false)
|
||||||
const toasts = ref<Array<{ id: number; message: string }>>([])
|
const toasts = ref<Array<{ id: number; message: string }>>([])
|
||||||
let toastId = 0
|
let toastId = 0
|
||||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
@@ -420,6 +428,11 @@ const folderChipCounts = computed(() => {
|
|||||||
})
|
})
|
||||||
const moveFolders = computed(() => folders.value.filter(folder => folder.id !== folderId.value))
|
const moveFolders = computed(() => folders.value.filter(folder => folder.id !== folderId.value))
|
||||||
const focused = computed(() => entries.value.find(entry => entry.key === focusedKey.value) || null)
|
const focused = computed(() => entries.value.find(entry => entry.key === focusedKey.value) || null)
|
||||||
|
const focusedStill = computed(() => {
|
||||||
|
if (focused.value?.kind !== 'still' || !focused.value.stillGroup) return null
|
||||||
|
return focused.value.stillGroup.parts.find(part => part.id === focusedPartId.value) || focused.value.stillGroup.head
|
||||||
|
})
|
||||||
|
const focusedStillRole = computed(() => focusedStill.value?.role === 'input' ? 'input' : (focusedStill.value ? (focusedStill.value.role || 'output') : ''))
|
||||||
const emptyLabel = computed(() => {
|
const emptyLabel = computed(() => {
|
||||||
if (search.value.trim()) return 'Nothing matches that search.'
|
if (search.value.trim()) return 'Nothing matches that search.'
|
||||||
if (filter.value === 'images') return 'No generated stills here yet.'
|
if (filter.value === 'images') return 'No generated stills here yet.'
|
||||||
@@ -716,6 +729,20 @@ function useInStudio(entry: LibraryEntry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveFocusedAsInput() {
|
||||||
|
const still = focusedStill.value
|
||||||
|
if (!still || keepingInputBusy.value) return
|
||||||
|
keepingInputBusy.value = true
|
||||||
|
try {
|
||||||
|
applyLibrary(await $fetch(`/api/library/stills/${still.id}/keep-input`, { method: 'POST' }))
|
||||||
|
toast('Saved to Input. Pick it later from Choose an image → Input.')
|
||||||
|
} catch (error: any) {
|
||||||
|
toast(error?.data?.statusMessage || 'Could not save that still as input')
|
||||||
|
} finally {
|
||||||
|
keepingInputBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startRename() {
|
function startRename() {
|
||||||
if (!focused.value) return
|
if (!focused.value) return
|
||||||
renaming.value = true
|
renaming.value = true
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { assertFolderAccess, getStill, keepStillAsInput, libraryOwnerKey, publicLibrary } from '~/server/utils/library'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const id = String(getRouterParam(event, 'id') || '')
|
||||||
|
const owner = libraryOwnerKey(event)
|
||||||
|
const still = getStill(owner, id)
|
||||||
|
assertFolderAccess(event, still.folderId)
|
||||||
|
const saved = await keepStillAsInput(owner, id)
|
||||||
|
return { still: saved, ...publicLibrary(event) }
|
||||||
|
})
|
||||||
@@ -1280,6 +1280,29 @@ export async function saveStill(params: {
|
|||||||
return still
|
return still
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function keepStillAsInput(owner: string, id: string) {
|
||||||
|
const still = getStill(owner, id)
|
||||||
|
if (stillRole(still) === 'input') return still
|
||||||
|
const path = stillPath(owner, still.id)
|
||||||
|
if (!existsSync(path)) throw createError({ statusCode: 404, statusMessage: 'Image file missing' })
|
||||||
|
const data = readFileSync(path)
|
||||||
|
const name = (still.name || still.filename.replace(/\.[a-z0-9]+$/i, '') || 'still').trim().slice(0, 80)
|
||||||
|
const base = lastFrameStillName(name) || clipIdFromStillFilename(`${name}.png`) ? 'still' : name
|
||||||
|
const saved = await saveStill({
|
||||||
|
ownerKey: owner,
|
||||||
|
folderId: still.folderId,
|
||||||
|
filename: `${base}.png`,
|
||||||
|
data,
|
||||||
|
width: still.width,
|
||||||
|
height: still.height,
|
||||||
|
hideInput: still.hideInput,
|
||||||
|
role: 'input',
|
||||||
|
name
|
||||||
|
})
|
||||||
|
if (!saved) throw createError({ statusCode: 400, statusMessage: 'Could not save that still as input' })
|
||||||
|
return saved
|
||||||
|
}
|
||||||
|
|
||||||
export async function rememberInputStill(params: {
|
export async function rememberInputStill(params: {
|
||||||
ownerKey: string
|
ownerKey: string
|
||||||
folderId: string
|
folderId: string
|
||||||
|
|||||||
Reference in New Issue
Block a user