diff --git a/api/main.py b/api/main.py index 7318048..76f7481 100644 --- a/api/main.py +++ b/api/main.py @@ -5671,7 +5671,7 @@ class RefineStoryboardReq(BaseModel): class BatchGenerateStoryboardReq(BaseModel): count_per_row: int = 4 - concurrency: int = 4 + concurrency: int = 1 model: str = "" size: str = "720x1280" @@ -6242,7 +6242,7 @@ def _batch_generate_worker(job_id: str, req: BatchGenerateStoryboardReq) -> None count = max(1, min(12, int(req.count_per_row or 4))) concurrency = max(1, min(8, int(req.concurrency or 4))) frames = list(job.frames) - update(job, message=f"整片视频候选生成已启动 · 0/{len(frames)} 条", error="") + update(job, message=f"整片视频候选排队生成已启动 · 0/{len(frames)} 条", error="") done = 0 def submit_one(frame: KeyFrame) -> None: @@ -6297,7 +6297,7 @@ def batch_generate_all_storyboard(job_id: str, req: BatchGenerateStoryboardReq) if not job.frames: raise HTTPException(400, "no frames to generate") threading.Thread(target=_batch_generate_worker, args=(job_id, req), daemon=True).start() - update(job, message=f"整片视频候选生成已启动 · {len(job.frames)} 条分镜 · 每条 {max(1, min(12, int(req.count_per_row or 4)))} 个候选") + update(job, message=f"整片视频候选排队生成已启动 · {len(job.frames)} 条分镜 · 每条 {max(1, min(12, int(req.count_per_row or 4)))} 个候选") return job diff --git a/docs/source-analysis.html b/docs/source-analysis.html index 2c405e8..a8156db 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -569,7 +569,7 @@

业务管线

-

当前产品方向已收窄为“信息流广告快速复刻”:主界面左侧是素材输入列,右侧是信息流复刻工作表。顶部固定显示 01-09 流程顺序和每一步的判定依据,编号不再是装饰文本,而是按素材任务、源视频、音频文案、抽帧、主体资产、产品资产、分镜文案、三字段规划和视频候选这些状态解锁。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动音频文案路和视频视觉路。音频文案路提取原音频文案/字幕,分析讲话人、语速节奏、背景音乐/环境声/音效,并为后续新口播和分镜文案提供时间轴;视频视觉路同步抽取参考帧,参考帧只用于人工选择主体并生成相似主体白底视图。产品图上传后独立形成产品资产包:自动识别视角、左右/上下/内外侧、结构点、比例和风险,并补缺角度。最终分镜规划按逐句时间轴把文案、相似主体资产和产品资产汇合;客户默认只看“文案 / 场景一句话 / 人物+产品+动作”三字段,一键为单条生成 4 个视频候选或整片批量生成候选。首尾帧、视觉规划、产品出现方式等细节保留在高级抽屉和后端自动展开逻辑里,不再作为客户默认闸门。

+

当前产品方向已收窄为“信息流广告快速复刻”:主界面左侧是素材输入列,右侧是信息流复刻工作表。顶部固定显示 01-09 流程顺序和每一步的判定依据,编号不再是装饰文本,而是按素材任务、源视频、音频文案、抽帧、主体资产、产品资产、分镜文案、三字段规划和视频候选这些状态解锁。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动音频文案路和视频视觉路。音频文案路提取原音频文案/字幕,分析讲话人、语速节奏、背景音乐/环境声/音效,并为后续新口播和分镜文案提供时间轴;视频视觉路同步抽取参考帧,参考帧只用于人工选择主体并生成相似主体白底视图。产品图上传后独立形成产品资产包:自动识别视角、左右/上下/内外侧、结构点、比例和风险,并补缺角度。最终分镜规划按逐句时间轴把文案、相似主体资产和产品资产汇合;每条分镜默认是左侧“文案 / 场景一句话 / 人物+产品+动作”三字段、右侧横向视频候选轨。客户可直接改中文镜像,前端会调用改写/翻译链路自动优化对应英文主值;单条和整片都可选择生成数量,整片按行排队提交。首尾帧、视觉规划、产品出现方式等细节保留在高级抽屉和后端自动展开逻辑里,不再作为客户默认闸门。

01

素材输入

有当前素材任务即通过;输入框只负责创建或切换任务。

02

源视频下载

job.video_url 存在即通过;created/downloading 视为运行中。公开视频默认不带 cookies 下载;只有 TikTok 明确要求登录态时才配置 YTDLP_COOKIES_FILE,生产容器禁止使用 YTDLP_COOKIES_FROM_BROWSER=chrome

@@ -579,7 +579,7 @@
06

产品素材池

product_refs 有记录即通过;不限量上传,后续按分镜最多挑 6 张。

07

分镜文案

逐句时间轴生成后进入分镜;新口播可单段或整片改写。

08

三字段规划

客户默认只编辑文案、场景一句话、人物+产品+动作;高级抽屉保留首尾帧和 6 字段。

-
09

视频候选

单条默认生成 4 个候选视频;候选区默认收起为状态条/迷你缩略条,展开后才显示 4-grid。

+
09

视频候选

每行右侧直接显示横向视频轨,数量可选;新候选一直向右追加,不再用 4-grid 撑高行。

@@ -594,7 +594,7 @@ web/app/globals.css全局主题变量、登录页视觉样式、信息流工作台同源品牌 token、ReactFlow 样式引用,以及本地开发态 nextjs-portal 遮挡隐藏规则。工作台在 skg-board-theme 内定义 --skg-gold-1--skg-gold-2--skg-cream--skg-bg-*--skg-text-*--skg-radius-* 和按钮阴影等变量,并新增 skg-board-brandskg-stat-cardskg-primary-actionskg-secondary-actionskg-empty-state 等样式。暗色工作台复用登录页金色聚焦、米白主按钮和弱暖光氛围;明亮模式通过 skg-board-theme--light 复用同一套结构,改成暖白底、白色 panel、黑底主 CTA 和深色文本,不另起一套界面。 web/app/page.tsx产品工作台主状态:jobs、activeJobId、生成任务状态;主渲染为全屏素材输入列 + 信息流广告复刻工作表;“开始分析”会把 job 放入并行素材分析队列,下载完成后触发 triggerTranscribe 解析音频,并触发 analyzeJob 自动抽 12 张参考帧,形成“音频文案路 + 视频视觉路”同步推进;底部吸附音频条和旧全局浮动主题按钮不再从主界面渲染,避免和工作台内的明暗模式切换重复。 web/components/ad-recreation-board.tsx信息流广告复刻工作表:顶部先展示与登录页连续的 SKG brand strip,包含 SKG 字标、“未来健康 · 营销内容工作台”和“营销内容工作台 · TK 二创”;右侧素材/任务/视频/文案统计改为米白 stat 卡片,主动作按钮统一走 skg-primary-action,次动作走 skg-secondary-action,空状态复用 AnimatedLoginCharacters。顶部由 buildWorkflowSteps 统一生成 01-09 流程顺序、状态和判定依据,WorkflowOrderBar 展示完整顺序,WorkflowStepBadge / PipelineLane / 分镜列标题共用同一套编号。左侧素材输入只负责链接/上传和任务切换,不再重复放横版原视频预览;右侧顶部用“音频文案、抽帧参考、相似主体、产品素材池”四个状态条显示后台并行进度。源视频工作区展示视频下载状态和默认折叠的文案依据。音频解析结果改成默认折叠的辅助信息,展开后同一行看讲话人/节奏/背景音;主工作区左侧放大为按 9:16 显示的竖版原视频播放器,播放器内覆盖“当前点抽帧”,按当前播放秒数手动补参考帧;右侧上方是音频波形 / 切点参考,下方左侧是参考帧池,右侧是逐句时间轴;下一行只保留“相似主体 / 主体模板”。音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点,顶部同时显示当前播放秒数、总时长和鼠标指针停点秒数。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。逐句时间轴左侧参考帧池的主入口是“自动抽帧 12 张”,一键按动作峰值目标重新抽取 12 张源视频参考帧,优先抓手势、表情变化、节奏点和镜头变化;缩略图按竖版完整比例显示不裁切并用更多列紧凑铺开,点选状态直接叠在参考帧池缩略图上,鼠标停留会通过固定浮层放大展示完整帧。“生成 10 张高清图”放在下方相似主体白底视图区,不和抽参考按钮平齐;如果用户没有勾选帧,默认把全部关键帧作为主体参考,勾选后只传已选帧;生成区可在“透明骨架 / 普通真人”之间切换,可选择桌面导入的 5 套内置形象作为创意方向,并可填写统一主体方向,例如年轻女性、更运动、更高级。关键帧和相似主体白底视图都用更小的竖版缩略图密排;白底视图只展示每个 view 的最新一张,缩略图上提供“重新生成这一张”和“删除这一张”,单张重生会用 replace_views=true 替换同一视角。前端调用 generateSubjectAssets 时按主体类型传 subject_style=transparent_humansource_actor,按需传 character_id,并使用 reconstruction_mode=similar;后端会把关键帧和内置形象视为同一个主体的创意证据,并锁定同一性别表现、年龄段、体型、材质、风格和视觉身份,同时生成全身多视角 + 肩颈正/左右近景 + 后颈肩背特写,避免整套图出现男女性别、老少年龄或样式混杂。主体生成完成后会形成 subject_consensus_brief,主体模板保存区可预览/编辑这段 brief。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传原图推荐长边 1200-2000px、短边至少 600px,但后端会统一生成最长边 1600px、JPEG 92 的 AI 工作副本,并回显尺寸、自动转换和风险标注;上传后按“套在脖子上的 U 形肩颈按摩仪”进行同一产品批量识别,左/右按佩戴者身体左右、上/下按佩戴方向,额外标注内外侧、开口方向、局部结构点、背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停通过固定浮层显示大图预览,能盖过滚动容器和分镜框架;缺视角补图失败时保留重试入口。脚本区在分镜行上方提供“作者想法”和“整片改写”,每行新口播文案可直接编辑并可单段 AI 改写,分镜时间和原内容列压缩为窄摘要列,新口播列进一步收窄,把横向空间留给画面规划和首尾帧。每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入和历史候选视频槽;画面规划区先选择镜头类型(人物/情绪、人物+产品、产品特写、场景过渡),再用人物/产品开关、首帧规划、尾帧规划和产品出现方式决定这一条到底需不需要产品图或相似主体参考。当前主流程暂停直接调用视频模型,不再提供“生成本条 · Seedance”或“一键提交全部”视频入口;行内新增“首尾帧闸门”,分别显示/生成首帧和尾帧,旧 keyframe 类型首尾帧会被忽略,只认真正的 asset 首尾帧。生成首尾帧时调用 generateSceneAsset,主体只传 subject_brief,不再传主体图;产品按端点选择最多 1-2 张硬参考图,默认正面,侧面/后颈/厚度/特写等关键词会额外补一张对应视角。关键帧只作为前置主体重构证据和行数据承载位置,不再作为后续视频首尾帧参考。视频候选槽只展示历史候选和待生成占位,按钮改为“保存本条规划 / 保存全部规划”。只有该行勾选“产品”时,首尾帧生成才会从产品素材池按端点视角策略自动挑选最多 1-2 张相关产品图;未勾选产品时不会把产品图提交给首尾帧/后续生视频模型,并走纯文字首尾帧。只有该行勾选“人物”时,才会把主体 brief 注入 prompt;否则 prompt 会明确禁止强行添加主角式透明骨架人,后端也不会再给产品特写强加透明骨架人约束。ModelTrace 会在音频解析、产品识别/补图、相似主体高清视图包、脚本改写等入口旁直接展示模型名;所有生图入口都显示并使用 gpt-image-2,没有其他图片模型 fallback;点击后用固定浮层展示模型链路、输入输出和回退逻辑。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 - AudioStoryboardPlanPanel 三字段候选生成当前分镜主路径:每行默认只显示 skg_copy_*scene_one_line_*action_one_line_* 三组中英字段,以及 AI 改写、生成 4 条视频、三字段折叠、候选折叠和高级抽屉按钮。quickPlanStoryboard 把三字段和主体 brief 展开为完整 StoryboardScenegenerateStoryboardVideo 默认提交 count=4,含义是一次创建 4 个独立视频候选任务。候选区默认不占大面积:无候选且未展开时不渲染,已有候选时只显示横向迷你缩略条,点“候选”展开后才用 4-grid 展示进度、hover 视频预览、选中、重生、删除和清空。产品素材池、批量控制、三字段、视频候选和高级区都可折叠,高级抽屉仍展示旧 6 字段、首尾帧 prompt 和首尾帧资产槽,但客户默认不用先处理首尾帧。 + AudioStoryboardPlanPanel 三字段候选生成当前分镜主路径:每行是左右双栏,左侧默认显示 skg_copy_*scene_one_line_*action_one_line_* 三组中英字段,右侧直接显示视频候选横向轨。用户改中文镜像后,字段失焦会通过 refineStoryboard 优化对应英文主值,失败时退回 translateText;英文仍是后续 prompt 主值。quickPlanStoryboard 把三字段和主体 brief 展开为完整 StoryboardScenegenerateStoryboardVideocount 可由单行数字控件选择,候选新生成后持续向右追加,不再用 4-grid 撑高每行。整片生成同样可选择每行数量,并以 concurrency=1 按行排队提交。产品素材池、批量控制、每行主体区和高级区都可折叠,高级抽屉仍展示旧 6 字段、首尾帧 prompt 和首尾帧资产槽,但客户默认不用先处理首尾帧。 web/components/resource-library/library-drawer.tsx全局资源中心浮窗:由工作台顶部“资源库”按钮打开,叠加在工作台上方但不阻塞主界面;尺寸、位置和当前 Tab 写入 localStorage["skg-resource-library-drawer"]。提示词 Tab 固定 5 列(场景描述、视频描述、主体描述、SKG 文案、产品角度),每列先显示 use_count 排名前 5 的“常用”,再按月份倒序分组;提示词节点常驻复制按钮,hover 可选英文/中文/双语复制,并调用 use 接口。素材 Tab 固定 4 列(主体、产品、场景、视频),节点不可拖动,按月份倒序硬编码排列;“应用到当前 job”只调用后端复制接口,得到普通 ImageRef(kind="asset") 后再写入产品素材池或复制 ID。浮窗顶部最近 24 小时横条混合显示提示词和素材;新建提示词、上传素材、删除前查引用、详情侧栏都在该组件内完成。 AdRecreationBoard 主题切换顶部指标区左侧有“明亮/暗色”按钮,使用 Sun / Moon 图标切换 skg-board-theme--light 类名,并把选择写入 localStorage["skg-board-theme"]。暗色仍是默认模式;明亮模式只改变工作台外观,不改变任务、素材、分镜、模型调用或接口数据。 SourceReferenceBuildPanel“相似主体 / 主体模板”当前承担主体资产生成和主体模板复用的前端入口:顶部用 radio 区分“用模板生成”和“不用模板(从源视频关键帧创新)”,源视频相似 不再作为模板卡混进网格。模板库把 GET /subject-templates 数据库模板和 GET /character-library/skg 内置形象合并成 120px 竖排卡片,选中态统一用 SKG 金色;当选择“不用模板”时模板网格会收起,避免把生成按钮和结果缩略图挤到折叠区域之外。保存为主体模板的名称、备注和按钮固定在模板区底部一行。下方“生成主体视图”独立显示模型链路,支持透明骨架/真人、全部 10 / 常用 4 / 自定义视图;同时新增“主体设定”,默认随机组合性别表现、年龄段、着装风格、地域人种、肤色、体型比例、发型和气质场景,也可切到手动指定。随机组合会在点击生成时解析成一套固定 profile 并传给后端 subject_profile,整包视图共用同一人设,不会一张男一张女或一张年轻一张银发。已有生成结果会优先显示在生成区标题下方,再显示控制项,避免用户生成后还要继续向下找图。主体缩略图放大为可单张重生、删除和 hover 放大的媒体卡;生成中会显示本次请求锁定的素材 ID 和主体设定,切换其他模块不会改变已经提交的生成目标。前端仍传 reconstruction_mode=similar,后端先用 VISION_MODEL 把关键帧/模板图转成非身份化文字 brief;如果 brief 失败,则继续用用户方向、模板文字、内置形象 brief 和结构化主体设定。最终主体图只走 gpt-image-2/images/generations 文字生图,不再把原帧或模板图作为强 image-edit 锚点。 @@ -1034,7 +1034,7 @@ ProductRefStateItem { 候选片段 - 当前分镜主路径的视频候选结果:单条默认生成 4 个候选,支持追加生成、选中最终视频、重生/删除/清空候选;整片一键后台批量提交。 + 当前分镜主路径的视频候选结果:单条可选择生成数量,候选在每行右侧横向持续追加,支持选中最终视频、重生/删除/清空候选;整片一键按每行数量排队提交。 不要要求客户先手动生成首帧/尾帧;不要把 prompt 全文塞进默认候选区,除非用户展开高级。 /storyboard/videogenerated_videosAdRecreationBoard @@ -1058,7 +1058,7 @@ ProductRefStateItem {
  • 分镜工作台 4 图槽和改造说明自动保存。
  • 音频文案轨:点击开始或提取音频后提取原文案、中文翻译、讲话人、语速节奏、背景音乐/环境声/音效;结果集中在右侧工作表展示。
  • GPT Image 生图;当前 IMAGE_MODEL 和主体 6 视图链路默认使用 gpt-image-2
  • -
  • 三字段分镜候选生成:默认行只露文案、场景一句话、人物+产品+动作;支持 AI 改写预览、单条生成 4 个视频候选、追加生成、选中候选和整片一键后台提交。
  • +
  • 三字段分镜候选生成:默认行左侧露文案、场景一句话、人物+产品+动作,右侧直接展示横向视频轨;中文镜像失焦后会自动优化英文主值;支持 AI 改写预览、单条选择数量生成、追加生成、选中候选和整片按行排队提交。
  • 全局资源中心:提示词库和素材库可从顶部“资源库”打开;提示词可复制并计数,素材应用到 job 时会复制成本 job 内普通 asset。
  • @@ -1108,6 +1108,19 @@ ProductRefStateItem {

    变更记录

    这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。

    +
    +
    +

    2026-05-19 · 分镜行改成左文字右视频轨

    + Storyboard + Video + UX +
    +
    +

    问题:三字段和视频候选上下堆叠时,每条分镜一展开就占用大量纵向空间;4-grid 候选区也让长分镜列表很难连续操作。

    +

    改动:AudioStoryboardPlanPanel 改为每条分镜左侧编辑三字段、右侧直接显示视频候选横向轨。候选生成数量可按单行选择,整片生成也可选择每行数量并按行排队。中文镜像字段失焦后会调用 AI 改写链路优化对应英文主值,失败时退回翻译。

    +

    影响:用户可以按行扫文案、按行横向看所有候选,生成新候选会持续向右追加,不再撑高分镜行;英文仍是视频 prompt 主值,中文作为团队编辑入口和镜像。

    +
    +

    2026-05-19 · 云端音频解析复制本地真实转写路径

    diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx index f0e01f3..847910e 100644 --- a/web/components/ad-recreation-board.tsx +++ b/web/components/ad-recreation-board.tsx @@ -436,6 +436,20 @@ function containsCjk(text: string) { return /[\u3400-\u9fff]/.test(text) } +type CompactStoryboardFieldKind = "copy" | "scene" | "action" + +const STORYBOARD_VIDEO_COUNT_OPTIONS = [1, 2, 4, 6, 8, 12] + +function storyboardFieldLabel(kind: CompactStoryboardFieldKind) { + if (kind === "copy") return "文案" + if (kind === "scene") return "场景一句话" + return "人物 + 产品 + 动作" +} + +function clampVideoCount(value: number) { + return Math.round(clampNumber(Number.isFinite(value) ? value : 4, 1, 12)) +} + async function ensureEnglishForModel(text: string) { const trimmed = text.trim() if (!trimmed || !containsCjk(trimmed)) return trimmed @@ -3405,6 +3419,10 @@ function AudioStoryboardPlanPanel({ const [refinePreview, setRefinePreview] = useState(null) const [panelOpen, setPanelOpen] = useState>({ product: true, batch: true }) const [rowSectionOpen, setRowSectionOpen] = useState>({}) + const [rowVideoCounts, setRowVideoCounts] = useState>({}) + const [batchVideoCount, setBatchVideoCount] = useState(4) + const [autoOptimizingField, setAutoOptimizingField] = useState(null) + const [autoOptimizedChinese, setAutoOptimizedChinese] = useState>({}) const productFileRef = useRef(null) const productPersistSeq = useRef(0) const rows = useMemo(() => buildAudioStoryboardRows(job), [job]) @@ -3442,6 +3460,10 @@ function AudioStoryboardPlanPanel({ setRefinePreview(null) setPanelOpen({ product: true, batch: true }) setRowSectionOpen({}) + setRowVideoCounts({}) + setBatchVideoCount(4) + setAutoOptimizingField(null) + setAutoOptimizedChinese({}) }, [job?.id]) const persistProductItems = async (items: ProductRefItem[]) => { @@ -3475,6 +3497,12 @@ function AudioStoryboardPlanPanel({ setPlanOverrides((prev) => ({ ...prev, [rowIndex]: { ...(prev[rowIndex] ?? {}), ...patch } })) } + const videoCountForRow = (rowIndex: number) => clampVideoCount(rowVideoCounts[rowIndex] ?? batchVideoCount) + + const patchRowVideoCount = (rowIndex: number, value: number) => { + setRowVideoCounts((prev) => ({ ...prev, [rowIndex]: clampVideoCount(value) })) + } + const savePromptToLibrary = async ( category: "scene_desc" | "video_desc" | "subject_desc" | "skg_script" | "product_angle", name: string, @@ -3996,17 +4024,18 @@ function AudioStoryboardPlanPanel({ const batchDrawAllRows = async () => { if (!job || !rows.length) return + const count = clampVideoCount(batchVideoCount) setBatchCardBusy(true) try { await saveAllStoryboardDrafts(true) const updated = await batchGenerateAll(job.id, { - count_per_row: 4, - concurrency: 4, + count_per_row: count, + concurrency: 1, model: "seedance", size: "720x1280", }) onJobUpdate?.(updated) - toast.success(`整片视频候选生成已启动:${rows.length} 条分镜 × 每条 4 个候选`) + toast.success(`整片视频候选生成已启动:${rows.length} 条分镜 × 每条 ${count} 个候选`) } catch (e) { toast.error("整片视频候选生成失败:" + (e instanceof Error ? e.message : String(e))) } finally { @@ -4033,6 +4062,84 @@ function AudioStoryboardPlanPanel({ }) } + const applyFieldRefineItems = (rowIndex: number, field: CompactStoryboardFieldKind, items: RefineStoryboardResult["items"]) => { + if (field === "copy") { + setCopyOverrides((prev) => ({ ...prev, [rowIndex]: items.skg_copy_en })) + setCopyZhOverrides((prev) => ({ ...prev, [rowIndex]: items.skg_copy_zh })) + patchRowPlan(rowIndex, { skgCopy: items.skg_copy_en, skgCopyZh: items.skg_copy_zh }) + return + } + if (field === "scene") { + patchRowPlan(rowIndex, { + sceneOneLine: items.scene_one_line_en, + sceneOneLineZh: items.scene_one_line_zh, + visualPlan: items.scene_one_line_en, + visualPlanZh: items.scene_one_line_zh, + }) + return + } + patchRowPlan(rowIndex, { + actionOneLine: items.action_one_line_en, + actionOneLineZh: items.action_one_line_zh, + subjectDescription: items.action_one_line_en, + subjectDescriptionZh: items.action_one_line_zh, + }) + } + + const patchTranslatedFieldFallback = (rowIndex: number, field: CompactStoryboardFieldKind, zhValue: string, english: string) => { + if (field === "copy") { + setCopyOverrides((prev) => ({ ...prev, [rowIndex]: english })) + setCopyZhOverrides((prev) => ({ ...prev, [rowIndex]: zhValue })) + patchRowPlan(rowIndex, { skgCopy: english, skgCopyZh: zhValue }) + return + } + if (field === "scene") { + patchRowPlan(rowIndex, { sceneOneLine: english, sceneOneLineZh: zhValue, visualPlan: english, visualPlanZh: zhValue }) + return + } + patchRowPlan(rowIndex, { actionOneLine: english, actionOneLineZh: zhValue, subjectDescription: english, subjectDescriptionZh: zhValue }) + } + + const optimizeEnglishFromChinese = async ( + row: AudioStoryboardRow, + frame: KeyFrame | null, + field: CompactStoryboardFieldKind, + zhValue: string, + ) => { + if (!job || !frame) return + const trimmedZh = zhValue.trim() + if (!trimmedZh || !containsCjk(trimmedZh)) return + const cacheKey = `${row.index}:${field}` + if (autoOptimizedChinese[cacheKey] === trimmedZh) return + const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row), skgCopyZh: copyZhForRow(row) } + const currentPlan = quickInputForRow(plannedRow, frame) + if (field === "copy") currentPlan.skg_copy_zh = trimmedZh + if (field === "scene") currentPlan.scene_one_line_zh = trimmedZh + if (field === "action") currentPlan.action_one_line_zh = trimmedZh + setAutoOptimizingField(cacheKey) + try { + const result = await refineStoryboard(job.id, frame.index, { + current_plan: currentPlan, + user_feedback: `用户刚修改了「${storyboardFieldLabel(field)}」的中文字段。请把这个中文作为该字段的最新意思,优化对应英文主字段,让它适合英文视频生成 prompt 和短视频口播/动作描述。其他两个字段只做必要的轻微润色,不要改变语义。`, + }) + applyFieldRefineItems(row.index, field, result.items) + setAutoOptimizedChinese((prev) => ({ ...prev, [cacheKey]: trimmedZh })) + if (result.error) toast.warning(`中文已更新,英文使用兜底优化:${result.error}`) + else toast.success(`${storyboardFieldLabel(field)}已按中文优化英文`) + } catch (e) { + try { + const english = await translateText(trimmedZh, "en") + patchTranslatedFieldFallback(row.index, field, trimmedZh, english) + setAutoOptimizedChinese((prev) => ({ ...prev, [cacheKey]: trimmedZh })) + toast.success(`${storyboardFieldLabel(field)}已翻译成英文`) + } catch { + toast.error("中文优化英文失败:" + (e instanceof Error ? e.message : String(e))) + } + } finally { + setAutoOptimizingField(null) + } + } + const submitRefine = async () => { if (!job || !refineDialog) return const row = rows.find((item) => item.index === refineDialog.rowIndex) @@ -4238,6 +4345,19 @@ function AudioStoryboardPlanPanel({ {scriptRewriteBusy === "all" ? : } 整片改写 + + + - +
    {fieldsOpen ? ( -
    - patchRowCopy(row.index, value)} - onChangeZh={(value) => patchRowCopyZh(row.index, value)} - onSave={() => void savePromptToLibrary("skg_script", `分镜 ${row.index + 1} 文案`, copyText, copyZhText)} - onPick={() => toast.info("从右侧资源库选用提示词后,可粘贴到当前字段。")} - /> - patchRowPlan(row.index, { sceneOneLine: value, visualPlan: value })} - onChangeZh={(value) => patchRowPlan(row.index, { sceneOneLineZh: value, visualPlanZh: value })} - onSave={() => void savePromptToLibrary("scene_desc", `分镜 ${row.index + 1} 场景一句话`, plannedRow.sceneOneLine, plannedRow.sceneOneLineZh)} - onPick={() => toast.info("从右侧资源库选用提示词后,可粘贴到当前字段。")} - /> - patchRowPlan(row.index, { actionOneLine: value, subjectDescription: value })} - onChangeZh={(value) => patchRowPlan(row.index, { actionOneLineZh: value, subjectDescriptionZh: value })} - onSave={() => void savePromptToLibrary("video_desc", `分镜 ${row.index + 1} 人物产品动作`, plannedRow.actionOneLine, plannedRow.actionOneLineZh)} - onPick={() => toast.info("从右侧资源库选用提示词后,可粘贴到当前字段。")} - /> -
    - ) : null} - - {!advancedRows.has(row.index) && (videosOpen || rowVideos.length || quickVideoBusyRow === row.index) ? ( +
    +
    + patchRowCopy(row.index, value)} + onChangeZh={(value) => patchRowCopyZh(row.index, value)} + onChineseCommit={(value) => void optimizeEnglishFromChinese(plannedRow, referenceFrame, "copy", value)} + onSave={() => void savePromptToLibrary("skg_script", `分镜 ${row.index + 1} 文案`, copyText, copyZhText)} + onPick={() => toast.info("从右侧资源库选用提示词后,可粘贴到当前字段。")} + /> + patchRowPlan(row.index, { sceneOneLine: value, visualPlan: value })} + onChangeZh={(value) => patchRowPlan(row.index, { sceneOneLineZh: value, visualPlanZh: value })} + onChineseCommit={(value) => void optimizeEnglishFromChinese(plannedRow, referenceFrame, "scene", value)} + onSave={() => void savePromptToLibrary("scene_desc", `分镜 ${row.index + 1} 场景一句话`, plannedRow.sceneOneLine, plannedRow.sceneOneLineZh)} + onPick={() => toast.info("从右侧资源库选用提示词后,可粘贴到当前字段。")} + /> + patchRowPlan(row.index, { actionOneLine: value, subjectDescription: value })} + onChangeZh={(value) => patchRowPlan(row.index, { actionOneLineZh: value, subjectDescriptionZh: value })} + onChineseCommit={(value) => void optimizeEnglishFromChinese(plannedRow, referenceFrame, "action", value)} + onSave={() => void savePromptToLibrary("video_desc", `分镜 ${row.index + 1} 人物产品动作`, plannedRow.actionOneLine, plannedRow.actionOneLineZh)} + onPick={() => toast.info("从右侧资源库选用提示词后,可粘贴到当前字段。")} + /> +
    toggleRowSection(row.index, "videos", false)} - onDraw={() => void drawVideosForRow(plannedRow, referenceFrame, 4)} - onReroll={() => void drawVideosForRow(plannedRow, referenceFrame, 4)} + count={rowVideoCount} + onCountChange={(count) => patchRowVideoCount(row.index, count)} + onDraw={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)} + onReroll={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)} onRegenerate={() => void drawVideosForRow(plannedRow, referenceFrame, 1)} onClear={() => clearVideosForRow(rowVideos)} onSelect={(videoId) => void selectVideoForRow(plannedRow, referenceFrame, videoId)} onDeleteVideo={onDeleteVideo} /> +
    ) : null} @@ -4626,9 +4774,11 @@ function AudioStoryboardPlanPanel({ expanded={videosOpen} selectedVideoId={referenceFrame?.storyboard?.selected_video_id ?? ""} busy={quickVideoBusyRow === row.index} + count={rowVideoCount} + onCountChange={(count) => patchRowVideoCount(row.index, count)} onToggleExpanded={() => toggleRowSection(row.index, "videos", false)} - onDraw={() => void drawVideosForRow(plannedRow, referenceFrame, 4)} - onReroll={() => void drawVideosForRow(plannedRow, referenceFrame, 4)} + onDraw={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)} + onReroll={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)} onRegenerate={() => void drawVideosForRow(plannedRow, referenceFrame, 1)} onClear={() => clearVideosForRow(rowVideos)} onSelect={(videoId) => void selectVideoForRow(plannedRow, referenceFrame, videoId)} @@ -4922,8 +5072,10 @@ function CompactStoryboardField({ showChinese, onChange, onChangeZh, + onChineseCommit, onSave, onPick, + optimizing = false, }: { label: string value: string @@ -4931,14 +5083,17 @@ function CompactStoryboardField({ showChinese: boolean onChange: (value: string) => void onChangeZh?: (value: string) => void + onChineseCommit?: (value: string) => void onSave?: () => void onPick?: () => void + optimizing?: boolean }) { return ( -
    +
    {label} + {optimizing ? : null}