Add default video length setting and inherit duration when extending.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -68,6 +68,19 @@
|
|||||||
<p v-if="notifyMessage" class="mt-2 text-xs text-zinc-500">{{ notifyMessage }}</p>
|
<p v-if="notifyMessage" class="mt-2 text-xs text-zinc-500">{{ notifyMessage }}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="activePanel === 'studio'" class="glass rounded-3xl p-5 sm:p-6">
|
||||||
|
<h2 class="font-display text-xl font-bold">Studio</h2>
|
||||||
|
<p class="mt-1 text-sm text-zinc-400">Defaults for new video jobs. Extend still copies the length of the clip you are extending.</p>
|
||||||
|
<div class="mt-4 max-w-xs rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3">
|
||||||
|
<DurationField
|
||||||
|
v-model="defaultVideoDuration"
|
||||||
|
label="Default video length"
|
||||||
|
hint="Used when you open Studio or add a new shot. Extend uses the source clip’s length."
|
||||||
|
@update:model-value="onDefaultVideoDuration"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section v-if="activePanel === 'storage'" class="glass rounded-3xl p-5 sm:p-6">
|
<section v-if="activePanel === 'storage'" class="glass rounded-3xl p-5 sm:p-6">
|
||||||
<h2 class="font-display text-xl font-bold">Storage</h2>
|
<h2 class="font-display text-xl font-bold">Storage</h2>
|
||||||
<div class="mt-4 rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3">
|
<div class="mt-4 rounded-2xl border border-white/10 bg-zinc-950/50 px-4 py-3">
|
||||||
@@ -379,6 +392,13 @@ import {
|
|||||||
type KeepPromptKind,
|
type KeepPromptKind,
|
||||||
type KeepPromptLists
|
type KeepPromptLists
|
||||||
} from '~/utils/keepPrompts'
|
} from '~/utils/keepPrompts'
|
||||||
|
import {
|
||||||
|
VIDEO_DURATION_DEFAULT,
|
||||||
|
clampVideoDuration,
|
||||||
|
defaultVideoDurationPref,
|
||||||
|
setDefaultVideoDurationPref
|
||||||
|
} from '~/utils/videoDuration'
|
||||||
|
import { generationNotifyPref, setGenerationNotifyPref, generationNotifySupported, ensureGenerationNotifyPermission } from '~/utils/generationNotify'
|
||||||
|
|
||||||
export interface SettingsFolder {
|
export interface SettingsFolder {
|
||||||
id: string
|
id: string
|
||||||
@@ -389,7 +409,7 @@ export interface SettingsFolder {
|
|||||||
unlocked: boolean
|
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'
|
type LoraKindFilter = 'all' | 'image' | 'video'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -402,7 +422,7 @@ const notice = ref('')
|
|||||||
const noticeError = ref(false)
|
const noticeError = ref(false)
|
||||||
let noticeTimer: ReturnType<typeof setTimeout> | null = null
|
let noticeTimer: ReturnType<typeof setTimeout> | 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 {
|
function parsePanel(raw: unknown): SettingsPanel {
|
||||||
const value = String(raw || '')
|
const value = String(raw || '')
|
||||||
@@ -424,6 +444,7 @@ const activePanel = computed({
|
|||||||
const navItems = computed(() => {
|
const navItems = computed(() => {
|
||||||
const items: Array<{ id: SettingsPanel; label: string }> = [
|
const items: Array<{ id: SettingsPanel; label: string }> = [
|
||||||
{ id: 'gpu', label: 'GPU' },
|
{ id: 'gpu', label: 'GPU' },
|
||||||
|
{ id: 'studio', label: 'Studio' },
|
||||||
{ id: 'storage', label: 'Storage' }
|
{ id: 'storage', label: 'Storage' }
|
||||||
]
|
]
|
||||||
if (authMode.value === 'password') items.push({ id: 'login', label: 'Site login' })
|
if (authMode.value === 'password') items.push({ id: 'login', label: 'Site login' })
|
||||||
@@ -480,6 +501,7 @@ const gpuError = ref(false)
|
|||||||
const gpuMessage = ref('')
|
const gpuMessage = ref('')
|
||||||
const notifyComplete = ref(true)
|
const notifyComplete = ref(true)
|
||||||
const notifyMessage = ref('')
|
const notifyMessage = ref('')
|
||||||
|
const defaultVideoDuration = ref(VIDEO_DURATION_DEFAULT)
|
||||||
const storage = ref<{
|
const storage = ref<{
|
||||||
libraryBytes: number
|
libraryBytes: number
|
||||||
breakdown: { clips: number; stills: number; tmp: number; other: number }
|
breakdown: { clips: number; stills: number; tmp: number; other: number }
|
||||||
@@ -554,6 +576,7 @@ async function loadAccount() {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
notifyComplete.value = generationNotifyPref()
|
notifyComplete.value = generationNotifyPref()
|
||||||
|
defaultVideoDuration.value = defaultVideoDurationPref()
|
||||||
void loadAccount()
|
void loadAccount()
|
||||||
void loadPresets()
|
void loadPresets()
|
||||||
void loadStorage()
|
void loadStorage()
|
||||||
@@ -660,6 +683,10 @@ async function onNotifyCompleteChange() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onDefaultVideoDuration(value: number) {
|
||||||
|
defaultVideoDuration.value = setDefaultVideoDurationPref(value)
|
||||||
|
}
|
||||||
|
|
||||||
async function sleepGpu() {
|
async function sleepGpu() {
|
||||||
if (gpuSleeping.value || gpuWaking.value) return
|
if (gpuSleeping.value || gpuWaking.value) return
|
||||||
gpuSleeping.value = true
|
gpuSleeping.value = true
|
||||||
|
|||||||
+27
-16
@@ -238,7 +238,7 @@
|
|||||||
@remove="deleteDirectorScript"
|
@remove="deleteDirectorScript"
|
||||||
/>
|
/>
|
||||||
<p v-if="shotScriptMode && studioMode === 'video'" class="mb-2 text-xs text-zinc-500">
|
<p v-if="shotScriptMode && studioMode === 'video'" class="mb-2 text-xs text-zinc-500">
|
||||||
Paste a block, or load a saved Director script. Text before the first marker is shot 1. Put <span class="font-medium text-zinc-300">shot 2</span>, <span class="font-medium text-zinc-300">shot 3</span> on their own lines to queue extensions — a line like <span class="font-medium text-zinc-300">Shot Type</span> 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 <span class="font-medium text-zinc-300">shot 2</span>, <span class="font-medium text-zinc-300">shot 3</span> on their own lines to queue extensions — a line like <span class="font-medium text-zinc-300">Shot Type</span> 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.
|
||||||
<span v-if="identityPrompting"> Character stills ride along as extra pictures. Picture 1 is the current frame (last frame after shot 1), not the original start still.</span>
|
<span v-if="identityPrompting"> Character stills ride along as extra pictures. Picture 1 is the current frame (last frame after shot 1), not the original start still.</span>
|
||||||
</p>
|
</p>
|
||||||
<p v-else-if="identityPrompting" class="mb-2 text-xs text-zinc-500">
|
<p v-else-if="identityPrompting" class="mb-2 text-xs text-zinc-500">
|
||||||
@@ -2298,6 +2298,12 @@ import {
|
|||||||
type VideoEngineId,
|
type VideoEngineId,
|
||||||
type VideoStartId
|
type VideoStartId
|
||||||
} from '~/utils/videoModels'
|
} from '~/utils/videoModels'
|
||||||
|
import {
|
||||||
|
VIDEO_DURATION_DEFAULT,
|
||||||
|
clampVideoDuration,
|
||||||
|
clipPartDuration,
|
||||||
|
defaultVideoDurationPref
|
||||||
|
} from '~/utils/videoDuration'
|
||||||
|
|
||||||
const runtimeConfig = useRuntimeConfig()
|
const runtimeConfig = useRuntimeConfig()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -2537,7 +2543,7 @@ const cfgTouched = ref(false)
|
|||||||
const fps = ref(24)
|
const fps = ref(24)
|
||||||
const samplerName = ref('res_multistep')
|
const samplerName = ref('res_multistep')
|
||||||
const scheduler = ref('simple')
|
const scheduler = ref('simple')
|
||||||
const duration = ref(5)
|
const duration = ref(VIDEO_DURATION_DEFAULT)
|
||||||
const seedInput = ref('random')
|
const seedInput = ref('random')
|
||||||
const inputSeed = ref('')
|
const inputSeed = ref('')
|
||||||
|
|
||||||
@@ -2653,7 +2659,7 @@ const extendClipId = ref('')
|
|||||||
const extendPrompt = ref('')
|
const extendPrompt = ref('')
|
||||||
const extendPromptPre = ref('')
|
const extendPromptPre = ref('')
|
||||||
const extendPromptPost = ref('')
|
const extendPromptPost = ref('')
|
||||||
const extendDuration = ref(5)
|
const extendDuration = ref(VIDEO_DURATION_DEFAULT)
|
||||||
const lastFrameUrl = ref('')
|
const lastFrameUrl = ref('')
|
||||||
const lastFrameLoading = ref(false)
|
const lastFrameLoading = ref(false)
|
||||||
const lastFrameRevealed = ref(false)
|
const lastFrameRevealed = ref(false)
|
||||||
@@ -3933,6 +3939,8 @@ onMounted(async () => {
|
|||||||
activeQueueId.value = localStorage.getItem(QUEUE_STORE) || ''
|
activeQueueId.value = localStorage.getItem(QUEUE_STORE) || ''
|
||||||
const savedPreset = localStorage.getItem('aigen-prompt-preset')
|
const savedPreset = localStorage.getItem('aigen-prompt-preset')
|
||||||
if (savedPreset) recommendPresetId.value = savedPreset
|
if (savedPreset) recommendPresetId.value = savedPreset
|
||||||
|
duration.value = defaultVideoDurationPref()
|
||||||
|
extendDuration.value = duration.value
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
applyInstancePrivacy()
|
applyInstancePrivacy()
|
||||||
bindConcealListeners()
|
bindConcealListeners()
|
||||||
@@ -4098,8 +4106,7 @@ watch(imageV2LoraStack, (value) => {
|
|||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
function clampDuration(value: number) {
|
function clampDuration(value: number) {
|
||||||
if (!Number.isFinite(value)) return 5
|
return clampVideoDuration(value, defaultVideoDurationPref())
|
||||||
return Math.min(120, Math.max(0.5, Math.round(value * 10) / 10))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampCfg(value: number) {
|
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 }) {
|
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') {
|
if (job.kind === 'edit') {
|
||||||
const total = job.shotCount || 1
|
const total = job.shotCount || 1
|
||||||
return total <= 1 ? 'image edit' : `${total} passes`
|
return total <= 1 ? 'image edit' : `${total} passes`
|
||||||
@@ -5588,11 +5597,13 @@ async function resumeActiveJob() {
|
|||||||
jobId?: string | null
|
jobId?: string | null
|
||||||
video?: { jobId?: string; hideThumbnail?: boolean; folderLocked?: boolean }
|
video?: { jobId?: string; hideThumbnail?: boolean; folderLocked?: boolean }
|
||||||
edit?: { jobId?: string; hideThumbnail?: boolean; folderLocked?: boolean }
|
edit?: { jobId?: string; hideThumbnail?: boolean; folderLocked?: boolean }
|
||||||
|
music?: { jobId?: string }
|
||||||
hideThumbnail?: boolean
|
hideThumbnail?: boolean
|
||||||
folderLocked?: boolean
|
folderLocked?: boolean
|
||||||
} | null>('/api/generate/active').catch(() => null)
|
} | 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 || ''
|
const editId = storedEdit?.jobId || active?.edit?.jobId || ''
|
||||||
|
|
||||||
if (videoId) {
|
if (videoId) {
|
||||||
@@ -5785,13 +5796,10 @@ function stillFileName(still?: Pick<LibraryStill, 'name' | 'filename'> | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clipGenerateDuration(item: LibraryClip) {
|
function clipGenerateDuration(item: LibraryClip) {
|
||||||
if (item.parentClipId) {
|
const parent = item.parentClipId
|
||||||
const parent = clips.value.find(clip => clip.id === item.parentClipId)
|
? clips.value.find(clip => clip.id === item.parentClipId)
|
||||||
if (parent?.duration && item.duration && item.duration > parent.duration + 0.4) {
|
: null
|
||||||
return clampDuration(item.duration - parent.duration)
|
return clipPartDuration(item, parent)
|
||||||
}
|
|
||||||
}
|
|
||||||
return clampDuration(Number(item.duration) || 5)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function familyPartsFor(item: LibraryClip) {
|
function familyPartsFor(item: LibraryClip) {
|
||||||
@@ -6043,7 +6051,9 @@ async function openExtend(item?: LibraryClip) {
|
|||||||
extendPromptPre.value = parts.pre
|
extendPromptPre.value = parts.pre
|
||||||
extendPrompt.value = parts.prompt
|
extendPrompt.value = parts.prompt
|
||||||
extendPromptPost.value = parts.post
|
extendPromptPost.value = parts.post
|
||||||
extendDuration.value = clampDuration(duration.value)
|
extendDuration.value = clip
|
||||||
|
? clipGenerateDuration(clip)
|
||||||
|
: clampDuration(duration.value)
|
||||||
extendLoraStack.value = readLoraStack(clip)
|
extendLoraStack.value = readLoraStack(clip)
|
||||||
lastFrameRevealed.value = false
|
lastFrameRevealed.value = false
|
||||||
extendOpen.value = true
|
extendOpen.value = true
|
||||||
@@ -6752,12 +6762,13 @@ function revealAndPlay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function queueExtension() {
|
function queueExtension() {
|
||||||
|
const prior = extensionQueue.value[extensionQueue.value.length - 1]
|
||||||
extensionQueue.value.push({
|
extensionQueue.value.push({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
prompt: prompt.value.trim(),
|
prompt: prompt.value.trim(),
|
||||||
promptPre: promptPre.value,
|
promptPre: promptPre.value,
|
||||||
promptPost: promptPost.value,
|
promptPost: promptPost.value,
|
||||||
duration: duration.value,
|
duration: clampDuration(prior?.duration ?? duration.value),
|
||||||
loraStack: []
|
loraStack: []
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'
|
|||||||
import { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/server/utils/studioQueue'
|
import { addStudioJob, kickStudioQueue, listStudioJobs, videoJobsBusy } from '~/server/utils/studioQueue'
|
||||||
import { persistLoraFields, parsePostedLoraStack, listStudioLoras } from '~/server/utils/loras'
|
import { persistLoraFields, parsePostedLoraStack, listStudioLoras } from '~/server/utils/loras'
|
||||||
import { readLoraStack } from '~/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 { defaultVideoSteps, isLtxWorkflow, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow } from '~/utils/videoModels'
|
||||||
import { composePromptParts } from '~/utils/promptParts'
|
import { composePromptParts } from '~/utils/promptParts'
|
||||||
import { nextFamilyPartName } from '~/server/utils/library'
|
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 destFolder = publicLibrary(event).folders.find(folder => folder.id === source.folderId)
|
||||||
const folderLocked = Boolean(destFolder?.protected && !destFolder.unlocked)
|
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')
|
const promptPre = body && Object.prototype.hasOwnProperty.call(body, 'promptPre')
|
||||||
? String(body.promptPre || '').trim()
|
? String(body.promptPre || '').trim()
|
||||||
: String(source.promptPre || '').trim()
|
: String(source.promptPre || '').trim()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { parsePostedLoraStack, listStudioLoras, persistLoraFields, ensureComfyLo
|
|||||||
import { readLoraStack } from '~/utils/loras'
|
import { readLoraStack } from '~/utils/loras'
|
||||||
import { clampVideoCfg } from '~/utils/generationPresets'
|
import { clampVideoCfg } from '~/utils/generationPresets'
|
||||||
import { defaultVideoSteps, isLtxWorkflow, LTX_DISABLED_MESSAGE, ltxWorkflowEnabled, parseVideoWorkflow } from '~/utils/videoModels'
|
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 { createJob, emitJob, type Job } from '~/server/utils/jobs'
|
||||||
import {
|
import {
|
||||||
clipTitle,
|
clipTitle,
|
||||||
@@ -25,10 +26,8 @@ import { writePendingJob } from '~/server/utils/pending'
|
|||||||
const SAMPLERS = new Set(['res_multistep', 'euler', 'dpmpp_2m'])
|
const SAMPLERS = new Set(['res_multistep', 'euler', 'dpmpp_2m'])
|
||||||
const SCHEDULERS = new Set(['simple', 'ddim_uniform', 'sgm_uniform'])
|
const SCHEDULERS = new Set(['simple', 'ddim_uniform', 'sgm_uniform'])
|
||||||
|
|
||||||
export function parseExtendDuration(raw: unknown) {
|
export function parseExtendDuration(raw: unknown, fallback = 5) {
|
||||||
const seconds = Number(raw)
|
return clampVideoDuration(raw, fallback)
|
||||||
if (!Number.isFinite(seconds)) return 5
|
|
||||||
return Math.min(120, Math.max(0.5, Math.round(seconds * 10) / 10))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function frameLength(seconds: number, fps: number) {
|
function frameLength(seconds: number, fps: number) {
|
||||||
@@ -71,7 +70,13 @@ export async function beginExtendFromClip(params: {
|
|||||||
throw createError({ statusCode: 404, statusMessage: 'Source video is missing' })
|
throw createError({ statusCode: 404, statusMessage: 'Source video is missing' })
|
||||||
}
|
}
|
||||||
const prompt = String(params.prompt || '').trim()
|
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
|
const promptPre = params.promptPre != null
|
||||||
? String(params.promptPre).trim()
|
? String(params.promptPre).trim()
|
||||||
: String(source.promptPre || '').trim()
|
: String(source.promptPre || '').trim()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user