@@ -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)
+}