diff --git a/components/SettingsForm.vue b/components/SettingsForm.vue index a3811d2..4569e2e 100644 --- a/components/SettingsForm.vue +++ b/components/SettingsForm.vue @@ -68,6 +68,19 @@

{{ notifyMessage }}

+
+

Studio

+

Defaults for new video jobs. Extend still copies the length of the clip you are extending.

+
+ +
+
+

Storage

@@ -379,6 +392,13 @@ import { type KeepPromptKind, type KeepPromptLists } from '~/utils/keepPrompts' +import { + VIDEO_DURATION_DEFAULT, + clampVideoDuration, + defaultVideoDurationPref, + setDefaultVideoDurationPref +} from '~/utils/videoDuration' +import { generationNotifyPref, setGenerationNotifyPref, generationNotifySupported, ensureGenerationNotifyPermission } from '~/utils/generationNotify' export interface SettingsFolder { id: string @@ -389,7 +409,7 @@ export interface SettingsFolder { unlocked: boolean } -type SettingsPanel = 'gpu' | 'storage' | 'login' | 'folders' | 'loras' | 'keeps' | 'prompts' +type SettingsPanel = 'gpu' | 'studio' | 'storage' | 'login' | 'folders' | 'loras' | 'keeps' | 'prompts' type LoraKindFilter = 'all' | 'image' | 'video' const route = useRoute() @@ -402,7 +422,7 @@ const notice = ref('') const noticeError = ref(false) let noticeTimer: ReturnType | null = null -const panelIds: SettingsPanel[] = ['gpu', 'storage', 'login', 'folders', 'loras', 'keeps', 'prompts'] +const panelIds: SettingsPanel[] = ['gpu', 'studio', 'storage', 'login', 'folders', 'loras', 'keeps', 'prompts'] function parsePanel(raw: unknown): SettingsPanel { const value = String(raw || '') @@ -424,6 +444,7 @@ const activePanel = computed({ const navItems = computed(() => { const items: Array<{ id: SettingsPanel; label: string }> = [ { id: 'gpu', label: 'GPU' }, + { id: 'studio', label: 'Studio' }, { id: 'storage', label: 'Storage' } ] if (authMode.value === 'password') items.push({ id: 'login', label: 'Site login' }) @@ -480,6 +501,7 @@ const gpuError = ref(false) const gpuMessage = ref('') const notifyComplete = ref(true) const notifyMessage = ref('') +const defaultVideoDuration = ref(VIDEO_DURATION_DEFAULT) const storage = ref<{ libraryBytes: number breakdown: { clips: number; stills: number; tmp: number; other: number } @@ -554,6 +576,7 @@ async function loadAccount() { onMounted(() => { notifyComplete.value = generationNotifyPref() + defaultVideoDuration.value = defaultVideoDurationPref() void loadAccount() void loadPresets() void loadStorage() @@ -660,6 +683,10 @@ async function onNotifyCompleteChange() { } } +function onDefaultVideoDuration(value: number) { + defaultVideoDuration.value = setDefaultVideoDurationPref(value) +} + async function sleepGpu() { if (gpuSleeping.value || gpuWaking.value) return gpuSleeping.value = true diff --git a/pages/index.vue b/pages/index.vue index dc0644b..fc580a8 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -238,7 +238,7 @@ @remove="deleteDirectorScript" />

- Paste a block, or load a saved Director script. Text before the first marker is shot 1. Put shot 2, shot 3 on their own lines to queue extensions — a line like Shot Type will not start a new shot. Every shot uses the duration slider. Global locks are inherited; each shot should add one beat. + Paste a block, or load a saved Director script. Text before the first marker is shot 1. Put shot 2, shot 3 on their own lines to queue extensions — a line like Shot Type will not start a new shot. Queued shots use the duration slider; Extend copies the length of the clip you are extending. Global locks are inherited; each shot should add one beat. Character stills ride along as extra pictures. Picture 1 is the current frame (last frame after shot 1), not the original start still.

@@ -2298,6 +2298,12 @@ import { type VideoEngineId, type VideoStartId } from '~/utils/videoModels' +import { + VIDEO_DURATION_DEFAULT, + clampVideoDuration, + clipPartDuration, + defaultVideoDurationPref +} from '~/utils/videoDuration' const runtimeConfig = useRuntimeConfig() const route = useRoute() @@ -2537,7 +2543,7 @@ const cfgTouched = ref(false) const fps = ref(24) const samplerName = ref('res_multistep') const scheduler = ref('simple') -const duration = ref(5) +const duration = ref(VIDEO_DURATION_DEFAULT) const seedInput = ref('random') const inputSeed = ref('') @@ -2653,7 +2659,7 @@ const extendClipId = ref('') const extendPrompt = ref('') const extendPromptPre = ref('') const extendPromptPost = ref('') -const extendDuration = ref(5) +const extendDuration = ref(VIDEO_DURATION_DEFAULT) const lastFrameUrl = ref('') const lastFrameLoading = ref(false) const lastFrameRevealed = ref(false) @@ -3933,6 +3939,8 @@ onMounted(async () => { activeQueueId.value = localStorage.getItem(QUEUE_STORE) || '' const savedPreset = localStorage.getItem('aigen-prompt-preset') if (savedPreset) recommendPresetId.value = savedPreset + duration.value = defaultVideoDurationPref() + extendDuration.value = duration.value } catch { /* ignore */ } applyInstancePrivacy() bindConcealListeners() @@ -4098,8 +4106,7 @@ watch(imageV2LoraStack, (value) => { }, { deep: true }) function clampDuration(value: number) { - if (!Number.isFinite(value)) return 5 - return Math.min(120, Math.max(0.5, Math.round(value * 10) / 10)) + return clampVideoDuration(value, defaultVideoDurationPref()) } function clampCfg(value: number) { @@ -5188,7 +5195,9 @@ function studioJobStatusLabel(job: { status: string; cutIn?: boolean; holdForCut } function studioJobShotLine(job: { kind?: string; shotCount: number; completedCount?: number; status: string }) { - if (job.kind === 'music') return 'ACE-Step' + if (job.kind === 'music') { + return String(job.payload?.engine || '').toLowerCase() === 'yue' ? 'YuE' : 'ACE-Step' + } if (job.kind === 'edit') { const total = job.shotCount || 1 return total <= 1 ? 'image edit' : `${total} passes` @@ -5588,11 +5597,13 @@ async function resumeActiveJob() { jobId?: string | null video?: { jobId?: string; hideThumbnail?: boolean; folderLocked?: boolean } edit?: { jobId?: string; hideThumbnail?: boolean; folderLocked?: boolean } + music?: { jobId?: string } hideThumbnail?: boolean folderLocked?: boolean } | null>('/api/generate/active').catch(() => null) - const videoId = storedVideo?.jobId || active?.video?.jobId || (!active?.edit?.jobId ? active?.jobId : null) || '' + // Never fall back to top-level jobId — that used to steal live music jobs into Video Output. + const videoId = storedVideo?.jobId || active?.video?.jobId || '' const editId = storedEdit?.jobId || active?.edit?.jobId || '' if (videoId) { @@ -5785,13 +5796,10 @@ function stillFileName(still?: Pick | null) { } function clipGenerateDuration(item: LibraryClip) { - if (item.parentClipId) { - const parent = clips.value.find(clip => clip.id === item.parentClipId) - if (parent?.duration && item.duration && item.duration > parent.duration + 0.4) { - return clampDuration(item.duration - parent.duration) - } - } - return clampDuration(Number(item.duration) || 5) + const parent = item.parentClipId + ? clips.value.find(clip => clip.id === item.parentClipId) + : null + return clipPartDuration(item, parent) } function familyPartsFor(item: LibraryClip) { @@ -6043,7 +6051,9 @@ async function openExtend(item?: LibraryClip) { extendPromptPre.value = parts.pre extendPrompt.value = parts.prompt extendPromptPost.value = parts.post - extendDuration.value = clampDuration(duration.value) + extendDuration.value = clip + ? clipGenerateDuration(clip) + : clampDuration(duration.value) extendLoraStack.value = readLoraStack(clip) lastFrameRevealed.value = false extendOpen.value = true @@ -6752,12 +6762,13 @@ function revealAndPlay() { } function queueExtension() { + const prior = extensionQueue.value[extensionQueue.value.length - 1] extensionQueue.value.push({ id: crypto.randomUUID(), prompt: prompt.value.trim(), promptPre: promptPre.value, promptPost: promptPost.value, - duration: duration.value, + duration: clampDuration(prior?.duration ?? duration.value), loraStack: [] }) } diff --git a/server/api/extend.post.ts b/server/api/extend.post.ts index a3d4e67..e28f8b2 100644 --- a/server/api/extend.post.ts +++ b/server/api/extend.post.ts @@ -2,7 +2,7 @@ import { existsSync } from 'node:fs' import { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/server/utils/studioQueue' import { persistLoraFields, parsePostedLoraStack, listStudioLoras } from '~/server/utils/loras' import { readLoraStack } from '~/utils/loras' -import { parseExtendDuration } from '~/server/utils/extendChain' +import { clipPartDuration, clampVideoDuration } from '~/utils/videoDuration' import { defaultVideoSteps, isLtxWorkflow, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow } from '~/utils/videoModels' import { composePromptParts } from '~/utils/promptParts' import { nextFamilyPartName } from '~/server/utils/library' @@ -25,7 +25,13 @@ export default defineEventHandler(async (event) => { const destFolder = publicLibrary(event).folders.find(folder => folder.id === source.folderId) const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked) - const durationSeconds = parseExtendDuration(body?.duration) + const parent = source.parentClipId + ? (() => { try { return getClip(owner, source.parentClipId!) } catch { return null } })() + : null + const inherited = clipPartDuration(source, parent) + const durationSeconds = body?.duration == null + ? inherited + : clampVideoDuration(body.duration, inherited) const promptPre = body && Object.prototype.hasOwnProperty.call(body, 'promptPre') ? String(body.promptPre || '').trim() : String(source.promptPre || '').trim() diff --git a/server/utils/extendChain.ts b/server/utils/extendChain.ts index 260b116..29f327b 100644 --- a/server/utils/extendChain.ts +++ b/server/utils/extendChain.ts @@ -6,6 +6,7 @@ import { parsePostedLoraStack, listStudioLoras, persistLoraFields, ensureComfyLo import { readLoraStack } from '~/utils/loras' import { clampVideoCfg } from '~/utils/generationPresets' import { defaultVideoSteps, isLtxWorkflow, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow } from '~/utils/videoModels' +import { clampVideoDuration, clipPartDuration } from '~/utils/videoDuration' import { createJob, emitJob, type Job } from '~/server/utils/jobs' import { clipTitle, @@ -25,10 +26,8 @@ import { writePendingJob } from '~/server/utils/pending' const SAMPLERS = new Set(['res_multistep', 'euler', 'dpmpp_2m']) const SCHEDULERS = new Set(['simple', 'ddim_uniform', 'sgm_uniform']) -export function parseExtendDuration(raw: unknown) { - const seconds = Number(raw) - if (!Number.isFinite(seconds)) return 5 - return Math.min(120, Math.max(0.5, Math.round(seconds * 10) / 10)) +export function parseExtendDuration(raw: unknown, fallback = 5) { + return clampVideoDuration(raw, fallback) } function frameLength(seconds: number, fps: number) { @@ -71,7 +70,13 @@ export async function beginExtendFromClip(params: { throw createError({ statusCode: 404, statusMessage: 'Source video is missing' }) } const prompt = String(params.prompt || '').trim() - const durationSeconds = parseExtendDuration(params.duration) + const parent = source.parentClipId + ? (() => { try { return getClip(params.ownerKey, source.parentClipId!) } catch { return null } })() + : null + const inherited = clipPartDuration(source, parent) + const durationSeconds = params.duration == null + ? inherited + : parseExtendDuration(params.duration, inherited) const promptPre = params.promptPre != null ? String(params.promptPre).trim() : String(source.promptPre || '').trim() diff --git a/utils/videoDuration.ts b/utils/videoDuration.ts new file mode 100644 index 0000000..5c4c7ff --- /dev/null +++ b/utils/videoDuration.ts @@ -0,0 +1,42 @@ +const STORE_KEY = 'aigen-default-video-duration' +export const VIDEO_DURATION_MIN = 0.5 +export const VIDEO_DURATION_MAX = 120 +export const VIDEO_DURATION_DEFAULT = 5 + +export function clampVideoDuration(raw: unknown, fallback = VIDEO_DURATION_DEFAULT) { + const value = Number(raw) + if (!Number.isFinite(value)) return fallback + return Math.min(VIDEO_DURATION_MAX, Math.max(VIDEO_DURATION_MIN, Math.round(value * 10) / 10)) +} + +export function defaultVideoDurationPref() { + if (!import.meta.client) return VIDEO_DURATION_DEFAULT + try { + return clampVideoDuration(localStorage.getItem(STORE_KEY), VIDEO_DURATION_DEFAULT) + } catch { + return VIDEO_DURATION_DEFAULT + } +} + +export function setDefaultVideoDurationPref(raw: unknown) { + const next = clampVideoDuration(raw, VIDEO_DURATION_DEFAULT) + if (import.meta.client) { + try { + localStorage.setItem(STORE_KEY, String(next)) + } catch { /* ignore */ } + } + return next +} + +/** Length of one generated part (extension delta), not the stitched family total. */ +export function clipPartDuration( + clip: { duration?: number; parentClipId?: string | null }, + parent?: { duration?: number } | null +) { + const own = Number(clip.duration) + const prior = Number(parent?.duration) + if (clip.parentClipId && Number.isFinite(own) && Number.isFinite(prior) && own > prior + 0.4) { + return clampVideoDuration(own - prior) + } + return clampVideoDuration(own) +}