From 577c2b9ddf58cc7cee52aa7985af5532214d0544 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 6 Nov 2026 22:07:18 +0000 Subject: [PATCH 1/9] Tutorial book and tour: Use example button, one primary per chapter, no parenthetical... Tutorial book and tour: Use example button, one primary per chapter, no parenthetical supplements, howto opening counter --- web/feature_tour.js | 4 ++-- web/howto.js | 4 +++- web/tutorial_book.css | 3 ++- web/tutorial_book.js | 4 ++-- web/tutorial_book_content.js | 20 ++++++++++---------- 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/web/feature_tour.js b/web/feature_tour.js index 4208cbee..5627df39 100644 --- a/web/feature_tour.js +++ b/web/feature_tour.js @@ -3,8 +3,8 @@ import {readGuideRecord, saveGuideRecord, guideRecord} from './first_run_guide.j import {isolateDialogKeys, listenDialogKey, routeDialogKey} from './dialog_keys.js'; export const TOUR_TEXT = { - en: {examples: 'Feature examples', use: 'Use this example', next: 'Next', goto: 'Take me there', book: 'Read the tutorial', done: 'Done', close: 'Close'}, - zh: {examples: '功能示例', use: '用这个示例', next: '下一张', goto: '带我去', book: '看教程', done: '完成', close: '关闭'}, + en: {examples: 'Feature examples', use: 'Use example', next: 'Next', goto: 'Take me there', book: 'Read the tutorial', done: 'Done', close: 'Close'}, + zh: {examples: '功能示例', use: '填入示例', next: '下一张', goto: '带我去', book: '看教程', done: '完成', close: '关闭'}, }; // The chapter of the tutorial book that teaches the same thing as the card. const BOOK_CHAPTER = {'first-video': 'quick-start', 'text-to-video': 'text-to-video', 'first-last-frame': 'first-last-frame', 'quality-tiers': 'quality-tiers', diff --git a/web/howto.js b/web/howto.js index 9e796c64..1aa81364 100644 --- a/web/howto.js +++ b/web/howto.js @@ -83,10 +83,12 @@ export async function availableHowToList(cn) { const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); // Plays one how-to. The editor ones open the Edit view first; the others open the creation panel. +let opening = 0; export async function startHowTo(id, {openWorkspace = async () => (await import('./view_navigation.js')).openCreativeWorkspace()} = {}) { const howTo = HOWTOS.find(item => item.id === id); if (!howTo || !await howToAvailable(id)) return false; // Tour cards stay closed while a how-to opens its view, as they do over the guide layer. + opening++; document.body.dataset.fvHowTo = 'opening'; try { if (!await openWorkspace()) return false; @@ -97,5 +99,5 @@ export async function startHowTo(id, {openWorkspace = async () => (await import( await document.fonts.ready; await wait(250); return await showHowTo(howTo.steps); - } finally { delete document.body.dataset.fvHowTo; } + } finally { if (!--opening) delete document.body.dataset.fvHowTo; } } diff --git a/web/tutorial_book.css b/web/tutorial_book.css index 8cd7996f..da365880 100644 --- a/web/tutorial_book.css +++ b/web/tutorial_book.css @@ -45,7 +45,8 @@ .fv-book-limits li { margin-bottom:var(--book-sm); } .fv-book-start { margin-top:0; padding:var(--book-sm) var(--book-md); min-height:var(--book-control); border:0; border-radius:var(--book-control-radius); background:var(--book-button); color:var(--book-button-ink)!important; font-weight:600; } .fv-book-start + .fv-book-start, .fv-book-try + .fv-book-start { margin-inline-start:var(--book-sm); } -.fv-book-try { background:var(--book-button); } +.fv-book-try { background:transparent; color:var(--book-ink)!important; box-shadow:inset 0 0 0 var(--book-border) var(--book-line); } +.fv-book-try:hover { background:var(--book-hover); } .fv-book-start:disabled { opacity:.5; cursor:default; } .fv-book-picker { display:none; } @media(max-width:680px) { diff --git a/web/tutorial_book.js b/web/tutorial_book.js index 11b4620a..b36829ae 100644 --- a/web/tutorial_book.js +++ b/web/tutorial_book.js @@ -1,8 +1,8 @@ import {TUTORIAL_CHAPTERS} from './tutorial_book_content.js'; const COPY = { - zh: {book: '教程', contents: '目录', close: '关闭', limits: '限制', start: '开始演示', tryIt: '试一试', goto: '带我去'}, - en: {book: 'Tutorial', contents: 'Contents', close: 'Close', limits: 'Limits', start: 'Start walkthrough', tryIt: 'Try it', goto: 'Take me there'}, + zh: {book: '教程', contents: '目录', close: '关闭', limits: '限制', start: '开始演示', tryIt: '填入示例', goto: '带我去'}, + en: {book: 'Tutorial', contents: 'Contents', close: 'Close', limits: 'Limits', start: 'Start walkthrough', tryIt: 'Use example', goto: 'Take me there'}, }; const el = (tag, cls, text) => { const node = document.createElement(tag); diff --git a/web/tutorial_book_content.js b/web/tutorial_book_content.js index 304a66c5..e7da9d30 100644 --- a/web/tutorial_book_content.js +++ b/web/tutorial_book_content.js @@ -129,8 +129,8 @@ export const TUTORIAL_CHAPTERS = [ alt: {zh: '角色窗口:名字、正面图片和其他角度图片已用红框标出。', en: 'The character window, with the name, front image and other angles outlined in red.'}, }, steps: { - zh: ['展开创作面板的“素材”,在“角色”一行点“+”(新建角色)。', '填写“名字”,设置“正面图片”:可“上⁠传⁠图⁠片”,也可“AI 生成”,后者会按描述一次生成 5 张供选。', '可再添加最多 5 张其他角度的图片,然后点“保⁠存”。', '之后在描述中输入 @ 选择该角色,并写明其动作。'], - en: ['Expand "Media" in the creation panel and, on the "Characters" row, choose + (New character).', 'Enter a "Name" and set the "Front image": choose "Upload images", or "Generate with AI", which makes 5 pictures from a description to pick from.', 'Add up to 5 images from other angles, then choose "Save".', 'In later prompts, type @ to pick the character and describe what the character does.'], + zh: ['展开创作面板的“素材”,在“角色”一行点“+”新建角色。', '填写“名字”,设置“正面图片”:可“上⁠传⁠图⁠片”,也可“AI 生成”,后者会按描述一次生成 5 张供选。', '可再添加最多 5 张其他角度的图片,然后点“保⁠存”。', '之后在描述中输入 @ 选择该角色,并写明其动作。'], + en: ['Expand "Media" in the creation panel and, on the "Characters" row, choose + to create a character.', 'Enter a "Name" and set the "Front image": choose "Upload images", or "Generate with AI", which makes 5 pictures from a description to pick from.', 'Add up to 5 images from other angles, then choose "Save".', 'In later prompts, type @ to pick the character and describe what the character does.'], }, example: { assets: [{file: 'fox-sheet.webp', role: 'reference'}], @@ -156,8 +156,8 @@ export const TUTORIAL_CHAPTERS = [ en: ['Expand "Media" in the creation panel and turn on "Replace person".', 'Under "Source video" choose "Add video", and under "Person image" choose "Add image".', 'To keep the sound of the source video, check "Keep source video sound".', 'The prompt can stay empty or describe more of the picture. Then choose "Generate video".'], }, limits: { - zh: ['替换人物不能与首帧、尾帧同时使用。', '首次使用时会自动下载替换人物模型(约 80 MB),面板中显示进度,下载完成后才能生成。', '源视频至少 1 秒;源视频没有声音时,不能保留声音。'], - en: ['Replace person cannot be used with first or last frames.', 'The first time, the Replace person model (about 80 MB) downloads on its own. The panel shows the progress, and generation can start when it finishes.', 'The source video must be at least 1 second long. A source video without sound has no sound to keep.'], + zh: ['替换人物不能与首帧、尾帧同时使用。', '首次使用时会自动下载约 80 MB 的替换人物模型,面板中显示进度,下载完成后才能生成。', '源视频至少 1 秒;源视频没有声音时,不能保留声音。'], + en: ['Replace person cannot be used with first or last frames.', 'The first time, the Replace person model of about 80 MB downloads on its own. The panel shows the progress, and generation can start when it finishes.', 'The source video must be at least 1 second long. A source video without sound has no sound to keep.'], }, }, { @@ -239,8 +239,8 @@ export const TUTORIAL_CHAPTERS = [ alt: {zh: '剪辑视图:接着生成的草稿段和描述框已用红框标出。', en: 'The Edit view, with the draft part that continues the video and the prompt box outlined in red.'}, }, steps: { - zh: ['在生成好的视频下方点“接着生成”,打开剪辑视图。', '选中后面自动加上的那一段(默认 10 秒;在 Mac 和一次生成不了 10 秒的设备上为 5 秒),在底部描述框写明接下来发生什么。', '需要更长时,拖长这一段,每段至少 5 秒,然后点“生⁠成”。', '新的一段会接在原视频之后,并自动拼成一条。'], - en: ['Choose "Extend" below a finished video. The Edit view opens.', 'Select the part added after it (10 seconds by default, or 5 seconds on a Mac and on devices that cannot make 10 seconds at once) and write what happens next in the box at the bottom.', 'To make it longer, drag the part out; each part is at least 5 seconds. Then choose "Generate".', 'The new part follows the original and is joined into one video.'], + zh: ['在生成好的视频下方点“接着生成”,打开剪辑视图。', '选中后面自动加上的那一段,在底部描述框写明接下来发生什么。这一段默认 10 秒,在 Mac 和一次生成不了 10 秒的设备上为 5 秒。', '需要更长时,拖长这一段,每段至少 5 秒,然后点“生⁠成”。', '新的一段会接在原视频之后,并自动拼成一条。'], + en: ['Choose "Extend" below a finished video. The Edit view opens.', 'Select the part added after it and write what happens next in the box at the bottom. The part is 10 seconds by default, or 5 seconds on a Mac and on devices that cannot make 10 seconds at once.', 'To make it longer, drag the part out; each part is at least 5 seconds. Then choose "Generate".', 'The new part follows the original and is joined into one video.'], }, example: { assets: [], @@ -286,8 +286,8 @@ export const TUTORIAL_CHAPTERS = [ alt: {zh: '剪辑视图:选中的一段和描述框已用红框标出。', en: 'The Edit view, with the selected part and the prompt box outlined in red.'}, }, steps: { - zh: ['点顶部的“剪辑视图”。', '在时间线上选中要处理的一段。', '在下方描述框中填写这一段的内容,点“生⁠成”接续下一段。', '在右侧“画面”中可调整缩放、位置和旋转,或点“裁⁠剪”(窗口窄于 1280 时,先点“调⁠整”打开该面板)。', '完成后点右上角的“导⁠出”保存。'], - en: ['Choose "Edit" at the top.', 'Select a part on the timeline.', 'Write what happens in this part in the box below and choose "Generate" to continue.', 'Under "Video" on the right, adjust scale, position and rotation, or choose "Crop" (on a window narrower than 1280 pixels, choose "Adjust" first to open the panel).', 'When done, choose "Export" at the top right.'], + zh: ['点顶部的“剪辑视图”。', '在时间线上选中要处理的一段。', '在下方描述框中填写这一段的内容,点“生⁠成”接续下一段。', '在右侧“画面”中可调整缩放、位置和旋转,或点“裁⁠剪”;窗口窄于 1280 时,先点“调⁠整”打开该面板。', '完成后点右上角的“导⁠出”保存。'], + en: ['Choose "Edit" at the top.', 'Select a part on the timeline.', 'Write what happens in this part in the box below and choose "Generate" to continue.', 'Under "Video" on the right, adjust scale, position and rotation, or choose "Crop". On a window narrower than 1280 pixels, choose "Adjust" first to open the panel.', 'When done, choose "Export" at the top right.'], }, example: { assets: [], @@ -408,8 +408,8 @@ export const TUTORIAL_CHAPTERS = [ alt: {zh: 'AI 全自动:连接 Agent 的按钮已用红框标出。', en: 'Autopilot, with the button that connects an agent outlined in red.'}, }, steps: { - zh: ['点顶部的“AI 全自动”。', '点“连接 Agent”卡片中的按钮复制连接信息,粘贴给所使用的 Agent。', '状态显示“已连接”(或所连 Agent 的名字)后,在“对 Agent 说…”中描述想做的视频,例如“做一条 1 分钟的竖屏短片:咖啡店的一天”。', '点“发送”,Agent 会生成并剪辑;可随时点“停止”,完成后点“打开”或“剪辑”。', '在对话列表中点对话旁的“更多”,选“重命名”可修改对话名称。'], - en: ['Choose "Autopilot" at the top.', 'On the "Connect an agent" card, choose the button to copy the connection details and paste them to the agent you use.', 'When it shows "Connected" (or the agent\'s name), describe the video under "Message the agent…", for example "Make a 1-minute vertical short film: a day in a coffee shop".', 'Choose "Send". The agent generates and edits it. Choose "Stop" at any time, and "Open" or "Edit" when it finishes.', 'In the chat list, choose "More" next to a chat, then "Rename" to change its name.'], + zh: ['点顶部的“AI 全自动”。', '点“连接 Agent”卡片中的按钮复制连接信息,粘贴给所使用的 Agent。', '状态显示“已连接”或所连 Agent 的名字后,在“对 Agent 说…”中描述想做的视频,例如“做一条 1 分钟的竖屏短片:咖啡店的一天”。', '点“发送”,Agent 会生成并剪辑;可随时点“停止”,完成后点“打开”或“剪辑”。', '在对话列表中点对话旁的“更多”,选“重命名”可修改对话名称。'], + en: ['Choose "Autopilot" at the top.', 'On the "Connect an agent" card, choose the button to copy the connection details and paste them to the agent you use.', 'When it shows "Connected" or the agent\'s name, describe the video under "Message the agent…", for example "Make a 1-minute vertical short film: a day in a coffee shop".', 'Choose "Send". The agent generates and edits it. Choose "Stop" at any time, and "Open" or "Edit" when it finishes.', 'In the chat list, choose "More" next to a chat, then "Rename" to change its name.'], }, limits: { zh: ['需要先连接 Agent,未连接时只显示连接说明。', '镜头越多,用时越长。', 'Agent 开拍整部片前、重拍耗时较长时是否先询问,可在“设置”的“AI Agent”中选择。'], From db5f5097f7c3de374e118072de8ab2653e9f08df Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 6 Nov 2026 22:29:05 +0000 Subject: [PATCH 2/9] Tutorial batch 3: tour cards and chapters for per-part descriptions and reuse settings --- web/editor_genbar.js | 2 +- web/feature_tour.css | 6 ++--- web/feature_tour_content.js | 5 ++++ web/result_actions.js | 1 + web/tutorial/library-reuse-1.webp | Bin 0 -> 4278 bytes web/tutorial/library-reuse-2.webp | Bin 0 -> 4558 bytes web/tutorial/segment-prompt-1.webp | Bin 0 -> 16572 bytes web/tutorial/segment-prompt-2.webp | Bin 0 -> 16834 bytes web/tutorial_book_content.js | 38 +++++++++++++++++++++++++++++ 9 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 web/tutorial/library-reuse-1.webp create mode 100644 web/tutorial/library-reuse-2.webp create mode 100644 web/tutorial/segment-prompt-1.webp create mode 100644 web/tutorial/segment-prompt-2.webp diff --git a/web/editor_genbar.js b/web/editor_genbar.js index e42a740f..33bf5ee0 100644 --- a/web/editor_genbar.js +++ b/web/editor_genbar.js @@ -54,7 +54,7 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { const input = el('textarea', 'fv-ed-description'); input.rows = 1; input.value = text; const description = el('div', 'fv-ed-description-box'); description.append(input); const generate = el('button', 'fv-ed-generate'), cancel = el('button', null, t('Cancel', '取消')), resume = el('button', null, t('Continue', '继续')); - input.dataset.fvGuide = 'editor-describe'; generate.dataset.fvGuide = 'editor-generate'; + input.dataset.fvGuide = 'editor-describe'; input.dataset.tourId = 'segment-prompt'; generate.dataset.fvGuide = 'editor-generate'; for (const button of [generate, cancel, resume]) button.type = 'button'; host.append(hint, note, description, generate, cancel, resume); // Beside Generate, as beside the creative panel's Generate video: the panel's own two-pass choices, the same setting. diff --git a/web/feature_tour.css b/web/feature_tour.css index 83a51902..ed9f2eae 100644 --- a/web/feature_tour.css +++ b/web/feature_tour.css @@ -23,9 +23,9 @@ .fv-tour-four{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px} .fv-tour-four figure{margin:0;min-width:0}.fv-tour-four figcaption{text-align:center;margin-top:6px} .fv-tour-footer{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:24px} -.fv-tour-dots{display:flex;gap:4px;flex-wrap:wrap;align-items:center} -.fv-feature-tour .fv-tour-dot{padding:0;width:24px;min-height:28px;border:0;background:transparent;position:relative} -.fv-tour-dot::after{content:'';position:absolute;inset:10px 8px;border-radius:50%;background:var(--fv-muted)} +.fv-tour-dots{display:flex;gap:2px;flex-wrap:wrap;align-items:center} +.fv-feature-tour .fv-tour-dot{padding:0;width:20px;min-height:28px;border:0;background:transparent;position:relative} +.fv-tour-dot::after{content:'';position:absolute;inset:10px 6px;border-radius:50%;background:var(--fv-muted)} .fv-tour-dot[aria-current=true]::after{background:var(--fv-accent)} .fv-tour-actions{display:flex;gap:10px;flex:none} .fv-feature-tour .fv-primary{background:var(--fv-accent);color:var(--fv-bg);border-color:var(--fv-accent);font-weight:600} diff --git a/web/feature_tour_content.js b/web/feature_tour_content.js index 539611b3..bb28e581 100644 --- a/web/feature_tour_content.js +++ b/web/feature_tour_content.js @@ -33,6 +33,11 @@ export const FEATURE_TOUR = [ card('sound-to-picture', copy('Generate a picture from sound', '按声音生成画面'), copy('Drag an empty range on the video track above a sound, and the picture follows it.', '在有声音的区间上方的视频轨拖出一段空白,画面随声音生成。'), [{src: 'cc-speech-wave.webp'}], clip('cc-speech'), {status: 'pending-assets', view: 'editor'}, {trigger: 'editor', added: '0.5.0', goto: ['video-empty-range'], sequence: [{...clip('cc-speech'), label: copy('Play with sound', '播放声音')}]}), + card('segment-prompt', copy('Describe each part', '逐段描述'), copy('Each part of a longer shot has its own description.', '较长镜头的每一段,各有各的描述。'), + [{text: copy('Part 1: A fox walks through a sunlit forest.', '第 1 段:狐狸走过洒满阳光的森林。')}, {text: copy('Part 2: A fox walks through snow.', '第 2 段:狐狸走过雪地。')}], clip('c5-forest-s1'), {status: 'pending-assets', view: 'editor'}, + {trigger: 'editor', added: '0.5.0', goto: ['segment-prompt'], sequence: [['forest', 'Part 1', '第 1 段'], ['snow', 'Part 2', '第 2 段']].map(([name, en, zh]) => ({...clip(`c5-${name}-s1`), label: copy(en, zh)}))}), + card('library-reuse', copy('Reuse settings', '复用设置'), copy('Fill the prompt, size, duration and seed of an earlier video back in.', '把以前一条视频的描述、尺寸、时长和种子填回来。'), + [{text: copy(...CASTLE_PROMPT)}], clip('c1-s2'), {status: 'pending-assets'}, {added: '0.5.0', goto: ['library-reuse', 'works-entry']}), card('extend', copy('Extend', '接着生成'), copy('Continue from where the last clip ends.', '从上一段的结尾接着生成下一段。'), [{src: 'c6-s1.webp'}], clip('c6-s1'), {status: 'pending-assets', view: 'editor'}, {trigger: 'editor', previewRange: [2, 8]}), card('two-pass', copy('Two-pass', '二次采样'), copy('The first pass sets the composition; the second adds detail.', '第一遍定构图,第二遍补细节。'), diff --git a/web/result_actions.js b/web/result_actions.js index c5e0798e..3f1fe3e2 100644 --- a/web/result_actions.js +++ b/web/result_actions.js @@ -162,6 +162,7 @@ async function reuseAction(record, t, reuse) { const idle = () => { clearTimeout(timer); stop(); const button = action('button', '', 'back', 'fv-action-secondary fv-action-reuse'); + button.dataset.tourId = 'library-reuse'; button.lastElementChild.replaceWith(make(label)); button.hidden = !offered(); button.onclick = () => { diff --git a/web/tutorial/library-reuse-1.webp b/web/tutorial/library-reuse-1.webp new file mode 100644 index 0000000000000000000000000000000000000000..7e0fc681702383a6dde22f60250bb32f82258b12 GIT binary patch literal 4278 zcmaKvWn9!v*T?@0!U_sViF8Yd2uLH{Al)Gy(n#knAl)D>B@Gf%f?U9^B1oq+2-4lL zz_NR<*Y)DMU);}g=5x+`=gjXjb7tn%Xe%oy)IIi@fghu7iaH3d;PILr@L?Ks;7hL?_!(=@C390ML_1y`7vXRxfKC`@Hqfr zC;ywX$_9Xz2mp9A_iv7+5CDk60pRn$0FaIX z0KUoJbC~dd)W(3BqQTVVhH3VIGhhoa0Lp+XU=46%h!5ZacmYAo3p2n8F_-uf$OCa& zVJQF-%1lY}Oht^TqUYi4cfuQ5UR6kW&}}tbpLUu+MD6N8ErEmJU5&kw4#}(hLZTg5 zujt?{AZZZH3$^K_lyrpI2j>Xb2TjkkT$vLg*{;}ThSe8EHZ)E&S|p=U^Ea8hivExb zSRS$#frYfb(m4Ljbhb7@%C4V zl>M#`skUDJoL>NvkS5YZ<%G_8Qwb(z@{25w2kG)t81I@4xtNx+C49IR)7=uS@Al2D z9gBh=zl_v1WpMw>IzeTzet8DjA-o8{RSKjv$9;T_XJ>jAwb0q!?%qo1lpW$hVTBPkxY5z6Ms7Aza_~F;+NnL)m{L58S8nl<-uV_& zQq#{5cXqejeQaHP1q8Lw&)cGSr>@cA5%(c@dOpse7Oog-RIF$gCp+3d?WwZn`@)@=w#yL&3irWecIUk zwwaNYa^*v-+!DLdWLTXjm_U|wnTMmR6<_c-hBYhFpVerIYO{W|*!Ebf zd2YtWEcRujz}f2%PR68r3tTYNy_!2joY7(h#>8+W37W`8ep7 zY!9=5q2b-AOOgBW!aaJ!kI><p zm;u|YHxseEp}9Bw#f!2`aiL5)jvdEucQ|QDkI_4{c!8sK=x|9||7l$=L1*U4_@VY) z9O!ubP3s~%sAn~5`1o1A`^`riw*d%%tXo&6xC>x zgt!i6F4kxqYsja16G^1bzKzlo_T z5<4h-4Ih91`?GB>V#PXA3V6bOJ`2U25%%q@Nm*0lChQYFOqbg`!qMbX5HjWo*|*WS ziFCi@kW7=qk*SUtQ%)43&JXdqVlz+`VItMgoHn9Sbo^ap(jzV@r}YlPt4Y#!*J|(B z(j|>^U;+0{^-()|IY`<^U1aBM&~5UDXuG@pf;B2E+nt=o))!Qs^4P>__g9V!GNOa6 z9#qpO4?K)gYWTXVvi>dGz$tFvCUAe%``x(j+^bnxR4G`yYO!Aj%mi6EaMd4Sy%(<7 zxPHj5+V*tBR>Ygbr{lO-U%I_-WU}BH?RFqDs#(}s$-||xf+UwLDM1G=9*tzG$bMY% z+x+)H)0*>N_77?~pm2fR571SFWOmG>a9XHyG)&ykmRfs!+0plc)*t@_kL`pYQu7PE zEu4qpqZg-PC}S4Gu}U z``;yxLwZ63jHn1E((oC4XCSa3fy=EODh;o_4e!g!;kcF|!s;>3gXh$&kX2-~$s1hr zYK1N@kG!|?=IY(zU;S6i+f!?BdOEr)lwMm1+|rxq^W!_cIN6pD6*I%n9vH7C><_L2KIa=P)%;l!a`l1-~ul4SGF6Z1N9zMBY| z;n|7g?@xtKYf5ky9*v+XI=O*2_-pqDh3DU*Jf9w{U%?vnrFf#y#XDsSGWt%tt{aUn zeS=YN>kmjDldry#s86MEmYAf)X4)|P?rbgmblPOgyIMVTT%j(oV8o=;{|@Ye(3ivI!D8#ewR=l>bobcgoe`=QH1JxHqH%+7R_jg z#U|a-LFT^amC?39w0Bjw`r+t@;GZaFFLVdK7CHAgW3$|1dz`KEtB~);&E+xS@e+@_ z4O_q3QqrHjKYnVDRJC-mmdg92+Un|Oj&g|YCS};bGq+937n~E|6-%}{%=W6wS5Z!M zF_`t`J3!eeTxy-uHD~RHeOurQOk=AdY!mGEZv}x!X9f+`E<&`$`==<@V>zEw-@f-@ z3hKum;_=k>PND|cImM9KNHEBuLKPRR4rek62WiT;rw;?!yr$*dy-a&&^;e zErc|2iJu#l-&&Remfz;~Mar02MHER#D0Ya=tIgk$t3|Bdtr?UScl`)@ERSdQ%XxW0 z=mi3D{o|u!c`x{g^QU=H|0nr`TNy>RZ)I~Umb({@6|N4S*TaR6U6Ry#m-B|W$VJT% z_UOC#)5!wz)~%AI3670jD@N-k`^D*nBMeWckH}CvNtf^D2<8xAHuGVE>A+ zYbzGvz8&yti&p}FT0h1qH#`jkH&x2VH0Vu8)M-5=-4p0&U;q$^uUBO(T`vfLp$WQG zVy+K6Jl@JM{{q8SfeaXqagz$UXg|~+d!2}W$X6o*x|(%s0l&*wfo3iC8G62El<@+| zi@!rH5p!T$DZ8jGI8+~vd!C`9agd{iUq&zY0$i!j$dbHzr0|8|MKhnx11mG`^cMb6 zK&4(c1X!5l&(+I}Ju^Ggx(L<0*QkE090o-0c9Fl`v2%U+g7tN(Ek0iFy> zcJ2l;vV(0!I;%-Fn0_jhFU0iz{M1_yb8D$w9%8jy6-r9Cg$E7rAACIiTbD%(rcdTM zLLV>u_2YD6eDbBJDs0%CR)*!!IKc8*UkrTb0W>=*B12k%l-a;RQbYd!5Z{N3Y((?L zBfYrE*drSK`QL(?^L0kFq2q!$WGVgeU318sAO)jo{!(6r`x9iFcTqx5ZWPu1SgLRu zhx*my1!@Bk*+8t4^G)OLucnzJLFKf;_c@KrgIO*_r<{5Y#gLp>Gj8J>lx^I0j=_QH zp%XgKZiMN7xY`dCwk^o^CbC~0tj)htZBZT96|RlW_A2Va7V3eE21hnG&wjxzCkZgW z_nq}yW!2N&&eOJq-0<4pXI3d;Mn2Zet<%Uaa( zp!*pkA)H)1oy@~Xsc+Sn8{P;!+ET;Ymu`vqro7LXQWUX=07%Hj6Iu8S{>Py5|;5H+>!~ZOLWqQNn|iIE@-!{wQUl z*Z8^Biq#f9@F{AL^5pu}SPI(b7orUz7aW31mN%=)MX}Eve$VG%wj9lGIB@)gIPkb2 zsP=4GB@$IETBDmvLrt1dQe|jO0X@o46Su2MFK{$e?MYF)yrw3a6-z3(e#G|a`l^!| zl6p;b+Bzo}z(DK|wcrbT7~Va$_hAz5L|fTPjqJ0h&M&zH`LNt{-+d_H2p%4WTx}D1t)Z1gCf65gg6mko}z00N&G7tRqd4 zdA ebQo2W&cD@kpl{HFV?ORwbgRP4Ae}7CNB2MDX3OFL literal 0 HcmV?d00001 diff --git a/web/tutorial/library-reuse-2.webp b/web/tutorial/library-reuse-2.webp new file mode 100644 index 0000000000000000000000000000000000000000..e63f856d36c3ea83cd88eec01d96634df0c7ded3 GIT binary patch literal 4558 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 BMb`iT literal 0 HcmV?d00001 diff --git a/web/tutorial/segment-prompt-1.webp b/web/tutorial/segment-prompt-1.webp new file mode 100644 index 0000000000000000000000000000000000000000..25d32c5e90af1dfde0872b68988798917eb401a3 GIT binary patch literal 16572 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 literal 0 HcmV?d00001 diff --git a/web/tutorial_book_content.js b/web/tutorial_book_content.js index e7da9d30..dee4d3d3 100644 --- a/web/tutorial_book_content.js +++ b/web/tutorial_book_content.js @@ -322,6 +322,25 @@ export const TUTORIAL_CHAPTERS = [ en: ['A part that continues the previous one uses up to 3 reference images and no first or last frames.', 'Edit takes images only. Add reference videos and audio under "Media" in the creation panel.', 'The same part limits apply. An NVIDIA graphics card or a Mac is required.'], }, }, + { + id: 'segment-prompt', + goto: ['segment-prompt'], + title: {zh: '逐段描述', en: 'Describe each part'}, + summary: {zh: '为剪辑视图中的每一段单独写描述。', en: 'Write a separate description for each part in the Edit view.'}, + media: { + type: 'image', + src: {zh: 'segment-prompt-1.webp', en: 'segment-prompt-2.webp'}, + alt: {zh: '剪辑视图:选中的一段和描述框已用红框标出。', en: 'The Edit view, with the selected part and the prompt box outlined in red.'}, + }, + steps: { + zh: ['在“画面”轨上拖出一段较长的范围,它会分成带序号的多段。', '点选其中一段,在描述框中写下这一段的内容。', '再点选另一段,写下它的内容;每段的描述保存在这一段上。', '点“生⁠成”,各段按顺序生成并连成一个视频。'], + en: ['On the picture track, drag a long range. It is divided into numbered parts.', 'Select one part and write what happens in it in the prompt box.', 'Select another part and write its own description. Each part keeps its own.', 'Choose "Generate". The parts are made in order and joined into one video.'], + }, + limits: { + zh: ['一个连续镜头的段数有上限,见“连续的长视频”。'], + en: ['One continuous shot has a limit on the number of parts; see "Make a longer video".'], + }, + }, { id: 'separate-tracks', goto: ['audio-track-button'], @@ -416,6 +435,25 @@ export const TUTORIAL_CHAPTERS = [ en: ['An agent must be connected first. Until then only the connection card is shown.', 'More shots take longer.', 'Whether an agent asks before starting a film or a long reshoot is set under "AI Agent" in Settings.'], }, }, + { + id: 'library-reuse', + goto: ['library-reuse', 'works-entry'], + title: {zh: '复用设置', en: 'Reuse settings'}, + summary: {zh: '把以前一条视频的描述、尺寸、时长和种子填回创作面板。', en: 'Fill the prompt, size, duration and seed of an earlier video back into the creation panel.'}, + media: { + type: 'image', + src: {zh: 'library-reuse-1.webp', en: 'library-reuse-2.webp'}, + alt: {zh: '预览区下方的按钮:“复用设置”已用红框标出。', en: 'The buttons below the preview, with "Reuse settings" outlined in red.'}, + }, + steps: { + zh: ['点预览区右上角“作品”左边的图标,选一条以前的视频。', '点“复⁠用⁠设⁠置”,描述、尺寸、时长和种子会填回创作面板。', '改动需要变化的内容,再点“生⁠成⁠视⁠频”。', '填入有误时,10 秒内点“撤⁠销”,恢复原来的内容。'], + en: ['Choose the icon to the left of "Creations" above the preview and pick an earlier video.', 'Choose "Reuse settings". Its prompt, size, duration and seed are filled into the creation panel.', 'Change what should be different, then choose "Generate video".', 'If the fill was a mistake, choose "Undo" within 10 seconds to bring back what was there.'], + }, + limits: { + zh: ['没有保存描述的视频不提供“复用设置”。'], + en: ['A video that kept no prompt does not offer "Reuse settings".'], + }, + }, { id: 'library-export', goto: ['works-entry'], From 66377add3d14cf8988a49f93565f18172573d5c7 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 7 Nov 2026 01:16:10 +0000 Subject: [PATCH 3/9] Agent: when a shot has several takes with the same cuts, the built-in director takes the best... Agent: when a shot has several takes with the same cuts, the built-in director takes the best part of every cut from different takes and joins them, checking the seam sound; no joins inside speech or under a crossfade, one whole take stays the fallback --- freevideo_engine/agent/builtin_director.py | 64 ++++++- freevideo_engine/agent/cut_pick.py | 202 +++++++++++++++++++++ freevideo_engine/agent/production.py | 45 ++++- freevideo_engine/agent/productions.py | 39 +++- 4 files changed, 339 insertions(+), 11 deletions(-) create mode 100644 freevideo_engine/agent/cut_pick.py diff --git a/freevideo_engine/agent/builtin_director.py b/freevideo_engine/agent/builtin_director.py index 3879ba83..172e60b6 100644 --- a/freevideo_engine/agent/builtin_director.py +++ b/freevideo_engine/agent/builtin_director.py @@ -17,7 +17,7 @@ """ import base64 from copy import deepcopy -from dataclasses import dataclass +from dataclasses import dataclass, replace import hashlib import ipaddress import json @@ -428,6 +428,46 @@ def judge(shot_view, sheets, *, ask): return uncertain +CUT_AGREE = 0.3 # seconds by which the same cut may differ between takes + + +def stitch_shot(shot_view, segments, sheets, *, ask): + """Pieces taking the best part of every cut from different takes, or None to keep one whole take. + + Needs two or more takes that passed the check, every one with the same two or more cuts. The last piece + comes from the take that is chosen, so the next shot still continues from the chosen take's last frame.""" + from . import cut_pick + if len(segments) < 2 or any(len(s['cuts']) != len(segments[0]['cuts']) for s in segments) or not segments[0]['cuts']: + return None + if any(abs(a - b) > CUT_AGREE for s in segments for a, b in zip(s['cuts'], segments[0]['cuts'])): + return None # The same cut sits at different times: these are not the same parts. + images = {row['take']: row['image'] for row in sheets['takes']} + if any(s['take'] not in images for s in segments): + return None + count = len(segments[0]['cuts']) + 1 + numbers = [s['take'] for s in segments] + rating = _object(dict(take=dict(type='integer', enum=numbers), + scores=dict(type='array', minItems=count, maxItems=count, + items=dict(type='number', minimum=0, maximum=10))), ('take', 'scores')) + schema = _object(dict(ratings=dict(type='array', minItems=len(numbers), maxItems=len(numbers), items=rating)), + ('ratings',)) + parts = ''.join('\n版%d:%s' % (s['take'], '、'.join('第%d段 %.2f–%.2f 秒' % (i + 1, a, b) for i, (a, b) in enumerate( + zip([0.0] + s['cuts'], s['cuts'] + [s['seconds']])))) for s in segments) + prompt = ('同一镜头有几个版本,每版在画面硬切处分成相同的几段。按图上的时间标注,给每版的每一段打 0–10 分' + '(人物变形、多余的字、和描述不符都扣分),scores 按段的顺序给出。' + '\n镜头描述:' + _dump({k: shot_view[k] for k in ('screenplay', 'characters', 'style') if k in shot_view}) + + '\n图片顺序对应版号:' + _dump(numbers) + '\n各版的分段:' + parts) + reply = _ask(ask, prompt, [images[n] for n in numbers], schema) + if reply is None or sorted(r['take'] for r in reply['ratings']) != sorted(numbers): + return None + scores = {r['take']: r['scores'] for r in reply['ratings']} + candidates = [dict(s, scores=scores[s['take']]) for s in segments] + result = cut_pick.stitch(candidates) + if result['fallback'] or len(result['pieces']) < 2: + return None + return result + + def revise(view, sentence, *, ask): """Translate one request into one checked reshoot, or ask for clarification.""" unclear = Problem('unclear', '请说明要改哪一镜,以及画面或时长要怎样改?') @@ -480,6 +520,18 @@ def _ref(identity, action, snapshot): return identity + ':builtin:' + action + ':' + digest +def _with_stitch(tools, production_id, shot, sheets, decision, stitched, ask): + """Take the best part of every cut when the takes allow it; the choice becomes the take that ends the film part.""" + try: + pieces = stitch_shot(shot, tools.segments(production_id, shot['id']), sheets, ask=ask) + except Exception: # noqa: BLE001 - stitching is an extra; one whole take is always the fallback + return decision + if pieces is None: + return decision + stitched[shot['id']] = [dict(take=p['take'], start=p['start'], end=p['end']) for p in pieces['pieces']] + return replace(decision, take=pieces['pieces'][-1]['take'], reason=(decision.reason + ';各切镜取最好的一版拼接')[:300]) + + def run(tools, production_id, *, ask, stop, wait=25): """Yield {shot, action, reason}; stop never cancels generation jobs. @@ -489,7 +541,7 @@ def run(tools, production_id, *, ask, stop, wait=25): """ wait = max(0, min(25, wait)) since, errors, conflicts = None, 0, 0 - handed_off = set() + handed_off, stitched = set(), {} while not stop(): try: view = tools.get(production_id, since=since, wait=wait if since is not None else 0) @@ -506,7 +558,12 @@ def run(tools, production_id, *, ask, stop, wait=25): for s in view['shots']): continue snapshot = [(s['id'], s['chosen']) for s in view['shots']] - tools.finish(production_id, by='builtin', ref=_ref(production_id, 'finish', snapshot)) + chosen = dict(snapshot) + # A part list only stands while its last take is still the chosen one. + pieces = {k: v for k, v in stitched.items() if chosen.get(k) == v[-1]['take']} + tools.finish(production_id, by='builtin', ref=_ref(production_id, 'finish', snapshot + sorted( + (k, [(p['take'], p['start'], p['end']) for p in v]) for k, v in pieces.items())), + **({'pieces': pieces} if pieces else {})) yield dict(shot=None, action='finish', reason='每镜已选定,开始接成影片。') return if action not in ('choose', 'reshoot'): @@ -534,6 +591,7 @@ def run(tools, production_id, *, ask, stop, wait=25): elif decision.action in ('choose', 'reshoot'): ref = _ref(production_id, decision.action, shot) if decision.action == 'choose': + decision = _with_stitch(tools, production_id, shot, sheets, decision, stitched, ask) tools.choose(production_id, shot['id'], decision.take, by='builtin', reason=decision.reason, ref=ref) else: tools.reshoot(production_id, shot['id'], by='builtin', note=decision.note, diff --git a/freevideo_engine/agent/cut_pick.py b/freevideo_engine/agent/cut_pick.py new file mode 100644 index 00000000..bbcd36e9 --- /dev/null +++ b/freevideo_engine/agent/cut_pick.py @@ -0,0 +1,202 @@ +"""Cut detection, per-segment checks and stitching of same-shot takes. + +Pure CPU helpers: `detect_cuts` finds the picture cuts of a take, `segment_qa` applies the take checks to one +segment, `seam` measures how a join between two takes sounds and `stitch` picks the best take for every +segment. Thresholds are those of take_qa. +""" +from functools import lru_cache +from pathlib import Path + +import numpy as np + +from . import take_qa +from .take_qa import _db, _runs + +CUT_DIFF = 25.0 # 0-255 mean change between consecutive frames of a hard cut +CUT_SPIKE = 3.0 # a cut stands out this much from the frame changes around it +MATCH_DIFF = 12.0 # weakest change accepted as an expected cut +SEAM_LEVEL_DB = 20.0 # level step at which the sound penalty is full +SEAM_CLICK = 0.5 # sample jump at which the click penalty is full +SEAM_SPEECH = 0.4 # penalty added for a join inside speech +SEAM_QUIET = 0.25 # weight of level and click at a join in silence + + +@lru_cache(maxsize=4) +def _decoded(path, stamp): + return take_qa._pictures(path) + + +@lru_cache(maxsize=4) +def _heard(path, stamp): + return take_qa._samples(path) + + +def _stamp(path): + stat = Path(path).stat() + return stat.st_mtime_ns, stat.st_size + + +def _pictures(path): + """take_qa's frames, decoded once while the same file is checked part by part.""" + return _decoded(str(path), _stamp(path)) + + +def _samples(path): + return _heard(str(path), _stamp(path)) + + +def _changes(small): + return [0.0] + [float(np.abs(small[i] - small[i - 1]).mean()) for i in range(1, len(small))] + + +def _spike(diffs, index, lo, hi, floor): + """Whether frame `index` is a cut: a large change that stands out from its neighbours in lo..hi.""" + around = [diffs[i] for i in range(lo, hi) if i != index] + return diffs[index] >= floor and diffs[index] >= CUT_SPIKE * (float(np.median(around)) if around else 0.0) + + +def detect_cuts(path, expected=None, *, tolerance=0.6): + """Seconds of the hard picture cuts, ascending, excluding 0 and the end. With `expected`, each expected cut + takes the strongest frame change within `tolerance` s; one with no such change is None, not guessed.""" + _, small, times = _pictures(path) + diffs = _changes(small) + if not expected: + return [round(times[i], 3) for i in range(1, len(times)) + if (diffs[i] >= CUT_DIFF and diffs[i] >= diffs[i - 1] and diffs[i] > (diffs[i + 1] if i + 1 < len(diffs) else -1) + and _spike(diffs, i, max(1, i - 3), min(len(diffs), i + 4), CUT_DIFF))] + found = [] + for wanted in expected: + near = [i for i in range(1, len(times)) if abs(times[i] - wanted) <= tolerance] + best = max(near, key=lambda i: (diffs[i], -abs(times[i] - wanted)), default=None) + if best is not None and _spike(diffs, best, max(1, min(near) - 1), min(len(diffs), max(near) + 2), MATCH_DIFF): + found.append(round(times[best], 3)) + else: + found.append(None) + return found + + +def segment_qa(path, start, end): + """Black picture, frozen picture and a long silence in the middle of speech, inside [start, end).""" + problems, notes = [], [] + means, small, times = _pictures(path) + keep = [i for i, t in enumerate(times) if start <= t < end] + if keep: + means, small, times = [means[i] for i in keep], [small[i] for i in keep], [times[i] for i in keep] + black = _runs([m < take_qa.BLACK_LUMA and float(g.std()) < take_qa.BLACK_SPREAD for m, g in zip(means, small)], + times, take_qa.BLACK_SECONDS) + flags = [False] * len(small) + for index in range(1, len(small)): + if float(np.abs(small[index] - small[index - 1]).mean()) <= take_qa.FREEZE_DIFF: + flags[index] = flags[index - 1] = True + frozen = _runs(flags, times, take_qa.FREEZE_SECONDS) + if black: + problems.append('black picture at %s' % ', '.join('%.1f-%.1f s' % s for s in black)) + if frozen: + problems.append('frozen picture at %s' % ', '.join('%.1f-%.1f s' % s for s in frozen)) + samples = _samples(path)[int(start * take_qa.SAMPLE_RATE):int(end * take_qa.SAMPLE_RATE)] + step = int(take_qa.SAMPLE_RATE * take_qa.WINDOW) + count = samples.size // step + if count: + levels = [_db(np.sqrt(np.mean(samples[i * step:(i + 1) * step] ** 2))) for i in range(count)] + moments = [start + i * take_qa.WINDOW for i in range(count)] + if any(level > take_qa.SPEECH_DB for level in levels): + gaps = [(a, b) for a, b in _runs([level < take_qa.GAP_DB for level in levels], moments, take_qa.GAP_SECONDS) + if a > start + take_qa.GAP_EDGE and b < moments[-1] - take_qa.GAP_EDGE] + if gaps: + problems.append('silent gap at %s' % ', '.join('%.1f-%.1f s' % g for g in gaps)) + return dict(problems=problems, notes=notes, ok=not problems) + + +def _side(samples, at, span, before): + rate = take_qa.SAMPLE_RATE + n = max(1, int(span * rate)) + cut = min(samples.size, max(0, int(round(at * rate)))) + return samples[max(0, cut - n):cut] if before else samples[cut:cut + n] + + +def _seam(a, a_end, b, b_start, span): + left, right = _side(a, a_end, span, True), _side(b, b_start, span, False) + if not left.size or not right.size: + return dict(level_db=0.0, click=0.0, in_speech=False, penalty=0.0) + levels = [_db(np.sqrt(np.mean(x ** 2))) for x in (left, right)] + level_db = abs(levels[0] - levels[1]) + click = float(abs(right[0] - left[-1])) + in_speech = any(level > take_qa.SPEECH_DB for level in levels) + base = 0.6 * min(1.0, level_db / SEAM_LEVEL_DB) + 0.4 * min(1.0, click / SEAM_CLICK) + penalty = base + SEAM_SPEECH if in_speech else base * SEAM_QUIET + return dict(level_db=round(level_db, 2), click=round(click, 4), in_speech=in_speech, + penalty=round(min(1.0, penalty), 4)) + + +def seam(a_path, a_end, b_path, b_start, *, span=0.1): + """How a join sounds: level step over `span` s each side, sample jump, whether it falls in speech, penalty 0..1.""" + return _seam(_samples(a_path), a_end, _samples(b_path), b_start, span) + + +def _whole(candidates): + best = min(candidates, key=lambda c: (-sum(c.get('scores') or [0]), c['take'])) + return dict(pieces=[dict(take=best['take'], start=0.0, end=best['seconds'])], + score=round(float(sum(best.get('scores') or [0])), 6), fallback=True) + + +def stitch(candidates, *, seam_weight=0.5): + """Choose a take for every segment, maximizing segment scores minus seam_weight * 10 * seam penalties. + A join between different takes inside speech is not allowed. Falls back to the best whole take when cut counts + differ, a segment has no usable candidate or no allowed sequence of takes exists.""" + if not candidates: + return dict(pieces=[], score=0.0, fallback=True) + candidates = sorted(candidates, key=lambda c: c['take']) + count = len(candidates[0]['cuts']) + 1 + if any(len(c['cuts']) + 1 != count or len(c['scores']) != count or len(c['qa']) != count for c in candidates): + return _whole(candidates) + bounds = {c['take']: [0.0] + [float(t) for t in c['cuts']] + [float(c['seconds'])] for c in candidates} + usable = [[c for c in candidates if c['qa'][k]] for k in range(count)] + if not all(usable): + return _whole(candidates) + audio, joins = {}, {} + + def penalty(a, b, k): + """The seam penalty, or None when the join is not allowed: a join between takes inside speech, or unreadable.""" + key = (a['take'], b['take'], k) + if key not in joins: + try: + for c in (a, b): + if c['take'] not in audio: + audio[c['take']] = _samples(c['path']) + row = _seam(audio[a['take']], bounds[a['take']][k + 1], audio[b['take']], bounds[b['take']][k + 1], 0.1) + joins[key] = None if row['in_speech'] else row['penalty'] + except Exception: + joins[key] = None + return joins[key] + + # state: candidate take -> (score, takes so far); ties keep the smaller take sequence + state = {c['take']: (float(c['scores'][0]), [c['take']]) for c in usable[0]} + for k in range(1, count): + step = {} + for c in usable[k]: + options = [] + for p in usable[k - 1]: + if p['take'] not in state: + continue + score, path = state[p['take']] + cost = 0.0 + if p['take'] != c['take']: + seam_penalty = penalty(p, c, k - 1) + if seam_penalty is None: + continue + cost = seam_weight * 10 * seam_penalty + options.append((round(score + float(c['scores'][k]) - cost, 9), path + [c['take']])) + if options: + step[c['take']] = min(options, key=lambda o: (-o[0], o[1])) + if not step: + return _whole(candidates) + state = step + score, takes = min(state.values(), key=lambda o: (-o[0], o[1])) + pieces = [] + for k, take in enumerate(takes): + start, end = bounds[take][k], bounds[take][k + 1] + if pieces and pieces[-1]['take'] == take: + pieces[-1]['end'] = end + else: + pieces.append(dict(take=take, start=start, end=end)) + return dict(pieces=pieces, score=round(score, 6), fallback=False) diff --git a/freevideo_engine/agent/production.py b/freevideo_engine/agent/production.py index 5229df82..a91b4705 100644 --- a/freevideo_engine/agent/production.py +++ b/freevideo_engine/agent/production.py @@ -486,7 +486,7 @@ def advance(state, children, *, now): if join.get('cancel_requested'): actions.append({'cancel': join['ref']}) elif not state.get('paused') and join['ref'] not in children: - actions.append({'join': {key: deepcopy(join[key]) for key in ('ref', 'takes', 'transition')}}) + actions.append({'join': {key: deepcopy(join[key]) for key in ('ref', 'takes', 'transition', 'pieces') if key in join}}) return actions @@ -623,8 +623,40 @@ def _missing(state): return result -def finish(state, *, by, transition=None, now): - """Snapshot the chosen refs for a new join, retaining every previous output.""" +def _pieces(state, pieces): + """Validate the parts to join for some shots; returns {shot id: [{job, start, end}]} or None.""" + if not pieces: + return None + result, problems = {}, [] + for shot in state['shots']: + rows = pieces.get(shot['id']) + if rows is None: + continue + found = [] + for row in rows if isinstance(rows, list) else (): + take = next((t for t in shot['takes'] if t['take'] == row.get('take') + and t['state'] == 'succeeded' and not t['stale']), None) + start, end = row.get('start'), row.get('end') + if take is None or type(start) not in (int, float) or type(end) not in (int, float) or not 0 <= start < end: + problems.append(dict(path='/pieces/' + shot['id'], message='needs succeeded takes and 0 <= start < end')) + break + found.append(dict(job=take['job'], start=float(start), end=float(end))) + else: + if found: + result[shot['id']] = found + continue + problems.append(dict(path='/pieces/' + shot['id'], message='must list at least one part')) + unknown = sorted(set(pieces) - {s['id'] for s in state['shots']}) + problems += [dict(path='/pieces/' + str(k), message='is not a shot') for k in unknown] + if problems: + _invalid(problems) + return result or None + + +def finish(state, *, by, transition=None, pieces=None, now): + """Snapshot the chosen refs for a new join, retaining every previous output. + + `pieces` maps a shot id to [{take, start, end}] to join those parts of its takes instead of the chosen take.""" _editable(state, by) missing = _missing(state) if missing: @@ -634,13 +666,16 @@ def finish(state, *, by, transition=None, now): if transition not in ('cut', 'crossfade'): _invalid([dict(path='/transition', message='must be "cut" or "crossfade"')]) refs = [_chosen(shot)['job'] for shot in state['shots']] + # A crossfade overlaps every neighbouring clip, which would dissolve the cuts inside a shot too. + parts = _pieces(state, pieces) if transition == 'cut' else None current = next((j for j in state['joins'] if j['ref'] == state['current_join']), None) - if current and current['state'] in ACTIVE and current['takes'] == refs and current['transition'] == transition: + if current and current['state'] in ACTIVE and current['takes'] == refs and current['transition'] == transition and current.get('pieces') == parts: return draft = deepcopy(state) ref = '%s:join%d' % (state['id'], len(state['joins']) + 1) draft['joins'].append(dict(ref=ref, takes=refs, transition=transition, state='waiting', by=by, - revision=state['revision'] + 1, library_id=None, seconds=None, error=None)) + revision=state['revision'] + 1, library_id=None, seconds=None, error=None, + **({'pieces': parts} if parts else {}))) draft['current_join'], draft['cancelled'] = ref, False _commit(state, draft, now) diff --git a/freevideo_engine/agent/productions.py b/freevideo_engine/agent/productions.py index 0b9ca030..68b100f6 100644 --- a/freevideo_engine/agent/productions.py +++ b/freevideo_engine/agent/productions.py @@ -1012,8 +1012,14 @@ def _execute(self, identity, action): if len(set(children) | set(record['children'])) >= MAX_CHILDREN: raise JobError('production_limit', 'Too many child jobs') if 'join' in action: - item = dict(kind='assemble', clips=[children[r]['id'] for r in item['takes']], - transition=item['transition'], + parts = item.get('pieces') or {} + shots = {t['job']: s['id'] for s in record['state']['shots'] for t in s['takes']} + clips = [] + for r in item['takes']: + rows = parts.get(shots.get(r)) + clips += ([dict(from_job=children[x['job']]['id'], start=x['start'], end=x['end']) for x in rows] + if rows else [children[r]['id']]) + item = dict(kind='assemble', clips=clips, transition=item['transition'], transition_seconds=record['state']['screenplay'].get('transition_seconds', .5)) else: take = next(t for s in record['state']['shots'] for t in s['takes'] if t['job'] == ref) @@ -1125,6 +1131,33 @@ def sheet(self, identity, shot, *, frames=None): result['previous'] = output.read_bytes() return result + def segments(self, identity, shot): + """The picture cuts of each usable take of a shot, with each part checked: what the director needs to + take the best part of every cut from different takes.""" + from . import cut_pick + with self.changed: + record = self._record(identity) + row = _domain(production._shot, record['state'], shot) + takes = [(t['take'], t['library_id'], t.get('seconds'), spec.cut_times((t.get('spec') or {}).get('prompt')), + (record.get('qa') or {}).get(t['job'])) for t in row['takes'] + if t['state'] == 'succeeded' and not t['stale']] + result = [] + with self.jobs.cpu: + for number, library, seconds, expected, qa in takes: + if not library or not (qa or {}).get('ok', False): + continue + path = self.jobs.port.library_video(library) + cuts = cut_pick.detect_cuts(path, [t for t in expected if t > 0]) + if None in cuts: + continue # A cut the prompt asks for is not in this take: its parts cannot be matched to the others. + total = float(seconds) if seconds else None + if total is None: + continue + bounds = [0.0] + cuts + [total] + checks = [cut_pick.segment_qa(path, bounds[i], bounds[i + 1])['ok'] for i in range(len(bounds) - 1)] + result.append(dict(take=number, path=str(path), cuts=cuts, seconds=total, qa=checks)) + return result + def adapter(self): return DirectorAdapter(self) @@ -1142,6 +1175,6 @@ def get(self, identity, *, since=None, wait=0): return self.productions.get(identity, since=since, wait=wait, compact=False) def __getattr__(self, name): - if name in ('plan', 'start', 'sheet', 'choose', 'reshoot', 'finish', 'cancel', 'pause', 'resume'): + if name in ('plan', 'start', 'sheet', 'segments', 'choose', 'reshoot', 'finish', 'cancel', 'pause', 'resume'): return getattr(self.productions, name) raise AttributeError(name) From 7b2675a129ce63e23b28bf609cf1f616d26c2f30 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 7 Nov 2026 01:44:00 +0000 Subject: [PATCH 4/9] Voice change with a script: a sentence cut by a pause is said once (joined only when each line... Voice change with a script: a sentence cut by a pause is said once (joined only when each line is its part, they say nearly all of it, and no silence or length is more than one line has), equal words take the script lines in order, numbers, hyphenated words and initials stay whole, and the result says why speakers were not told apart --- freevideo_engine/agent/voice.py | 6 ++- freevideo_engine/voice/script.py | 80 ++++++++++++++++++++++++------ freevideo_engine/voice/speakers.py | 18 +++++-- 3 files changed, 84 insertions(+), 20 deletions(-) diff --git a/freevideo_engine/agent/voice.py b/freevideo_engine/agent/voice.py index 173a34e9..9cc6533a 100644 --- a/freevideo_engine/agent/voice.py +++ b/freevideo_engine/agent/voice.py @@ -340,14 +340,15 @@ def progress(fraction): words = [word for line in heard['lines'] for word in line['words']] rows = sentences.lines(words, audio, rate) known = spec.get('script') or [] - entries = script.match(rows, known) if known else [None] * len(rows) + rows, entries = script.merge(rows, known) if known else (rows, [None] * len(rows)) for line, entry in zip(rows, entries): if entry is not None: # the text is known: it is what the line says, and the recogniser only gives the times line['words'] = script.retime(line['words'], entry['text'], line['start'], line['end']) or line['words'] line['text'] = sentences._join(line['words']) slices = [audio[int(line['start'] * rate):int(line['end'] * rate)] for line in rows] - numbers = speakers.label(slices, speakers.embedder()) + why = [] + numbers = speakers.label(slices, speakers.embedder(why=why), why) if known: numbers = speakers.by_role(numbers, [None if entry is None or not (entry.get('speaker') or '').strip() else script.roles(known)[entry['speaker'].strip()] for entry in entries]) @@ -359,6 +360,7 @@ def progress(fraction): kind = 'dialogue' if ctx['separated'] or sources else 'a1' return dict(editor_project=spec['editor_project'], revision=spec['revision'], separated=ctx['separated'], language=heard.get('language'), speech=bool(lines), lines=lines, source=kind, + **(dict(speakers_not_told_apart=why[0]) if why and lines else {}), recognition=remember(spec, ctx, spec.get('language', 'auto'), lines) if lines else None) finally: _drop_folder(jobs, sources) diff --git a/freevideo_engine/voice/script.py b/freevideo_engine/voice/script.py index 7547f49b..7eec2db4 100644 --- a/freevideo_engine/voice/script.py +++ b/freevideo_engine/voice/script.py @@ -8,9 +8,14 @@ from difflib import SequenceMatcher FLOOR = .55 # least likeness of a recognised line to a script line for them to be the same line +BALANCE = .6 # a recognised line is the script line only if neither is much longer than the other +SPAN = 4 # most recognised lines (a pause cuts one sentence in pieces) that one script line may cover +COVER = .85 # the lines a script line covers must say at least this much of it +GAP = 2. # longest silence, seconds, inside one script line cut by a pause +LONGEST = 29. # longest line, seconds (as in sentences.LONGEST) MINIMUM = .06 # no word is given less time than this, seconds _CJK = '぀-ヿ㐀-鿿가-힯' -_PIECE = re.compile('[%s]|[^\\W_%s]+(?:\'[^\\W_%s]+)?' % (_CJK, _CJK, _CJK)) +_PIECE = re.compile('[%s]|(?:[^\\W\\d_%s]\\.){2,}(?:[^\\W\\d_%s](?!\\w))?|[^\\W_%s]+(?:[\'’.,-][^\\W_%s]+)*%%?' % (_CJK, _CJK, _CJK, _CJK, _CJK)) def letters(text): @@ -25,22 +30,69 @@ def pieces(text): def match(lines, script): """For each recognised line, the script entry it is (or None), in order; an entry is used once. - `lines` are [{text}], `script` [{text, speaker?}].""" + `lines` are [{text}], `script` [{text, speaker?}]. Of equally alike entries the first unused one is taken.""" + return [entry for _, entry in _assign(lines, script)] + + +def _assign(lines, script): + """[(number of lines covered, entry)] for each line: the first of a covered run holds the run, the rest (0, None).""" wanted = [letters(entry['text']) for entry in script] - used, found = set(), [] - for line in lines: - heard = letters(line['text']) - best, likeness = None, FLOOR - for index, text in enumerate(wanted): - if index in used or not heard or not text: - continue - ratio = SequenceMatcher(None, heard, text, autojunk=False).ratio() - if ratio >= likeness: - best, likeness = index, ratio + used, result, at = set(), [], 0 + while at < len(lines): + best, likeness, span = None, FLOOR, 1 + for count in range(1, min(SPAN, len(lines) - at) + 1): + group = lines[at:at + count] + heard = letters(' '.join(line['text'] for line in group)) + for index, text in enumerate(wanted): + if index in used or not heard or not text or min(len(heard), len(text)) < BALANCE * max(len(heard), len(text)): + continue + if count > 1 and not _one_line(group, text): + continue + ratio = SequenceMatcher(None, heard, text, autojunk=False).ratio() + if ratio >= FLOOR and (best is None or ratio > likeness): + best, likeness, span = index, ratio, count if best is not None: used.add(best) - found.append(None if best is None else script[best]) - return found + result.append((span, None if best is None else script[best])) + result.extend([(0, None)] * (span - 1)) + at += span + return result + + +def _one_line(group, text): + """Whether the recognised `group` of lines is one script line (letters `text`) cut by pauses: each line is the part + of it that stands in its place, together they say all of it, and no silence or length is more than one line has.""" + parts = [letters(line['text']) for line in group] + if sum(map(len, parts)) < COVER * len(text): + return False + here = 0 + for part in parts: + if SequenceMatcher(None, part, text[here:here + len(part)], autojunk=False).ratio() < FLOOR: + return False + here += len(part) + if all('start' in line and 'end' in line for line in group): + return (group[-1]['end'] - group[0]['start'] <= LONGEST + and all(after['start'] - before['end'] <= GAP for before, after in zip(group, group[1:]))) + return True + + +def merge(lines, script): + """(lines, entries) where the recognised lines that one script line covers are joined into one line. + + A sentence cut by a pause is two recognised lines; joined, it is said once. `lines` are [{text, words, start, end}].""" + rows, entries, at = [], [], 0 + for span, entry in _assign(lines, script): + if span == 0: + continue + group = lines[at:at + span] + at += span + row = dict(group[0]) + if span > 1: + row.update(words=[word for line in group for word in line['words']], end=group[-1]['end'], + text=' '.join(line['text'] for line in group)) + rows.append(row) + entries.append(entry) + return rows, entries def retime(words, text, start, end): diff --git a/freevideo_engine/voice/speakers.py b/freevideo_engine/voice/speakers.py index b8767bee..0e48df55 100644 --- a/freevideo_engine/voice/speakers.py +++ b/freevideo_engine/voice/speakers.py @@ -14,17 +14,22 @@ RATE = 16000 -def embedder(root=None): - """A function from mono 16 kHz samples to a vector, or None when the model or its runtime is not here.""" +def embedder(root=None, why=None): + """A function from mono 16 kHz samples to a vector, or None when the model or its runtime is not here. + + The reason it is None is appended to the list `why` (a short code, never a path).""" + why = why if why is not None else [] try: from ..dubbing import pack file = pack.directory(root) / 'campplus.onnx' if not file.is_file(): + why.append('model_missing') return None try: import onnxruntime except ImportError: if not pack.activate(pack.runtime_path(root)): + why.append('runtime_missing') return None import onnxruntime import torch @@ -34,6 +39,7 @@ def embedder(root=None): session = onnxruntime.InferenceSession(str(file), sess_options=options, providers=['CPUExecutionProvider']) except Exception as error: # a missing optional part must never fail the lines _LOG.info('Speakers are not told apart: %s', error) + why.append('load_failed:%s' % type(error).__name__) return None name = session.get_inputs()[0].name @@ -77,8 +83,10 @@ def cluster(vectors, same=SAME): return numbers -def label(slices, embed): - """Speaker numbers for the mono 16 kHz `slices` of the lines, in order (all 0 without an embedder).""" +def label(slices, embed, why=None): + """Speaker numbers for the mono 16 kHz `slices` of the lines, in order (all 0 without an embedder). + + When `why` (a list) already holds a reason it stays; a line that could not be told apart adds 'line_failed:'.""" if embed is None: return [0] * len(slices) vectors = [] @@ -87,6 +95,8 @@ def label(slices, embed): vectors.append(embed(samples) if len(samples) >= SHORTEST * RATE else None) except Exception as error: _LOG.info('A line could not be told apart: %s', error) + if why is not None and not why: + why.append('line_failed:%s' % type(error).__name__) vectors.append(None) return cluster(vectors) From c30b567af16e80b1911b9df99f88f69f75803a9d Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 7 Nov 2026 01:47:13 +0000 Subject: [PATCH 5/9] Editor: the record button stays in the toolbar and greys out when the selection cannot take a... Editor: the record button stays in the toolbar and greys out when the selection cannot take a recording, so the icons after it no longer move --- web/editor_record.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/editor_record.js b/web/editor_record.js index 3c75b721..cc0bc8a0 100644 --- a/web/editor_record.js +++ b/web/editor_record.js @@ -181,8 +181,8 @@ export function createEditorRecord({state, player, library, t, error = () => {}, // The menu entry sits on the sound tracks, where an empty spot or a stretch chosen can take a recording. const unregister = registerMenuItem({where: 'empty', group: 'sound', label: context => context.t('Record', '录音'), visible: context => !context.track || ['a1', 'a2'].includes(context.track), run: () => open()}); - const unsubscribe = state.subscribe(event => { if (event.type === 'selection' && toolbarButton) toolbarButton.hidden = !recordable(state.selection); }); - if (toolbarButton) toolbarButton.hidden = !recordable(state.selection); + const unsubscribe = state.subscribe(event => { if (event.type === 'selection' && toolbarButton) toolbarButton.disabled = !recordable(state.selection); }); + if (toolbarButton) toolbarButton.disabled = !recordable(state.selection); paint(); return {element: bar, open, hide, keyboard: keys, get phase() { return phase; }, dispose() { disposed = true; unregister(); unsubscribe(); abort(); release(); bar.remove(); }}; } From 9f12e6a3502518f94b23d28856ae354839a067ff Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 7 Nov 2026 01:50:00 +0000 Subject: [PATCH 6/9] Agent: take check also reports picture cuts the prompt did not ask for, planned cuts that are... Agent: take check also reports picture cuts the prompt did not ask for, planned cuts that are missing, the longest shot, and a first or last frame that is not the given one --- freevideo_engine/agent/cut_pick.py | 14 +++++--- freevideo_engine/agent/productions.py | 22 ++++++++++-- freevideo_engine/agent/take_qa.py | 48 +++++++++++++++++++++++++-- 3 files changed, 75 insertions(+), 9 deletions(-) diff --git a/freevideo_engine/agent/cut_pick.py b/freevideo_engine/agent/cut_pick.py index bbcd36e9..484bf53a 100644 --- a/freevideo_engine/agent/cut_pick.py +++ b/freevideo_engine/agent/cut_pick.py @@ -55,15 +55,21 @@ def _spike(diffs, index, lo, hi, floor): return diffs[index] >= floor and diffs[index] >= CUT_SPIKE * (float(np.median(around)) if around else 0.0) +def cuts_in(small, times): + """Seconds of the hard picture cuts in decoded frames, ascending, excluding 0 and the end.""" + diffs = _changes(small) + return [round(times[i], 3) for i in range(1, len(times)) + if (diffs[i] >= CUT_DIFF and diffs[i] >= diffs[i - 1] and diffs[i] > (diffs[i + 1] if i + 1 < len(diffs) else -1) + and _spike(diffs, i, max(1, i - 3), min(len(diffs), i + 4), CUT_DIFF))] + + def detect_cuts(path, expected=None, *, tolerance=0.6): """Seconds of the hard picture cuts, ascending, excluding 0 and the end. With `expected`, each expected cut takes the strongest frame change within `tolerance` s; one with no such change is None, not guessed.""" _, small, times = _pictures(path) - diffs = _changes(small) if not expected: - return [round(times[i], 3) for i in range(1, len(times)) - if (diffs[i] >= CUT_DIFF and diffs[i] >= diffs[i - 1] and diffs[i] > (diffs[i + 1] if i + 1 < len(diffs) else -1) - and _spike(diffs, i, max(1, i - 3), min(len(diffs), i + 4), CUT_DIFF))] + return cuts_in(small, times) + diffs = _changes(small) found = [] for wanted in expected: near = [i for i in range(1, len(times)) if abs(times[i] - wanted) <= tolerance] diff --git a/freevideo_engine/agent/productions.py b/freevideo_engine/agent/productions.py index 68b100f6..32d3329d 100644 --- a/freevideo_engine/agent/productions.py +++ b/freevideo_engine/agent/productions.py @@ -962,14 +962,30 @@ def _inspect_takes(self, identity): continue self._inspecting.add((identity, job)) lines = [row['line'] for row in shot.get('lines') or [] if row.get('line')] + made = take.get('spec') or {} + prompt = made.get('prompt') + # A prompt that is not text (taken from another job) says nothing about its cuts. + planned = [t for t in spec.cut_times(prompt) if t > 0] if isinstance(prompt, str) else None + given = made.get('media') or {} threading.Thread(target=self._inspect, daemon=True, args=( - identity, job, take['library_id'], shot.get('seconds'), lines)).start() + identity, job, take['library_id'], shot.get('seconds'), lines, planned, + self._frame_file(given.get('first')), self._frame_file(given.get('last')))).start() - def _inspect(self, identity, job, library, seconds, lines): + def _frame_file(self, item): + """The file of a given frame image that sits in the input folder, else None (other sources are not checked).""" + name = item.get('file') if isinstance(item, dict) else None + if not isinstance(name, str): + return None + try: + return self.jobs.port.input_path(name) + except (OSError, ValueError, TypeError): + return None + + def _inspect(self, identity, job, library, seconds, lines, planned=None, first=None, last=None): try: with self.jobs.cpu: result = take_qa.check(self.jobs.port.library_video(library), seconds=seconds, - lines=lines, transcribe=self.transcribe) + lines=lines, transcribe=self.transcribe, planned=planned, first=first, last=last) except Exception as error: # noqa: BLE001 - a failed check must not block the film; the judge sees it as unchecked logging.getLogger(__name__).warning('A take could not be checked (%s)', type(error).__name__) result = dict(error=type(error).__name__, problems=[], notes=[], ok=True) diff --git a/freevideo_engine/agent/take_qa.py b/freevideo_engine/agent/take_qa.py index a6abf96e..6e59e5e4 100644 --- a/freevideo_engine/agent/take_qa.py +++ b/freevideo_engine/agent/take_qa.py @@ -28,6 +28,9 @@ SPEECH_DB = -35.0 CLIP_PEAK = 0.999 LINE_MATCH = 0.6 +CUT_MATCH = 0.6 # s, a detected cut this close to a planned one is that cut +ASPECT_TOLERANCE = 0.03 # shape difference up to which a given frame is compared +HOLD_DIFF = 12.0 # 0-255 mean difference between a given frame and the take's own end frame ANALYSIS_WIDTH = 64 SAMPLE_RATE = 16000 WINDOW = 0.05 @@ -68,6 +71,19 @@ def recognise(path): return ' '.join(word['text'] for line in heard['lines'] for word in line['words']) +def _still(path, shape): + """A given frame image as a small gray array of the analysis size, or None when it cannot be read.""" + try: + from PIL import Image + with Image.open(media._file(path)) as image: + # A different shape is cropped or padded by the generator, so the two do not compare pixel for pixel. + if abs(image.width / image.height - shape[1] / shape[0]) > ASPECT_TOLERANCE: + return None + return np.asarray(image.convert('L').resize((shape[1], shape[0])), np.float32) + except Exception: # noqa: BLE001 - an unreadable frame image only skips its check + return None + + def _edge_flicker(changes, times): """Seconds of a single odd frame at the start or the end: a jump to or from the neighbour that the motion beside it does not explain.""" @@ -112,13 +128,32 @@ def _runs(flags, times, minimum): return spans +def _shots(result, notes, small, times, planned): + """Hard cuts inside the take: those the prompt did not ask for, planned ones that are missing, and the + longest stretch without a cut.""" + from . import cut_pick + cuts = cut_pick.cuts_in(small, times) + edges = [0.0] + cuts + [times[-1]] + result.update(cuts=cuts, longest_shot=round(max(b - a for a, b in zip(edges, edges[1:])), 2)) + if planned is None: + return + extra = [c for c in cuts if all(abs(c - p) > CUT_MATCH for p in planned)] + lost = [p for p in planned if p > 0 and all(abs(c - p) > CUT_MATCH for c in cuts)] + if extra: + notes.append('picture cuts at %s that the prompt does not ask for' % ', '.join('%.1f s' % c for c in extra)) + if lost: + notes.append('no picture cut at %s where the prompt has one' % ', '.join('%.1f s' % c for c in lost)) + + def _db(value): return float(20 * np.log10(max(float(value), 1e-9))) -def check(path, *, seconds=None, size=None, lines=None, transcribe=None): +def check(path, *, seconds=None, size=None, lines=None, transcribe=None, planned=None, first=None, last=None): """Inspect one take. `seconds` is the planned length, `size` the expected (width, height), - `lines` the spoken lines the shot should contain and `transcribe(path)` returns the heard text.""" + `lines` the spoken lines the shot should contain and `transcribe(path)` returns the heard text. + `planned` lists the seconds of the cuts the prompt asks for (empty: a single shot; None: not known), + `first` and `last` are the given first and last frame images, if any.""" problems, notes = [], [] info = media.inspect(path) if size and (info['width'], info['height']) != tuple(size): @@ -154,6 +189,15 @@ def check(path, *, seconds=None, size=None, lines=None, transcribe=None): if flicker: notes.append('single odd frame at the %s (trim %s)' % (' and '.join(w for w, _ in flicker), ' and '.join('the first frame' if w == 'start' else 'the last frame' for w, _ in flicker))) result.update(black=black, frozen=frozen, flicker=[w for w, _ in flicker], stopped=ended) + if len(small) > 2: + _shots(result, notes, small, times, planned) + for name, given, frame in (('first', first, small[0] if small else None), ('last', last, small[-1])): + still = _still(given, frame.shape) if given else None + if still is not None: + held = float(np.abs(still - frame).mean()) + result[name + '_frame_diff'] = round(held, 1) + if held > HOLD_DIFF: + notes.append('the %s frame is not the given one' % name) if not info['has_audio']: problems.append('no audio track') From be7a47a09f09540d7d708198a7eb17f55e6b92a6 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 7 Nov 2026 02:06:57 +0000 Subject: [PATCH 7/9] Agent: external Agents can take the best part of every cut too. Agent: external Agents can take the best part of every cut too. A new segments read returns each take's cuts, part checks and how takes join at each cut; choose and finish accept pieces, kept in the film state, and an Agent cannot change the parts of a shot the user chose. Cut times a caller rounds still join on the right frame. The built-in director waits for the other takes unless every cut of the first scores 7 or more. A join no longer adds a stray frame, one frame cannot be two cuts, cuts that do not advance or a take without numbered cuts fall back to one whole take --- freevideo_engine/agent/builtin_director.py | 75 ++++++++++++++-------- freevideo_engine/agent/cut_pick.py | 12 +++- freevideo_engine/agent/guide.py | 6 ++ freevideo_engine/agent/production.py | 54 ++++++++++++++-- freevideo_engine/agent/productions.py | 37 ++++++++++- freevideo_engine/agent/routes.py | 2 + freevideo_engine/agent/schema.py | 17 ++++- 7 files changed, 169 insertions(+), 34 deletions(-) diff --git a/freevideo_engine/agent/builtin_director.py b/freevideo_engine/agent/builtin_director.py index 172e60b6..2dc40503 100644 --- a/freevideo_engine/agent/builtin_director.py +++ b/freevideo_engine/agent/builtin_director.py @@ -431,18 +431,13 @@ def judge(shot_view, sheets, *, ask): CUT_AGREE = 0.3 # seconds by which the same cut may differ between takes -def stitch_shot(shot_view, segments, sheets, *, ask): - """Pieces taking the best part of every cut from different takes, or None to keep one whole take. +GOOD = 7 # a part is good enough to keep when it scores this or more - Needs two or more takes that passed the check, every one with the same two or more cuts. The last piece - comes from the take that is chosen, so the next shot still continues from the chosen take's last frame.""" - from . import cut_pick - if len(segments) < 2 or any(len(s['cuts']) != len(segments[0]['cuts']) for s in segments) or not segments[0]['cuts']: - return None - if any(abs(a - b) > CUT_AGREE for s in segments for a, b in zip(s['cuts'], segments[0]['cuts'])): - return None # The same cut sits at different times: these are not the same parts. + +def rate_segments(shot_view, segments, sheets, *, ask): + """{take: [score of every part]} for takes that share their cuts, or None when the model cannot say.""" images = {row['take']: row['image'] for row in sheets['takes']} - if any(s['take'] not in images for s in segments): + if not segments or any(s['take'] not in images for s in segments): return None count = len(segments[0]['cuts']) + 1 numbers = [s['take'] for s in segments] @@ -460,7 +455,35 @@ def stitch_shot(shot_view, segments, sheets, *, ask): reply = _ask(ask, prompt, [images[n] for n in numbers], schema) if reply is None or sorted(r['take'] for r in reply['ratings']) != sorted(numbers): return None - scores = {r['take']: r['scores'] for r in reply['ratings']} + return {r['take']: r['scores'] for r in reply['ratings']} + + +def first_take_is_good(tools, production_id, shot, sheets, take, *, ask): + """Whether a shot still being filmed may stop at this take: it has no numbered cuts, or every part of it scores + GOOD. Otherwise the other takes are waited for so each cut can take its best part.""" + try: + own = [s for s in tools.segments(production_id, shot['id']) if s['take'] == take] + if not own or not own[0]['cuts']: + return True + scores = rate_segments(shot, own, sheets, ask=ask) + except Exception: # noqa: BLE001 - without the cut scores the whole-take score decides, as before + return True + return scores is None or all(x >= GOOD for x in scores.get(take, ())) + + +def stitch_shot(shot_view, segments, sheets, *, ask): + """Pieces taking the best part of every cut from different takes, or None to keep one whole take. + + Needs two or more takes that passed the check, every one with the same two or more cuts. The last piece + comes from the take that is chosen, so the next shot still continues from the chosen take's last frame.""" + from . import cut_pick + if len(segments) < 2 or any(len(s['cuts']) != len(segments[0]['cuts']) for s in segments) or not segments[0]['cuts']: + return None + if any(abs(a - b) > CUT_AGREE for s in segments for a, b in zip(s['cuts'], segments[0]['cuts'])): + return None # The same cut sits at different times: these are not the same parts. + scores = rate_segments(shot_view, segments, sheets, ask=ask) + if scores is None: + return None candidates = [dict(s, scores=scores[s['take']]) for s in segments] result = cut_pick.stitch(candidates) if result['fallback'] or len(result['pieces']) < 2: @@ -520,16 +543,17 @@ def _ref(identity, action, snapshot): return identity + ':builtin:' + action + ':' + digest -def _with_stitch(tools, production_id, shot, sheets, decision, stitched, ask): - """Take the best part of every cut when the takes allow it; the choice becomes the take that ends the film part.""" +def _with_stitch(tools, production_id, shot, sheets, decision, ask): + """Take the best part of every cut when the takes allow it; the choice becomes the take that ends the film part. + Returns the decision and the parts, or None for the parts when one whole take stands.""" try: pieces = stitch_shot(shot, tools.segments(production_id, shot['id']), sheets, ask=ask) except Exception: # noqa: BLE001 - stitching is an extra; one whole take is always the fallback - return decision + return decision, None if pieces is None: - return decision - stitched[shot['id']] = [dict(take=p['take'], start=p['start'], end=p['end']) for p in pieces['pieces']] - return replace(decision, take=pieces['pieces'][-1]['take'], reason=(decision.reason + ';各切镜取最好的一版拼接')[:300]) + return decision, None + parts = [dict(take=p['take'], start=p['start'], end=p['end']) for p in pieces['pieces']] + return replace(decision, take=parts[-1]['take'], reason=(decision.reason + ';各切镜取最好的一版拼接')[:300]), parts def run(tools, production_id, *, ask, stop, wait=25): @@ -541,7 +565,7 @@ def run(tools, production_id, *, ask, stop, wait=25): """ wait = max(0, min(25, wait)) since, errors, conflicts = None, 0, 0 - handed_off, stitched = set(), {} + handed_off = set() while not stop(): try: view = tools.get(production_id, since=since, wait=wait if since is not None else 0) @@ -559,11 +583,8 @@ def run(tools, production_id, *, ask, stop, wait=25): continue snapshot = [(s['id'], s['chosen']) for s in view['shots']] chosen = dict(snapshot) - # A part list only stands while its last take is still the chosen one. - pieces = {k: v for k, v in stitched.items() if chosen.get(k) == v[-1]['take']} - tools.finish(production_id, by='builtin', ref=_ref(production_id, 'finish', snapshot + sorted( - (k, [(p['take'], p['start'], p['end']) for p in v]) for k, v in pieces.items())), - **({'pieces': pieces} if pieces else {})) + # The parts of a shot are kept with its chosen take in the production. + tools.finish(production_id, by='builtin', ref=_ref(production_id, 'finish', snapshot)) yield dict(shot=None, action='finish', reason='每镜已选定,开始接成影片。') return if action not in ('choose', 'reshoot'): @@ -591,8 +612,12 @@ def run(tools, production_id, *, ask, stop, wait=25): elif decision.action in ('choose', 'reshoot'): ref = _ref(production_id, decision.action, shot) if decision.action == 'choose': - decision = _with_stitch(tools, production_id, shot, sheets, decision, stitched, ask) - tools.choose(production_id, shot['id'], decision.take, by='builtin', reason=decision.reason, ref=ref) + if shot.get('state') in ('waiting', 'shooting') and not first_take_is_good( + tools, production_id, shot, sheets, decision.take, ask=ask): + continue # the first take is not good in every cut: wait for the others + decision, parts = _with_stitch(tools, production_id, shot, sheets, decision, ask) + tools.choose(production_id, shot['id'], decision.take, by='builtin', reason=decision.reason, ref=ref, + **({'pieces': parts} if parts else {})) else: tools.reshoot(production_id, shot['id'], by='builtin', note=decision.note, takes=decision.takes, reason=decision.reason, ref=ref) diff --git a/freevideo_engine/agent/cut_pick.py b/freevideo_engine/agent/cut_pick.py index 484bf53a..9e3f6892 100644 --- a/freevideo_engine/agent/cut_pick.py +++ b/freevideo_engine/agent/cut_pick.py @@ -75,9 +75,17 @@ def detect_cuts(path, expected=None, *, tolerance=0.6): near = [i for i in range(1, len(times)) if abs(times[i] - wanted) <= tolerance] best = max(near, key=lambda i: (diffs[i], -abs(times[i] - wanted)), default=None) if best is not None and _spike(diffs, best, max(1, min(near) - 1), min(len(diffs), max(near) + 2), MATCH_DIFF): - found.append(round(times[best], 3)) + found.append(times[best]) else: found.append(None) + # one frame cannot be two cuts: the cut nearer its expected time keeps it, the other is not found + for index, at in enumerate(found): + for other in range(index): + if at is not None and found[other] == at: + loser = index if abs(at - expected[index]) >= abs(at - expected[other]) else other + found[loser] = None + if loser == index: + break return found @@ -156,6 +164,8 @@ def stitch(candidates, *, seam_weight=0.5): if any(len(c['cuts']) + 1 != count or len(c['scores']) != count or len(c['qa']) != count for c in candidates): return _whole(candidates) bounds = {c['take']: [0.0] + [float(t) for t in c['cuts']] + [float(c['seconds'])] for c in candidates} + if any(b <= a for row in bounds.values() for a, b in zip(row, row[1:])): + return _whole(candidates) # a cut at or before the previous one leaves an empty piece usable = [[c for c in candidates if c['qa'][k]] for k in range(count)] if not all(usable): return _whole(candidates) diff --git a/freevideo_engine/agent/guide.py b/freevideo_engine/agent/guide.py index c96e0d86..b61cf912 100644 --- a/freevideo_engine/agent/guide.py +++ b/freevideo_engine/agent/guide.py @@ -197,6 +197,11 @@ 'job (30 s or more, or beyond the device limit, continues and joins automatically); to polish an idea first, run enhance, then generate with "prompt": {"from_job": ""} rather than retyping the text. ' 'A film is cut into shots: see the rule "Cut by default" before making one long continued video.', 'Use drama plan → start → get → sheet → choose/reshoot → finish; inspect todo after every change.', + 'A shot whose prompt has numbered cuts and several takes: do not stop at the first take unless every cut of it is good ' + '(scores 7 or more of 10); wait for the other takes, read segments, and choose with pieces ' + '[{take, start, end}] to take the best part of every cut from different takes. Cut only where the takes cut, ' + 'never inside speech (in_speech), and skip a part with parts_ok false. The last part comes from the chosen take. Pass the cut times back exactly as returned, unrounded. ' + 'finish accepts pieces for any shot too. One whole take stays the fallback.', 'At start tell the user the estimated total time; on over_budget decide whether to ask them; ' 'without an estimate report only the current work and elapsed time.', 'Use get with since=revision and wait=25. changed=false is a compact timeout; retain the last full board.', @@ -353,6 +358,7 @@ ('GET', '/drama/{id}'): '读取待办事项 todo 和版本 revision;用 since 和 wait=25 等待变化。' 'paused 有值时停止操作,持续等待用户继续', ('GET', '/drama/{id}/shots/{shot}/sheet'): '各条生成完成后,查看带时间标记的画面和前一镜所选片段的末帧', + ('GET', '/drama/{id}/shots/{shot}/segments'): '镜头有几版且提示词写了同样的编号切镜时,读取每版的切点、各段检查和各版在每个切点处的衔接,再用 pieces 选出每个切镜最好的一段', ('POST', '/drama/{id}/shots/{shot}/choose'): '查看画面后选择一条;选定后才能拍摄承接它的下一镜', ('POST', '/drama/{id}/shots/{shot}/reshoot'): '按修改要求重拍,再查看画面并重新选片', ('POST', '/drama/{id}/finish'): '每一镜都选定后提交拼接,继续查询影片直到成片就绪', diff --git a/freevideo_engine/agent/production.py b/freevideo_engine/agent/production.py index a91b4705..73f2a1f7 100644 --- a/freevideo_engine/agent/production.py +++ b/freevideo_engine/agent/production.py @@ -335,12 +335,13 @@ def _reconnect(state, shot): if take['stale'] and take['state'] in ACTIVE + ('paused',): take['cancel_requested'] = True if not _chosen(shot): - shot['chosen'] = shot['chosen_by'] = None + shot['chosen'] = shot['chosen_by'] = shot['pieces'] = None choice = shot.get('choices', {}).get(parent['job']) if parent else None if choice: take = _take(shot, choice['take']) if take['state'] == 'succeeded' and not take['stale']: shot['chosen'], shot['chosen_by'] = take['take'], choice['by'] + shot['pieces'] = None if _usable(shot): _drop_automatic(shot) @@ -547,8 +548,31 @@ def _withdraw_others(row, chosen): row['pending'] = [g for g in row['pending'] if g[0].get('automatic')] -def choose(state, shot, take, *, by, reason=None, cascade=True, now): - """Choose a current take, optionally reconnecting descendants automatically.""" +def _kept_parts(row, take, pieces): + """The parts to remember with a chosen take: valid takes of this shot, the last one being the chosen take.""" + if pieces is None: + return None + problems = [] + if not isinstance(pieces, list) or not pieces: + problems.append(dict(path='/pieces', message='must list at least one part')) + else: + for index, part in enumerate(pieces): + version = next((t for t in row['takes'] if isinstance(part, dict) and t['take'] == part.get('take') + and t['state'] == 'succeeded' and not t['stale']), None) + start, end = (part.get('start'), part.get('end')) if isinstance(part, dict) else (None, None) + if version is None or type(start) not in (int, float) or type(end) not in (int, float) or not 0 <= start < end: + problems.append(dict(path='/pieces/%d' % index, message='needs a succeeded take of this shot and 0 <= start < end')) + if not problems and pieces[-1]['take'] != take: + problems.append(dict(path='/pieces/%d' % (len(pieces) - 1), message='the last part must come from the chosen take')) + if problems: + _invalid(problems) + return [dict(take=p['take'], start=float(p['start']), end=float(p['end'])) for p in pieces] + + +def choose(state, shot, take, *, by, reason=None, cascade=True, pieces=None, now): + """Choose a current take, optionally reconnecting descendants automatically. + + `pieces` is [{take, start, end}] to join from several takes of this shot; the last one comes from `take`.""" _editable(state, by) _optional_text(reason, '/reason') draft = deepcopy(state) @@ -560,13 +584,21 @@ def choose(state, shot, take, *, by, reason=None, cascade=True, now): raise ProductionError('take_not_ready', 'The take is stale or has not succeeded.', details=dict(shot=shot, take=take, state=version['state'], stale=version['stale'], why='stale' if version['stale'] else 'not_succeeded')) + kept = _kept_parts(row, take, pieces) if row['chosen'] == take: if by == 'user' and row['chosen_by'] != 'user': row['chosen_by'] = by + row['pieces'] = kept + _decision(draft, now, by, 'chosen', shot, take, reason) + _commit(state, draft, now) + elif pieces is not None and kept != row.get('pieces'): + _guard(row, by) + row['pieces'] = kept _decision(draft, now, by, 'chosen', shot, take, reason) _commit(state, draft, now) return row['chosen'], row['chosen_by'] = take, by + row['pieces'] = kept _withdraw_others(row, take) _refresh(draft) if by != 'user': @@ -653,6 +685,13 @@ def _pieces(state, pieces): return result or None +def _kept_standing(shot): + rows = shot.get('pieces') + chosen = shot.get('chosen') + return bool(rows) and rows[-1]['take'] == chosen and all( + any(t['take'] == r['take'] and t['state'] == 'succeeded' and not t['stale'] for t in shot['takes']) for r in rows) + + def finish(state, *, by, transition=None, pieces=None, now): """Snapshot the chosen refs for a new join, retaining every previous output. @@ -667,7 +706,13 @@ def finish(state, *, by, transition=None, pieces=None, now): _invalid([dict(path='/transition', message='must be "cut" or "crossfade"')]) refs = [_chosen(shot)['job'] for shot in state['shots']] # A crossfade overlaps every neighbouring clip, which would dissolve the cuts inside a shot too. - parts = _pieces(state, pieces) if transition == 'cut' else None + # Parts kept with a chosen take stand while their takes do; parts given now replace them for that shot. + if by != 'user': + owned = [shot['id'] for shot in state['shots'] if shot['id'] in (pieces or {}) and shot['chosen_by'] == 'user'] + if owned: + raise ProductionError('chosen_by_user', 'This shot was chosen by the user.', details={'shots': owned}) + kept = {shot['id']: shot['pieces'] for shot in state['shots'] if _kept_standing(shot)} + parts = _pieces(state, {**kept, **(pieces or {})}) if transition == 'cut' else None current = next((j for j in state['joins'] if j['ref'] == state['current_join']), None) if current and current['state'] in ACTIVE and current['takes'] == refs and current['transition'] == transition and current.get('pieces') == parts: return @@ -774,6 +819,7 @@ def view(state): result['shots'] = [] for shot in state['shots']: row = {key: deepcopy(shot[key]) for key in ('id', 'index', 'continues', 'state', 'chosen', 'chosen_by')} + row['pieces'] = deepcopy(shot.get('pieces')) # The cast of the latest submitted take: cards are read when a take is submitted. latest = next((t for t in reversed(shot['takes']) if 'cast_reference' in t), {}) row.update(cast_reference=latest.get('cast_reference'), cast_omitted=deepcopy(latest.get('cast_omitted', [])), diff --git a/freevideo_engine/agent/productions.py b/freevideo_engine/agent/productions.py index 32d3329d..61cd3284 100644 --- a/freevideo_engine/agent/productions.py +++ b/freevideo_engine/agent/productions.py @@ -1033,7 +1033,7 @@ def _execute(self, identity, action): clips = [] for r in item['takes']: rows = parts.get(shots.get(r)) - clips += ([dict(from_job=children[x['job']]['id'], start=x['start'], end=x['end']) for x in rows] + clips += ([dict(from_job=children[x['job']]['id'], **_on_frames(x['start'], x['end'])) for x in rows] if rows else [children[r]['id']]) item = dict(kind='assemble', clips=clips, transition=item['transition'], transition_seconds=record['state']['screenplay'].get('transition_seconds', .5)) @@ -1163,7 +1163,10 @@ def segments(self, identity, shot): if not library or not (qa or {}).get('ok', False): continue path = self.jobs.port.library_video(library) - cuts = cut_pick.detect_cuts(path, [t for t in expected if t > 0]) + wanted = [t for t in expected if t > 0] + if not wanted: + continue # no numbered cuts in the prompt: this take is only ever used whole + cuts = cut_pick.detect_cuts(path, wanted) if None in cuts: continue # A cut the prompt asks for is not in this take: its parts cannot be matched to the others. total = float(seconds) if seconds else None @@ -1174,10 +1177,40 @@ def segments(self, identity, shot): result.append(dict(take=number, path=str(path), cuts=cuts, seconds=total, qa=checks)) return result + def segments_view(self, identity, shot): + """What an agent needs to take the best part of every cut from different takes: for each usable take its + cuts, length and the check of each part, and how every join between two takes would sound at each cut. + Parts and joins use take numbers; finish takes {shot: [{take, start, end}]} (or choose takes pieces).""" + from . import cut_pick + rows = self.segments(identity, shot) + takes = [dict(take=r['take'], cuts=r['cuts'], seconds=r['seconds'], parts_ok=r['qa']) for r in rows] + joins = [] + with self.jobs.cpu: + for a in rows: + for b in rows: + if a['take'] == b['take'] or len(a['cuts']) != len(b['cuts']): + continue + for k, (end, start) in enumerate(zip(a['cuts'], b['cuts'])): + try: + heard = cut_pick.seam(a['path'], end, b['path'], start) + except Exception: # noqa: BLE001 - an unreadable sound is no join to recommend + continue + joins.append(dict(cut=k + 1, out_of=a['take'], into=b['take'], penalty=heard['penalty'], + in_speech=heard['in_speech'], level_step_db=heard['level_db'])) + return dict(shot=shot, takes=takes, joins=joins, + rule='A join is not allowed inside speech (in_speech) and a part with parts_ok false is not used.') + def adapter(self): return DirectorAdapter(self) +def _on_frames(start, end, fps=24): + """A cut time rounded by a caller (2.04 for 2.0416..) would leave one frame of the other scene at the join: + move both ends to the nearest output frame. The stored values stay as given.""" + a, b = round(start * fps) / fps, round(end * fps) / fps + return dict(start=a, end=b) if b > a else dict(start=start, end=end) + + class DirectorAdapter: """Synchronous in-process operations for builtin_director.run. diff --git a/freevideo_engine/agent/routes.py b/freevideo_engine/agent/routes.py index 9810b2f7..bdfc2b8e 100644 --- a/freevideo_engine/agent/routes.py +++ b/freevideo_engine/agent/routes.py @@ -2153,6 +2153,8 @@ async def handler(request): if operation == 'get': # Long waits are asynchronous: they hold no worker thread, so user controls never queue behind them. return reply(await productions.get_async(identity, **request[query_key])) + if operation == 'segments': + return reply(await asyncio.to_thread(productions.segments_view, identity, shot)) if operation == 'sheet': value = await asyncio.to_thread(productions.sheet, identity, shot, **request[query_key]) def picture(data): diff --git a/freevideo_engine/agent/schema.py b/freevideo_engine/agent/schema.py index e272e1b5..b93dbb12 100644 --- a/freevideo_engine/agent/schema.py +++ b/freevideo_engine/agent/schema.py @@ -651,6 +651,7 @@ def route(summary, body=None, query=None, **kw): 'list': ('GET', '/drama', 'List films; status=active returns unfinished summaries'), 'get': ('GET', '/drama/{id}', 'Read todo and revision; use since and wait=25. If paused, stop acting and long-wait until the user continues'), 'sheet': ('GET', '/drama/{id}/shots/{shot}/sheet', 'After takes succeed, review their timed images and the previous chosen last frame'), + 'segments': ('GET', '/drama/{id}/shots/{shot}/segments', 'When a shot has several takes with the same numbered cuts, read each take\'s cuts and part checks and how takes join at each cut; then choose with pieces to take the best part of every cut, passing the cut times back exactly as returned'), 'choose': ('POST', '/drama/{id}/shots/{shot}/choose', 'After reviewing the sheet, choose a take; only then can the next continuation shoot'), 'reshoot': ('POST', '/drama/{id}/shots/{shot}/reshoot', 'Request corrected takes, then review the sheet and choose again'), 'finish': ('POST', '/drama/{id}/finish', 'After every shot is chosen, accept assembly; get the film until its output is ready'), @@ -668,6 +669,16 @@ def route(summary, body=None, query=None, **kw): 'continue: true to carry on from the previous shot}]; also title, style, aspect, quality; a whole-film score is not written here but imported onto a sound track.')} +PIECES = dict(type='array', minItems=1, maxItems=24, description=( + 'Parts of this shot\'s takes to join in order instead of one whole take: [{take, start, end}] in seconds, the last ' + 'part from the chosen take. Read segments first; cut where the takes cut, never inside speech.'), + items=dict(type='object', additionalProperties=False, required=['take', 'start', 'end'], + properties=dict(take=dict(type='integer', minimum=1), start=dict(type='number', minimum=0), + end=dict(type='number', exclusiveMinimum=0)))) +FINISH_PIECES = dict(type='object', description='Parts for some shots: {shot id: [{take, start, end}]}, replacing the parts chosen with the take.', + additionalProperties=PIECES) + + def drama_schema(name): if name in ('plan', 'start'): return obj(dict(screenplay=SCREENPLAY, takes_per_shot=number(1, 8, integer=True, default=1), @@ -678,14 +689,16 @@ def drama_schema(name): return obj(dict(since=number(1, integer=True), wait=WAIT)) if name == 'sheet': return obj(dict(frames=number(2, 24, integer=True, description='Frames per take; default one per second of the take (6 to 24)'))) + if name == 'segments': + return obj({}) if name == 'choose': - return obj(dict(take=number(1, integer=True), reason=string(20000), cascade={'type': 'boolean'}, ref=REF), ('take',)) + return obj(dict(take=number(1, integer=True), reason=string(20000), cascade={'type': 'boolean'}, pieces=PIECES, ref=REF), ('take',)) if name == 'reshoot': changes = {key: dict(type='string', maxLength=20000) for key in ('action', 'camera', 'framing', 'prompt')} changes.update(seconds=number(1.625, 60), lines=dict(type='array', items={'type': 'object'})) return obj(dict(note=string(20000), changes=obj(changes), takes=number(1, 8, integer=True, default=1), reason=string(20000), ref=REF)) - return obj(dict(ref=REF, **({'transition': enum(('cut', 'crossfade'))} if name == 'finish' else {}))) + return obj(dict(ref=REF, **({'transition': enum(('cut', 'crossfade')), 'pieces': FINISH_PIECES} if name == 'finish' else {}))) CAST_OPERATIONS = { From 6ec1dece63d42062301025f8d38fb1242cf9ad85 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 7 Nov 2026 02:24:19 +0000 Subject: [PATCH 8/9] Editor: audio track names fit their head; text and subtitles can be added after separating sound --- freevideo_engine/editor_text.py | 2 +- web/editor_audio.css | 5 +++-- web/editor_text_model.js | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/freevideo_engine/editor_text.py b/freevideo_engine/editor_text.py index a38a0c7c..e6ed3af8 100644 --- a/freevideo_engine/editor_text.py +++ b/freevideo_engine/editor_text.py @@ -53,7 +53,7 @@ def clean_style(raw, kind='text'): def validate_text_project(raw, validate_media): - _check(isinstance(raw.get('tracks'), list) and len(raw['tracks']) <= 5) + _check(isinstance(raw.get('tracks'), list) and len(raw['tracks']) <= 9) media = {k:v for k,v in raw.items() if k != 'subtitle_style'} media['tracks'] = [t for t in raw['tracks'] if not is_text_track(t)] result = validate_media(media) diff --git a/web/editor_audio.css b/web/editor_audio.css index f92cf08b..bc7c5170 100644 --- a/web/editor_audio.css +++ b/web/editor_audio.css @@ -1,11 +1,12 @@ .fv-ed-audio-panel{box-sizing:border-box;width:100%;color:var(--fv-ink);font:13px/1.4 var(--fv-font,system-ui)} .fv-ed-audio-form,.fv-ed-audio-cross{display:flex;flex-direction:column;gap:12px} .fv-ed-audio-panel [hidden]{display:none!important} -.fv-ed-track-view .fv-ed-track-head{justify-content:space-between;gap:4px;padding:0 6px} +.fv-ed-track-view .fv-ed-track-head{justify-content:space-between;gap:2px;padding:0 4px} .fv-ed-track-head>svg{display:none} .fv-ed-track-head>span:not(.fv-ed-audio-track-controls){white-space:nowrap;min-width:0;overflow:hidden;text-overflow:ellipsis} .fv-ed-audio-track-controls{display:flex;gap:1px;flex:none} -.fv-ed-track-view .fv-ed-audio-track-switch{display:grid;place-items:center;padding:2px;width:24px;height:28px;border:0;border-radius:4px;background:transparent;opacity:.5;cursor:pointer} +.fv-ed-track-view .fv-ed-audio-track-switch{display:grid;place-items:center;padding:2px;width:22px;height:28px;border:0;border-radius:4px;background:transparent;opacity:.5;cursor:pointer} +@media (max-width:480px){.fv-ed-track-view .fv-ed-audio-track-switch{width:24px}} .fv-ed-audio-track-switch:hover,.fv-ed-audio-track-switch[aria-pressed=true]{opacity:1!important} .fv-ed-audio-track-switch[aria-pressed=true]{color:var(--fv-accent)!important} .fv-ed-track-row[data-muted=true] .fv-ed-clip{opacity:.45} diff --git a/web/editor_text_model.js b/web/editor_text_model.js index 790e1090..1ed4fb63 100644 --- a/web/editor_text_model.js +++ b/web/editor_text_model.js @@ -34,7 +34,7 @@ export function cleanStyle(raw = {}, kind = 'text') { } const freeze = value => { if (value && typeof value === 'object') { Object.values(value).forEach(freeze); Object.freeze(value); } return value; }; export function readTextProject(raw, parseMedia) { - check(Array.isArray(raw.tracks) && raw.tracks.length <= 5, 'track'); + check(Array.isArray(raw.tracks) && raw.tracks.length <= 9, 'track'); const media = {...raw, tracks: raw.tracks.filter(t => !isTextTrack(t))}; delete media.subtitle_style; const result = structuredClone(parseMedia(media)), ids = new Set(result.tracks.flatMap(t => t.clips.map(c => c.id))), seen = new Set(); for (const track of raw.tracks.filter(isTextTrack)) { From f73f443c2bae6b248f6efa9c1e7218d81020d3c1 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 7 Nov 2026 04:58:44 +0000 Subject: [PATCH 9/9] Release 0.4.35: update notes --- freevideo_engine/release_notes.json | 98 +++++++++++++++-------------- 1 file changed, 50 insertions(+), 48 deletions(-) diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index 9793e96a..ee67100a 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -1,41 +1,64 @@ { "schema": 1, - "product_version": "0.4.34", + "product_version": "0.4.35", "en": { - "summary": "Agent can finish the sound of a whole editor project in one job, a line of a clip can be respoken in the original voice, and the Edit tab can save the current frame as a picture.", + "summary": "External Agents can join the best part of every cut from different takes, the record button stays in place, and text and subtitles can be added after the sound has been separated.", "changes": [ - "Agent: a new job finishes the sound of an editor project in one step. It separates each clip's sound, removes sustained music that came with the clips while keeping effects such as gunshots and crowds, lays one continuous music track across the cut, evens out loudness and fills silent gaps with room tone.", - "Edit only some of the lines in a clip: those lines are respoken in the original speaker's voice at the original timing, and the rest stay as they were. Dubbed lines keep the original pace and loudness. The dubbing model (about 4.9 GB) is downloaded on first use.", - "A reference video marked as voice only is now read as an audio reference: only its sound track is used and no picture is encoded. A voice reference used across several shots is no longer encoded again for each one.", - "Agent: take checks listen for the scripted lines (Chinese and English), notice a single odd frame at the start or end and a picture that stops before the take ends, and do not pick such takes. The Agent skill text also submits storyboards as one batch and resumes a retryable failure once.", - "Short dramas: long shots submitted right after startup are no longer refused while this computer's generation limit is still being worked out.", - "The Edit tab can save the frame under the playhead as a picture. It is drawn as the export would draw it and kept under Edit image > Generate with AI > History.", - "Continued parts in the editor can now use Preview first, and Run second pass replaces the preview in place.", - "The preview shown while a video is generated plays as a loop of up to sixteen frames instead of a still sheet of four, and no longer flashes black when its stage changes size. Recent videos and Creations fold videos with the same prompt together only when they were made within two hours of each other from the same references.", - "Reference video encoding now shows progress, and the time estimate counts the reference video from the start.", - "When a request is refused for lack of GPU memory, the suggested size is now the largest smaller one that fits, instead of one of only two fixed smaller sizes.", - "Reference-video frames are removed after encoding and stopped requests free their leftovers, so a generation with reference videos no longer leaves over 1 GB per reference in its folder. Changing the prepared model also removes the online LoRA folders that pinned the old model, and unused ones are cleaned up automatically.", - "Launcher and Settings: the Restart ComfyUI step and the wrong-folder message no longer offer to report a problem, the wrong-folder message points to Install ComfyUI when none is found nearby, and the placement test button is disabled while an installation check or installation is running." + "External Agents: when the takes of a shot have the same cuts, an Agent can read each take's cuts, per-part checks and how the takes join at every cut, then join the best part of every cut from different takes. The choice is kept with the film.", + "External Agents: when a voice is changed with a script, a sentence split by a pause is said once instead of twice, and numbers, hyphenated words and initials stay whole.", + "Editor: the record button stays in the toolbar and turns grey when the selection cannot take a recording, so the icons after it no longer move.", + "Editor: audio track names fit in the track head, and text and subtitles can be added after the sound has been separated.", + "Tutorial and feature tour: new cards and chapters for Describe each part and Reuse settings. The example button reads \"Use example\" in both." ] }, "zh": { - "summary": "Agent 可以一步完成整个剪辑工程的声音;可以只改一段里的几句台词,用原说话人的声音重说;编辑页可以把当前帧存成图片。", + "summary": "外部 Agent 可从不同版本中选取每个切镜最好的一段并拼接,录音按钮位置固定,声音分离后可以添加文字和字幕。", "changes": [ - "Agent:新增一个任务,一步完成剪辑工程的全片声音:分离各段音轨,去掉持续的旧配乐(枪声、人群声等效果声保留),铺一条贯穿全片的配乐,统一全片响度,并在静音处补上环境声。", - "可以只改其中几句台词:改过的句子用原说话人的声音重说,时间位置不变,其余原声不动。配音的语速与响度保持与原句一致。配音模型约 4.9 GB,第一次使用时下载。", - "标为只用声音的参考视频,现在按音频参考读取:只取其声音,不再编码画面。同一段声音参考用于多个镜头时,不再重复编码。", - "Agent:镜头质检会听出每条候选是否说出了剧本台词(中英文均可),并发现开头或结尾单帧闪烁和画面提前停住,有这些问题的候选不会被选用。Agent 技能文本也改为把分镜作为一批提交,可重试的失败只重试一次。", - "短剧:在服务刚启动时提交长镜头,不再因本机的生成能力尚未算完而被拒绝。", - "编辑页可以把播放头所在的一帧存成图片,画面与导出一致,图片放在“编辑图片 > AI 生成 > 历史”里。", - "剪辑页的续写段现在可以使用“先出预览”,看过后用“继续二采”原位生成正式版。", - "生成中的预览改为循环播放最多 16 帧,取代原来 4 帧的静态拼图,画面尺寸变化时也不再闪黑。最近视频和作品库中,提示词相同的视频只有在相隔不超过两小时、且参考内容相同时才折叠在一起。", - "参考视频编码现在会显示进度,预计时间从一开始就把参考视频算进去。", - "因显存不足而拒绝请求时,建议的尺寸改为能放行的最大一档,不再只在两个固定的较小尺寸里选。", - "视频参考的帧文件在编码后即删除,取消或失败的请求也会释放残留文件,带视频参考的生成不再在文件夹里留下每个参考超过 1 GB 的文件。更换模型后会一并清理占住旧模型的在线 LoRA 文件夹,不再使用的也会自动清理。", - "启动器和设置:“需要重启 ComfyUI”这一步和“这不是 ComfyUI 文件夹”的提示不再显示“提交问题”;后者在附近找不到 ComfyUI 时会提示选择“帮我安装 ComfyUI”;安装检查或安装进行时,放置测试按钮置灰。" + "外部 Agent:同一镜头的各版本切镜相同时,Agent 可读取每一版的切镜、各段检查结果和各切镜处的衔接情况,从不同版本中为每个切镜选取最好的一段并拼接;所选片段随影片保存。", + "外部 Agent:传入台词换人声时,被停顿切开的一句话只说一次;数字、连字符词和缩写保持完整。", + "剪辑:录音按钮固定在工具栏中,选区不能录音时变灰,其后的图标不再移动。", + "剪辑:声音轨名称在轨头内完整显示,声音分离后可以添加文字和字幕。", + "教程和功能导览:新增“逐段描述”和“复用设置”的卡片和章节;示例按钮统一为“填入示例”。" ] }, "history": [ + { + "product_version": "0.4.34", + "en": { + "summary": "Agent can finish the sound of a whole editor project in one job, a line of a clip can be respoken in the original voice, and the Edit tab can save the current frame as a picture.", + "changes": [ + "Agent: a new job finishes the sound of an editor project in one step. It separates each clip's sound, removes sustained music that came with the clips while keeping effects such as gunshots and crowds, lays one continuous music track across the cut, evens out loudness and fills silent gaps with room tone.", + "Edit only some of the lines in a clip: those lines are respoken in the original speaker's voice at the original timing, and the rest stay as they were. Dubbed lines keep the original pace and loudness. The dubbing model (about 4.9 GB) is downloaded on first use.", + "A reference video marked as voice only is now read as an audio reference: only its sound track is used and no picture is encoded. A voice reference used across several shots is no longer encoded again for each one.", + "Agent: take checks listen for the scripted lines (Chinese and English), notice a single odd frame at the start or end and a picture that stops before the take ends, and do not pick such takes. The Agent skill text also submits storyboards as one batch and resumes a retryable failure once.", + "Short dramas: long shots submitted right after startup are no longer refused while this computer's generation limit is still being worked out.", + "The Edit tab can save the frame under the playhead as a picture. It is drawn as the export would draw it and kept under Edit image > Generate with AI > History.", + "Continued parts in the editor can now use Preview first, and Run second pass replaces the preview in place.", + "The preview shown while a video is generated plays as a loop of up to sixteen frames instead of a still sheet of four, and no longer flashes black when its stage changes size. Recent videos and Creations fold videos with the same prompt together only when they were made within two hours of each other from the same references.", + "Reference video encoding now shows progress, and the time estimate counts the reference video from the start.", + "When a request is refused for lack of GPU memory, the suggested size is now the largest smaller one that fits, instead of one of only two fixed smaller sizes.", + "Reference-video frames are removed after encoding and stopped requests free their leftovers, so a generation with reference videos no longer leaves over 1 GB per reference in its folder. Changing the prepared model also removes the online LoRA folders that pinned the old model, and unused ones are cleaned up automatically.", + "Launcher and Settings: the Restart ComfyUI step and the wrong-folder message no longer offer to report a problem, the wrong-folder message points to Install ComfyUI when none is found nearby, and the placement test button is disabled while an installation check or installation is running." + ] + }, + "zh": { + "summary": "Agent 可以一步完成整个剪辑工程的声音;可以只改一段里的几句台词,用原说话人的声音重说;编辑页可以把当前帧存成图片。", + "changes": [ + "Agent:新增一个任务,一步完成剪辑工程的全片声音:分离各段音轨,去掉持续的旧配乐(枪声、人群声等效果声保留),铺一条贯穿全片的配乐,统一全片响度,并在静音处补上环境声。", + "可以只改其中几句台词:改过的句子用原说话人的声音重说,时间位置不变,其余原声不动。配音的语速与响度保持与原句一致。配音模型约 4.9 GB,第一次使用时下载。", + "标为只用声音的参考视频,现在按音频参考读取:只取其声音,不再编码画面。同一段声音参考用于多个镜头时,不再重复编码。", + "Agent:镜头质检会听出每条候选是否说出了剧本台词(中英文均可),并发现开头或结尾单帧闪烁和画面提前停住,有这些问题的候选不会被选用。Agent 技能文本也改为把分镜作为一批提交,可重试的失败只重试一次。", + "短剧:在服务刚启动时提交长镜头,不再因本机的生成能力尚未算完而被拒绝。", + "编辑页可以把播放头所在的一帧存成图片,画面与导出一致,图片放在“编辑图片 > AI 生成 > 历史”里。", + "剪辑页的续写段现在可以使用“先出预览”,看过后用“继续二采”原位生成正式版。", + "生成中的预览改为循环播放最多 16 帧,取代原来 4 帧的静态拼图,画面尺寸变化时也不再闪黑。最近视频和作品库中,提示词相同的视频只有在相隔不超过两小时、且参考内容相同时才折叠在一起。", + "参考视频编码现在会显示进度,预计时间从一开始就把参考视频算进去。", + "因显存不足而拒绝请求时,建议的尺寸改为能放行的最大一档,不再只在两个固定的较小尺寸里选。", + "视频参考的帧文件在编码后即删除,取消或失败的请求也会释放残留文件,带视频参考的生成不再在文件夹里留下每个参考超过 1 GB 的文件。更换模型后会一并清理占住旧模型的在线 LoRA 文件夹,不再使用的也会自动清理。", + "启动器和设置:“需要重启 ComfyUI”这一步和“这不是 ComfyUI 文件夹”的提示不再显示“提交问题”;后者在附近找不到 ComfyUI 时会提示选择“帮我安装 ComfyUI”;安装检查或安装进行时,放置测试按钮置灰。" + ] + } + }, { "product_version": "0.4.33", "en": { @@ -390,27 +413,6 @@ "剪辑视图:剪辑里规划的每一段现在都可以单独写描述,接着生成的一段会用为它写的描述。改写后的提示词填回时,@人物 高亮立即刷新。" ] } - }, - { - "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 自己占用多少。" - ] - } } ] }