diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index 91ea03f2..41b3113e 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -1,25 +1,42 @@ { "schema": 1, - "product_version": "0.4.22", + "product_version": "0.4.23", "en": { - "summary": "Autopilot chat can attach reference pictures; the launcher and the editor explain more precisely why a step failed; Mac setup no longer needs Git installed.", + "summary": "Each part planned in the editor can have its own description; low-memory generations explain why they are slower.", "changes": [ - "Autopilot: a chat message can carry reference pictures, added with the Picture button, by pasting or by dragging, up to 4 per message and 10 MB each. A picture that fails to upload stays in place and can be clicked to retry.", - "Launcher and setup: choosing a folder inside a portable ComfyUI, such as its python_embeded folder, now names the portable folder to select. Mac setup downloads its own Git when the system has none. A failed Git download no longer leaves an incomplete folder behind, and leftovers from earlier attempts are removed. On Windows, when security software briefly holds the newly downloaded installer tool, setup retries for a few seconds instead of stopping with \"This folder is not writable\".", - "Edit: a failed save now states its cause, and a lost connection is reported as a lost connection. Edit also works on an https page served through a proxy that terminates the secure connection, as on some cloud GPU hosts.", - "Diagnostic reports: the page check states why an open creative panel cannot be seen. When a request is refused for lack of memory on Windows, the report records how much of the used memory FreeVideo itself holds." + "Generation: When a generation runs in low-memory mode, the progress view says why it is slower.", + "Editor: Each part of a video planned in the editor can now have its own description, and a continued part uses exactly what you write for it. The highlight on characters mentioned with @ in the prompt box updates as soon as a rewritten prompt is filled in." ] }, "zh": { - "summary": "AI 全自动聊天可以附参考图;启动器和编辑器更准确地说明失败原因;Mac 安装不再需要预先装好 Git。", + "summary": "剪辑里规划的每一段可以单独写描述;低显存模式下的生成会说明较慢的原因。", "changes": [ - "AI 全自动:聊天消息可以附参考图,用“图片”按钮、粘贴或拖入添加,每条最多 4 张、每张不超过 10 MB。上传失败的图片会保留,点击即可重试。", - "启动器和安装:选中便携版 ComfyUI 内部的文件夹(例如 python_embeded)时,提示会写出应选择的便携版文件夹。Mac 系统没有 Git 时,安装会自行下载 Git。Git 下载失败后不再留下不完整的文件夹,以前残留的也会清除。Windows 上安全软件短暂占用刚下载的安装工具时,安装会重试数秒,不再以“此文件夹不可写”停止。", - "编辑:保存失败时会写明原因,连接断开时显示为连接已断开。通过代理终止安全连接的 https 页面(部分云端显卡主机即是如此)上也可以正常使用编辑。", - "诊断报告:页面检查会写明创作面板已打开却看不到的原因。Windows 上请求因内存不足被拒绝时,报告会写出已用内存中 FreeVideo 自己占用多少。" + "生成:低显存模式下,进度区会说明这次生成较慢的原因。", + "剪辑视图:剪辑里规划的每一段现在都可以单独写描述,接着生成的一段会用为它写的描述。改写后的提示词填回时,@人物 高亮立即刷新。" ] }, "history": [ + { + "product_version": "0.4.22", + "en": { + "summary": "Autopilot chat can attach reference pictures; the launcher and the editor explain more precisely why a step failed; Mac setup no longer needs Git installed.", + "changes": [ + "Autopilot: a chat message can carry reference pictures, added with the Picture button, by pasting or by dragging, up to 4 per message and 10 MB each. A picture that fails to upload stays in place and can be clicked to retry.", + "Launcher and setup: choosing a folder inside a portable ComfyUI, such as its python_embeded folder, now names the portable folder to select. Mac setup downloads its own Git when the system has none. A failed Git download no longer leaves an incomplete folder behind, and leftovers from earlier attempts are removed. On Windows, when security software briefly holds the newly downloaded installer tool, setup retries for a few seconds instead of stopping with \"This folder is not writable\".", + "Edit: a failed save now states its cause, and a lost connection is reported as a lost connection. Edit also works on an https page served through a proxy that terminates the secure connection, as on some cloud GPU hosts.", + "Diagnostic reports: the page check states why an open creative panel cannot be seen. When a request is refused for lack of memory on Windows, the report records how much of the used memory FreeVideo itself holds." + ] + }, + "zh": { + "summary": "AI 全自动聊天可以附参考图;启动器和编辑器更准确地说明失败原因;Mac 安装不再需要预先装好 Git。", + "changes": [ + "AI 全自动:聊天消息可以附参考图,用“图片”按钮、粘贴或拖入添加,每条最多 4 张、每张不超过 10 MB。上传失败的图片会保留,点击即可重试。", + "启动器和安装:选中便携版 ComfyUI 内部的文件夹(例如 python_embeded)时,提示会写出应选择的便携版文件夹。Mac 系统没有 Git 时,安装会自行下载 Git。Git 下载失败后不再留下不完整的文件夹,以前残留的也会清除。Windows 上安全软件短暂占用刚下载的安装工具时,安装会重试数秒,不再以“此文件夹不可写”停止。", + "编辑:保存失败时会写明原因,连接断开时显示为连接已断开。通过代理终止安全连接的 https 页面(部分云端显卡主机即是如此)上也可以正常使用编辑。", + "诊断报告:页面检查会写明创作面板已打开却看不到的原因。Windows 上请求因内存不足被拒绝时,报告会写出已用内存中 FreeVideo 自己占用多少。" + ] + } + }, { "product_version": "0.4.21", "en": { @@ -226,39 +243,6 @@ "Linux:内存较小的电脑接着再生成一次时,不再在采样快结束时内存不足。6 GB 显存的显卡选“极致”时,不再要等好几个小时。" ] } - }, - { - "product_version": "0.4.14", - "en": { - "summary": "Max is faster, a picture can now be changed only inside a region you select, agents can import their own clips into the timeline, and long videos can take more requests into the queue while they generate.", - "changes": [ - "Quality levels: Max now uses 20 steps with a guarded step cache and finishes sooner; High uses 16 steps with the step cache. The level names stay the same.", - "Picture editing: when changing a picture, drag a box, click an object, or draw a closed outline to select a region. Shift adds to the selection and Alt removes from it. Only the selected region changes, and everything outside it stays exactly the same. Click selection downloads a 184 MB model from ModelScope the first time it is used.", - "Studio: while a long or multi-part video is generating, the button stays available as \"Add to queue\". Up to five requests can wait, and each starts when the current video finishes or is stopped.", - "Agent: agents can import clips they rendered through HTTP, MCP and the command line, place them on the timeline by frame, and export. Before an agent is connected, the autopilot page says what connecting one is for.", - "Editing view: a reference picture that cannot be read is flagged right away so another one can be chosen.", - "Upgrade: when an old FreeVideo tab is replaced, two-pass sampling, first- and second-pass steps, the step cache and the upscale factor are carried over.", - "Prompt enhancement: after the model folder is copied, an offline package is unpacked, or antivirus software scans the files, FreeVideo no longer asks to download 8.9 GB again when the files are unchanged. It now also works on 6 GB graphics cards, finishing in under a minute instead of timing out.", - "Chinese interface: browser translation no longer changes FreeVideo's own labels, so the size menu keeps showing MP.", - "Feedback: feedback sent for a failed generation now includes the engine's error message.", - "Install: the Windows library checks also handle files in deeply nested folders." - ] - }, - "zh": { - "summary": "极致档更快;改图时可以只改选中的区域;Agent 可以把自己渲染的片段导入时间线;长视频生成中可以继续加入队列。", - "changes": [ - "质量档位:极致档改为 20 步加带检查的步缓存,更快完成;精细档改为 16 步加步缓存。档位名称不变。", - "改图:改一张图时,可以拖框、点选物体或画闭合曲线来选区;按住 Shift 添加,按住 Alt 减去。只改选区里的内容,选区外完全不变。点选第一次使用时从魔搭下载 184 MB 的模型。", - "创作面板:长视频或分段视频生成中,按钮保持可用,显示“加入队列”。最多排 5 条,当前视频完成或停止后自动开始下一条。", - "Agent:Agent 可以通过 HTTP、MCP 和命令行导入自己渲染的片段,按帧放上时间线并导出。未连接时,自动页说明连上之后能做什么。", - "剪辑视图:参考图读不出来时会当场提示,可以换一张。", - "升级:替换旧版本的 FreeVideo 标签页时,二次采样、第一遍和第二遍步数、步缓存和放大倍数都会带过来。", - "提示词增强:复制模型文件夹、解压离线包或被杀毒软件扫描之后,只要文件内容没变,就不再要求重新下载 8.9 GB。6 GB 显卡上现在也能用,一分钟内完成,不再超时。", - "中文界面:浏览器翻译不再改动 FreeVideo 自己的文字,尺寸菜单保持显示 MP。", - "反馈:为失败的生成发送反馈时,会附上引擎给出的报错信息。", - "安装:Windows 上的库文件检查也支持层级很深的文件夹。" - ] - } } ] } diff --git a/web/editor.js b/web/editor.js index 1c55f65b..eb1fdd1c 100644 --- a/web/editor.js +++ b/web/editor.js @@ -392,6 +392,22 @@ export function openEditor({api, app, from = null, panel = {}, components: given const generating = clip => ['queued', 'running'].includes(clip?.pending); const isHeld = id => held.some(entry => entry.ids.includes(id)); const partBefore = start => state.record.tracks[0].clips.find(clip => clip.frames > 0 && clip.start + clip.frames === start); + // A planned part whose description is still the user's to write: not started, not being sent. + const editablePart = clip => !!clip && !clip.source && clip.pending === 'draft' && !clip.job && !sending.has(clip.id); + const attached = (clip, before) => !!before && before.start + before.frames === clip.start && model.continuesBefore(state.record, clip.id); + // The planned parts Generate makes from a choice of planned parts: with the parts before them that they + // continue and that are not started yet, as one video; a part whose earlier part is not started would + // otherwise start a new one. + function partRun(selection) { + const free = clip => editablePart(clip) && !isHeld(clip.id); + const clips = selectionClips(selection).sort((a, b) => a.start - b.start); + if (!clips.length || !clips.every(free)) return null; + if (!clips.every((clip, i) => !i || attached(clip, clips[i - 1]))) return clips; + const run = [...clips]; + for (let before; attached(run[0], before = partBefore(run[0].start)) && free(before);) run.unshift(before); + return run; + } + const clipChoice = clips => ({kind: 'clip', ids: clips.flatMap(clip => clip.link ? [clip.id, clip.link] : [clip.id])}); function busySelection(selection) { const range = selectionRange(selection); if (!range) return false; if ([...sendingRanges].some(other => overlaps(other, range))) return true; @@ -438,13 +454,13 @@ export function openEditor({api, app, from = null, panel = {}, components: given progress.set(id, value); tracks?.setProgress(id, value); } }; const queuedReady = () => t('Queued. It starts once generation is ready.', '已排队,准备好后自动开始。'); - function hold(selection, text, job = null) { - const range = selectionRange(selection), selectedIds = selectionClips(selection).map(clip => clip.id), shown = selection === state.selection; + function hold(selection, text, job = null, {texts = null, shown = selection === state.selection} = {}) { + const range = selectionRange(selection), selectedIds = selectionClips(selection).map(clip => clip.id); let ids = []; if (job) ids = selectedIds; else state.apply(t('Generate', '生成'), record => { const selected = selectedIds.map(id => model.find(record, id)).filter(Boolean), planned = selected.length && selected.every(clip => !clip.source && clip.pending && !generating(clip)); - const next = planned ? model.startAttempt(record, selected.map(clip => clip.id), text) : model.planRange(record, {...range, target: 'both', text, maxSeconds: capability.maxSeconds, maxFollowSeconds: capability.maxFollowSeconds}); + const next = planned ? model.startAttempt(record, selected.map(clip => clip.id), texts ?? text) : model.planRange(record, {...range, target: 'both', text, maxSeconds: capability.maxSeconds, maxFollowSeconds: capability.maxFollowSeconds}); ids = next.tracks[0].clips.filter(clip => clip.start >= range.start && clip.start < range.start + range.frames && clip.pending && !generating(clip)).map(clip => clip.id); return next; }, {checkpoint: true}); @@ -471,7 +487,9 @@ export function openEditor({api, app, from = null, panel = {}, components: given const selection = {kind: 'clip', ids: clips.map(clip => clip.id)}, before = partBefore(Math.min(...clips.map(clip => clip.start))); held.shift(); markHeld(entry.ids, false); rememberView(); if (before && !before.source && before.pending && !before.job) { genbar?.say(t('The part before did not finish, so this one did not start.', '上一段没有完成,这一段没有开始。')); continue; } - try { await actions.generate({text: entry.text, selection}); } + // Each part goes with its own description, as written while it waited. + const texts = Object.fromEntries(clips.map(clip => [clip.id, clip.text.trim() || entry.text])); + try { await actions.generate({text: texts[clips[0].id], texts, selection}); } catch (value) { const reason = error(value, 'generate'); if (reason && !['chain_too_long', 'chain_part_too_short'].includes(reason.reason)) genbar?.say(reason.say); } } } finally { startingHeld = false; } @@ -774,6 +792,8 @@ export function openEditor({api, app, from = null, panel = {}, components: given generationWaiting: () => generationAsked && !store.generationAvailable, importFiles: (files, options) => library?.importFiles(files, options), gestureStart({selectionGesture = false} = {}) { + // A description being typed is written to its part before the gesture, which a later edit would stop. + genbar?.commit(); pointerEditing = true; if (selectionGesture && !menu.hidden) { menu.hidden = true; updateTransport(); } }, @@ -834,13 +854,25 @@ export function openEditor({api, app, from = null, panel = {}, components: given canGenerate(text, selection = state.selection) { return store.generationAvailable && actions.supportedSelection(selection) && (!!text.trim() || !actions.needsText(selection)); }, + // The description the box shows for a choice of planned parts, and the parts it is written to; null for + // any other choice. + partText(selection = state.selection) { + const clips = selectionClips(selection).sort((a, b) => a.start - b.start); + return clips.length && clips.every(editablePart) ? {ids: clips.map(clip => clip.id), text: clips[0].text} : null; + }, + describeParts(ids, text) { + if (!ids.every(id => editablePart(model.find(state.record, id)))) return false; + try { state.apply(t('Edit description', '修改说明'), record => model.describePlanned(record, ids, text)); return true; } + catch (value) { if (value instanceof model.EditError) return false; throw value; } + }, needsText(selection = state.selection) { const range = selectionRange(selection); if (!range) return true; - // Only a part pulled out of a finished one needs no description: it continues that one with the - // description it took over. A finished video without one (an exported edit) still needs it. + // Only a part pulled out of a finished one needs no description: it continues that one, with that one's + // description when it has none of its own. A finished video without one (an exported edit) still needs it. const head = state.record.tracks[0].clips.find(clip => clip.start === range.start); - const before = !!head && !!head.text?.trim() && model.continuesBefore(state.record, head.id) && state.record.tracks[0].clips.some(clip => - clip.start + clip.frames === range.start && clip.source && clip.extend && clip.in + clip.frames === clip.length); + const before = !!head && model.continuesBefore(state.record, head.id) && state.record.tracks[0].clips.some(clip => + clip.start + clip.frames === range.start && clip.source && clip.extend && clip.in + clip.frames === clip.length + && !!(head.text?.trim() || clip.text?.trim())); return !before; }, // Only where earlier footage is continued: a part right after an extendable clip, or a range split into several parts. @@ -864,16 +896,17 @@ export function openEditor({api, app, from = null, panel = {}, components: given }}, previewRange: selection => genbar?.preview(selection), async estimate(selection) { - const range = selectionRange(selection); if (!range) return null; + const run = partRun(selection), range = selectionRange(run ? clipChoice(run) : selection); if (!range) return null; let seconds = 0; for (const count of model.rangeParts(state.record, {...range, ...capability})) { const value = capability.estimate(count); if (value == null) return null; seconds += value; } return seconds; }, - async generate({target = 'both', text = '', label = t('Generate', '生成'), selection = state.selection} = {}) { + // texts: a description for each planned part, by the part's picture; otherwise text is every part's. + async generate({target = 'both', text = '', texts = null, label = t('Generate', '生成'), selection = state.selection, shown = selection === state.selection} = {}) { if (target !== 'both' || !actions.supportedSelection(selection)) throw new model.EditError('unsupported'); if (!actions.canGenerate(text, selection)) throw new model.EditError('text'); submissionFailure = null; genbar?.update(null); - const shown = selection === state.selection; + const chosen = state.selection; generationBusy = true; updateTop(); updateTransport(); generationDone = new Promise(resolve => { finishGeneration = resolve; }); let ids = [], prepared = null, beforeAttempt = null, priorSelection = null, priorText = null, submittedSelection = null, frozen = null; @@ -884,7 +917,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given beforeAttempt = state.record; priorSelection = state.selection; priorText = state.rangeText; state.apply(label, record => { const selected = selectedIds.map(id => model.find(record, id)).filter(Boolean), alreadyPlanned = selected.length && selected.every(clip => !clip.source && clip.pending && !['queued', 'running'].includes(clip.pending)); - let next = alreadyPlanned ? model.startAttempt(record, selected.map(clip => clip.id), text) : model.planRange(record, {...range, target: 'both', text, maxSeconds: capability.maxSeconds, maxFollowSeconds: capability.maxFollowSeconds}); + let next = alreadyPlanned ? model.startAttempt(record, selected.map(clip => clip.id), texts ?? text) : model.planRange(record, {...range, target: 'both', text, maxSeconds: capability.maxSeconds, maxFollowSeconds: capability.maxFollowSeconds}); if (!record.tracks[0].clips.length && text.trim()) next = model.rename(next, model.truncateText(text.trim(), 40)); ids = next.tracks[0].clips.filter(clip => clip.start >= range.start && clip.start < range.start + range.frames && clip.pending && !['queued', 'running'].includes(clip.pending)).map(clip => clip.id); return model.prepare(next, ids); @@ -893,9 +926,9 @@ export function openEditor({api, app, from = null, panel = {}, components: given prepared = state.record; // Only a press on what is still chosen moves the choice; a part started in the background // leaves whatever the user has moved on to, and what they typed there. - submittedSelection = shown && state.selection === selection ? state.select({kind: 'clip', ids}) : state.selection; await completeSources(); + submittedSelection = shown && state.selection === chosen ? state.select({kind: 'clip', ids}) : state.selection; await completeSources(); const paths = Object.fromEntries([...store.sources].map(([key, value]) => [key, value.path])); - const request = model.generationRequest(prepared, ids, {text, paths, queue: true}); + const request = model.generationRequest(prepared, ids, {text: texts ? undefined : text, paths, queue: true}); const beforeKeys = model.generationBefore(prepared, ids), sizes = new Map(store.sources); for (const key of beforeKeys.filter(key => key?.startsWith('chain:'))) { const job = await store.chain(key.split(':')[1]); @@ -973,12 +1006,31 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (!moved) return {say: t('Choose an empty spot on the timeline.', '请在时间线上选一段空白。')}; selection = state.select(moved); said = t('Moved to the next empty spot.', '已挪到后面的空位。'); genbar?.say(said); } - let used = null; - if (!text.trim() && actions.needsText(selection)) { + let used = null, texts = null, shown = selection === state.selection; + const before = t('Used the description of the part before.', '沿用了上一段的描述。'), latest = t('Used the latest description.', '沿用了最近一次的描述。'); + let run = partRun(selection); + if (run) { + // What is in the box is the chosen parts' description; each part goes with its own, and one with + // none uses the one before it. + const chosen = selectionClips(selection).sort((a, b) => a.start - b.start); + if (text.trim() && text.trim() !== chosen[0].text && actions.describeParts(chosen.map(clip => clip.id), text)) run = partRun(selection); + texts = {}; + for (const [i, clip] of run.entries()) { + let value = clip.text.trim(); + if (!value && i && attached(clip, run[i - 1])) { value = texts[run[i - 1].id]; used ??= before; } + else if (!value) { + const found = nearbyText(clip.start); + if (!found) return {say: [said, t('Write a description first.', '先写一句描述。')].filter(Boolean).join(' '), focus: true}; + value = found.text; used ??= found.before ? before : latest; + } + texts[clip.id] = value; + } + text = texts[chosen[0].id]; + selection = clipChoice(run); + } else if (!text.trim() && actions.needsText(selection)) { const found = nearbyText(selectionRange(selection).start); if (!found) return {say: [said, t('Write a description first.', '先写一句描述。')].filter(Boolean).join(' '), focus: true}; - text = found.text; used = found.before ? t('Used the description of the part before.', '沿用了上一段的描述。') - : t('Used the latest description.', '沿用了最近一次的描述。'); + text = found.text; used = found.before ? before : latest; state.setRangeText(text); genbar?.setText(text); } else if (!text.trim()) { // The box was emptied; the part keeps the description it took over, as an empty one cannot be generated. @@ -987,12 +1039,12 @@ export function openEditor({api, app, from = null, panel = {}, components: given } const waiting = waitingFor(selection); if (waiting) { - hold(selection, text); + hold(selection, texts ? texts[run[0].id] : text, null, {texts, shown}); return {text, say: [said, used, waiting === 'ready' ? queuedReady() : t('Queued. It starts after the previous part finishes.', '已排队,上一段完成后自动开始。')].filter(Boolean).join(' '), queued: true}; } const busyBefore = jobActive(); - await actions.generate({text, selection}); + await actions.generate({text: texts ? texts[run[0].id] : text, texts, selection, shown}); return {text, say: [said, used, busyBefore ? t('Queued. It starts after the part now generating.', '已排队,正在生成的那段完成后自动开始。') : null].filter(Boolean).join(' ') || null, queued: busyBefore}; }, selectionRunning: () => busySelection(state.selection), diff --git a/web/editor_genbar.js b/web/editor_genbar.js index b6152eb9..823e22bc 100644 --- a/web/editor_genbar.js +++ b/web/editor_genbar.js @@ -202,7 +202,29 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { width = next; resizeDescription(); }) : null; resize?.observe(description); - input.oninput = () => { state.setRangeText(input.value); render(); }; + // The box shows the description of the planned parts chosen and is written to them when the choice moves + // on, the box is left or Generate is pressed. What is typed with no part or range chosen goes to the next + // part chosen that has no description yet. + // written: what is to be written, which the enlarged box has before the small one. + let bound = null, dirty = false, loose = false, committing = false, written = ''; + const typed = (value = input.value) => { + state.setRangeText(value); written = value; + if (bound) dirty = true; else loose = state.selection?.kind !== 'range'; + }; + function commit() { + if (!bound || !dirty) return; + const {ids} = bound; dirty = false; committing = true; + try { actions.describeParts?.(ids, written); } finally { committing = false; } + } + function bind() { + const part = actions.partText?.(state.selection) ?? null; + if (part && !part.text && loose && input.value.trim()) { bound = part; dirty = true; written = input.value; } + else if (part) { bound = part; dirty = false; input.value = part.text; } + else bound = null; + if (part || state.selection?.kind === 'range') loose = false; + } + input.oninput = () => { typed(); render(); }; + input.onblur = () => { if (dirty) { commit(); bind(); } }; input.onkeydown = event => { // The former text input did not submit or insert a line on Enter. if (event.key === 'Enter' && !event.shiftKey && !event.isComposing && event.keyCode !== 229) event.preventDefault(); @@ -211,14 +233,15 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { const key = JSON.stringify(state.selection ?? null); if (pressing.has(key)) return; pressing.add(key); say(''); render(); - const typed = input.value; + commit(); + const shown = input.value; let reason = ''; try { // The press shows before any planning, however long that takes. await painted(); const result = actions.press ? await actions.press(input.value) : await actions.generate({target: 'both', text: input.value}); // Never over what the user typed meanwhile, for another part. - if (result?.text != null && result.text !== input.value && input.value === typed) input.value = result.text; + if (result?.text != null && result.text !== input.value && input.value === shown) input.value = result.text; if (result?.say) { say(result.say, !!result.queued); reason = result.say; } if (result?.focus) { pressing.delete(key); render(); input.disabled = false; input.focus(); } } @@ -229,7 +252,7 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { finally { pressing.delete(key); render(); } return reason; }; - const expand = createExpandButton({t, textarea: input, onGenerate: () => generate.onclick(), canGenerate: () => !generate.disabled, onDraft: text => state.setRangeText(text)}); + const expand = createExpandButton({t, textarea: input, onGenerate: () => generate.onclick(), canGenerate: () => !generate.disabled, onDraft: text => typed(text)}); description.append(expand.element); cancel.onclick = () => (!previewSelection && actions.selectionHeld?.() ? Promise.resolve(actions.unhold()).then(render) : actions.cancel()).catch(actions.error); resume.onclick = () => actions.resume().catch(value => actions.error(value, 'generate')); @@ -238,20 +261,23 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { if (state.rangeText === null) state.setRangeText(input.value); else if (input.value !== state.rangeText) input.value = state.rangeText; } - if (event.type === 'selection' && state.selection?.kind === 'clip') { - const selected = state.record.tracks.flatMap(track => track.clips).find(clip => state.selection.ids.includes(clip.id) && clip.pending); - // A part with no description of its own keeps what is typed, as a new range does. + if (event.type === 'selection') { + commit(); bind(); + // A part being made shows the description it is made with. + const selected = !bound && state.selection?.kind === 'clip' && state.record.tracks.flatMap(track => track.clips).find(clip => state.selection.ids.includes(clip.id) && clip.pending); if (selected?.text) input.value = selected.text; } + // An undo, or a description written elsewhere, shows unless something not yet written is in the box. + if (event.type === 'record' && !committing && !dirty && (bound || actions.partText?.(state.selection))) bind(); if (event.type === 'selection' || event.type === 'record') render(); }); render(); - return {element: host, say, downloadFailure, + return {element: host, say, downloadFailure, commit, service(value) { service = value; if (!value && note.contains(retryService)) { retryService.remove(); note.hidden = true; } showService(); - }, getText() { expand.commit(); return input.value; }, update(value) { status = value; render(); }, preview(selection) { previewSelection = selection; render(); }, setText(value) { input.value = value || ''; state.setRangeText(input.value); render(); }, + }, getText() { expand.commit(); return input.value; }, update(value) { status = value; render(); }, preview(selection) { previewSelection = selection; render(); }, setText(value) { input.value = value || ''; state.setRangeText(input.value); if (bound && input.value !== bound.text) { dirty = true; written = input.value; } render(); }, dispose() { disposed = true; ++serial; clearTimeout(noteTimer); clearTimeout(refNoticeTimer); unsubscribe(); resize?.disconnect(); expand.dispose(); references?.dispose(); assets?.dispose(); }}; } diff --git a/web/editor_model.js b/web/editor_model.js index 0e7d5a86..dbd62237 100644 --- a/web/editor_model.js +++ b/web/editor_model.js @@ -362,6 +362,18 @@ function written(clip, text, by) { if (by === 'user' && text !== clip.text) delete clip.note; clip.text = text; if (text && by === 'agent') clip.text_by = by; else delete clip.text_by; } +// A planned part's own description. The parts after it that still had the same one take the new one too; +// a part with none of its own is left so, and goes on using the one before it when generated. +export function describePlanned(record, ids, text, {by = 'user'} = {}) { + const selected = linkedIds(record, ids), video = trackBy(record, 'v1').clips; + const chosen = video.filter(clip => selected.has(clip.id)).sort((a, b) => a.start - b.start); + const following = [], old = chosen.at(-1)?.text ?? ''; + for (let last = chosen.at(-1), next; old && (next = video.find(clip => continuing(clip, last))); last = next) { + if (selected.has(next.id) || next.source || next.pending !== 'draft' || next.job || (next.text && next.text !== old)) break; + if (next.text) following.push(next.id); + } + return describe(record, [...ids, ...following], text, {by}); +} function onBlocks(record, ids, change, refuseBusy, {picture = false, kept = false} = {}) { check(Array.isArray(ids) && ids.length > 0 && ids.every(id => typeof id === 'string'), 'id'); for (const id of ids) check(find(record, id), 'missing'); @@ -923,8 +935,9 @@ export function extendClip(record, id, frames, {maxSeconds = RULES.maxSeconds, m const next = copy(restored), sound = sourceAudioTrack(next, start, count); const parts = rangeParts(restored, {start, frames: count, continues: true, maxSeconds, maxFollowSeconds}); const planned = planRange({...emptyProject(restored), serial: next.serial}, - {start, frames: count, parts, target: 'both', text: clip.text, maxSeconds: maxFollowSeconds, maxFollowSeconds}); - // Pulled out of this part's end: the new parts continue it. + {start, frames: count, parts, target: 'both', maxSeconds: maxFollowSeconds, maxFollowSeconds}); + // Pulled out of this part's end: the new parts continue it. They have no description of their own until one + // is written, and are generated with the one of the part before them. trackBy(next, 'v1').clips.push(...planned.tracks[0].clips.map(added => added.start === start ? {...added, follows: clip.id} : added)); sound.clips.push(...planned.tracks[1].clips); next.serial = planned.serial; @@ -1033,9 +1046,11 @@ function update(record, ids, change) { for (const clip of all(next)) if (selected.has(clip.id)) change(clip); return finish(next); } +// text is one description for every part, or one per part keyed by the part's picture. export function startAttempt(record, ids, text, {by = 'user'} = {}) { - check(validText(text) && TEXT_BY.includes(by), 'text'); + const textOf = clip => typeof text === 'string' ? text : text?.[clip.id] ?? text?.[clip.link]; const next = copy(record), selected = linkedIds(next, ids), parts = new Map(); + check(TEXT_BY.includes(by) && all(next).every(clip => !selected.has(clip.id) || validText(textOf(clip))), 'text'); busy(all(next).filter(clip => selected.has(clip.id))); // Parts that continue one another stay one video; ranges drawn apart stay apart, however many are started together. let part = null, last = null; @@ -1044,7 +1059,7 @@ export function startAttempt(record, ids, text, {by = 'user'} = {}) { parts.set(clip.id, part); if (clip.link) parts.set(clip.link, part); last = clip; } const other = () => part ??= freshId(next); - for (const clip of all(next)) if (selected.has(clip.id)) { written(clip, text, by); clip.part = parts.get(clip.id) ?? other(); clip.pending = 'draft'; clip.reason = clip.job = null; } + for (const clip of all(next)) if (selected.has(clip.id)) { written(clip, textOf(clip), by); clip.part = parts.get(clip.id) ?? other(); clip.pending = 'draft'; clip.reason = clip.job = null; } return finish(next); } export function assignJob(record, ids, job, {prepared = record} = {}) { diff --git a/web/editor_tracks.js b/web/editor_tracks.js index 937073ce..6f80e2e3 100644 --- a/web/editor_tracks.js +++ b/web/editor_tracks.js @@ -625,8 +625,24 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = const room = next.length ? Math.min(...next) : bounds.height; popover.style.top = `${Math.max(8, below + height <= room || above < 8 ? Math.min(below, bounds.height - height - 8) : above)}px`; } + // A click inside a range drawn over empty space that makes several parts plans those parts and chooses the one + // clicked, so each can be described on its own. What was typed for the range is the first part's description. + function choosePart(event) { + const range = state.selection; + if (event.kind !== 'rangeMove' || range?.kind !== 'range' || rangeParts(range).length < 2 + || state.record.tracks.some(track => track.clips.some(clip => clip.start < range.start + range.frames && clip.start + clip.frames > range.start))) return; + const frame = frameAt(event), text = state.rangeText?.trim() || ''; + let next, clicked; + try { + next = model.planRange(state.record, {start: range.start, frames: range.frames, ...(range.parts ? {parts: range.parts} : {}), target: 'both', ...actions.generationLimits?.()}); + const drawn = next.tracks[0].clips.filter(clip => !model.find(state.record, clip.id)).sort((a, b) => a.start - b.start); + clicked = drawn.find(clip => frame >= clip.start && frame < clip.start + clip.frames) ?? (frame < range.start ? drawn[0] : drawn.at(-1)); + if (text) next = model.describe(next, [drawn[0].id], text); + } catch (error) { if (error instanceof model.EditError) return; throw error; } + attempt(t('Edit description', '修改说明'), () => next, {selectionAfter: {kind: 'clip', ids: model.pair(next, clicked.id).map(clip => clip.id)}}); + } function clickSelection(event) { - if (event.range) return; + if (event.range) { choosePart(event); return; } const id = event.clipId || event.target.closest?.('.fv-ed-clip')?.dataset.clip; if (id) { selectGestures.choose(id, event); @@ -928,7 +944,7 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = if (cancel) { state.select(finished.previousSelection); if (finished.type === 'seek') state.seek(finished.previousPlayhead); return; } if (finished.type === 'seek') return; if (!finished.moved || finished.type === 'click') { - clickSelection({target: finished.target, clientX: event.clientX, range: finished.range, clipId: finished.id}); return; + clickSelection({target: finished.target, clientX: event.clientX, range: finished.range, kind: finished.type, clipId: finished.id}); return; } if (finished.type === 'rangeMove' && finished.rangeBlocked) return; if (['range', 'rangeMove', 'rangeEdge', 'rangeBoundary'].includes(finished.type)) { state.select(finished.selection, {history: true}); return; } @@ -1186,7 +1202,7 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = const unsubscribe = state.subscribe(event => { if (event.type === 'record') { // Status updates elsewhere must not cancel an unchanged moving pair. - if (gesture && !(gesture.type === 'move' && gesture.members.every(clip => + if (gesture && !(gesture.type === 'move' && (gesture.members ?? []).every(clip => JSON.stringify(model.find(state.record, clip.id)) === JSON.stringify(clip)))) stopPointer(null, true); if (gesture) cachedPoints = allPoints(gesture.ids); renderRecord(); diff --git a/web/generation_progress.js b/web/generation_progress.js index 1880718e..d72fde4d 100644 --- a/web/generation_progress.js +++ b/web/generation_progress.js @@ -758,8 +758,8 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f : splitFailed && !progressText ? 'split' : ''; const explanation = {retry: recovery, pause: progressText, - memory: t('This video will take longer than usual, but it will finish.', - '这次生成会比平时慢,但能完成。'), + memory: t('This video\'s size and length do not fit in GPU memory, so some data is kept in RAM. It will take longer than usual, but it will finish.', + '可用显存放不下这次的尺寸和时长,部分数据改放在内存中,生成会比平时慢,但能完成。'), // Mac, level 3 with low-memory mode (never both). The measured numbers go to the // report only. Closing applications during the first pass can still give the // second pass room, so that advice shows only until then. diff --git a/web/studio.js b/web/studio.js index 071a152d..72253639 100644 --- a/web/studio.js +++ b/web/studio.js @@ -774,7 +774,7 @@ export function openStudio(node, {extendFrom = null} = {}) { // The rewrite has its own report; the video's report entry is not touched. onReport: row => { node.freevideoPromptReportId = row.rewrite_report_id || row.report_id; }, onChange: () => { rememberPromptDraft(api, node); requestAssets?.refresh(); }, - setText: text => { set(node, 'text', text || (untouched() ? starter : '')); placeholder(); }, context: () => { + setText: text => { set(node, 'text', text || (untouched() ? starter : '')); placeholder(); castOverlay.refresh(); announceCast(); }, context: () => { const media = enhancementMedia(referenceItems(node), linked(node, 'conditioning')); return {seconds: Number(value(node, 'seconds')), media}; }});