diff --git a/docs/BOARD.md b/docs/BOARD.md index b2c18f3..cee684a 100644 --- a/docs/BOARD.md +++ b/docs/BOARD.md @@ -44,7 +44,7 @@ | 功能 | 状态 | 备注 | |---|---|---| -| 分镜 / Shot 节点(脚本→分镜→序列) | 🔵 | Shot 编辑器落地(`3ba33d3`);**首切片完成**:「用本镜备注生成图像」(shot_notes→image config 节点+narrative 边)+ FAB/空态 shot 创建入口 + addNode typeConfig 透传。**SB-5 链排序 util 落地**(`canvas/util/narrative_order.dart`——E1/E4 公共地基,同时解锁 SB-6 序列预览与 EX-1′ 导出叙事序)。**SB-3 落地**:shot 面板预期时长/预期运镜(意图口径,不按 provider 能力钳制;运镜文案抽 `util/camera_labels.dart` 双面板共享)。**SB-6 序列预览落地**(`features/storyboard`:顶栏「序列预览」→ 按叙事链把整条分镜从头播一遍;图片按时长、视频按真实进度推进,无产物镜显 notes 占位同计时)。后续:脚本解析(SB-1,**D-M4-1 待拍板**)、shot→video 直达(SB-4) | +| 分镜 / Shot 节点(脚本→分镜→序列) | 🔵 | Shot 编辑器落地(`3ba33d3`);**首切片完成**:「用本镜备注生成图像」(shot_notes→image config 节点+narrative 边)+ FAB/空态 shot 创建入口 + addNode typeConfig 透传。**SB-5 链排序 util 落地**(`canvas/util/narrative_order.dart`——E1/E4 公共地基,同时解锁 SB-6 序列预览与 EX-1′ 导出叙事序)。**SB-3 落地**:shot 面板预期时长/预期运镜(意图口径,不按 provider 能力钳制;运镜文案抽 `util/camera_labels.dart` 双面板共享)。**SB-6 序列预览落地**(`features/storyboard`:顶栏「序列预览」→ 按叙事链把整条分镜从头播一遍;图片按时长、视频按真实进度推进,无产物镜显 notes 占位同计时)。**SB-4 落地**(shot→video 直达,镜头级参数一并带过去)。**D-M4-1 拍板 A 规则拆分**(2026-08-08)→ **SB-1 落地**(`storyboard/util/script_splitter.dart` 纯规则零 LLM,无 API key 也能用)。后续:脚本导入对话框 + 批量建链(SB-2,依赖 SB-1 已解锁)——补上后「粘贴脚本→分镜链」这条入口即通 | | 视频导出 / 拼接 | 🔵 | **服务层落地**:`VideoExportService.concat`(项目相对路径进出)→ `FfmpegVideoExportService`(concat demuxer 流拷贝 `-c copy`,不转码,要求输入同编码参数)+ `FfmpegLocator`(INKFRAME_FFMPEG env → PATH 探测,命中缓存)+ 最小 `ProcessRunner` 抽象(fake 可注入);`FileResolverService.resolveInProject`(项目根边界安全校验,导出落 `exports/`)。**不打包 ffmpeg 二进制**(体积/许可评估延后):系统有 ffmpeg 才真拼接,没有 → `LocalIOError(reason=ffmpeg_not_found)`。真 ffmpeg 集成测 `@Tags(['ffmpeg'])` TEST_FFMPEG=1 门控。**UI 入口落地**:`features/export`——画布顶栏「导出视频」按钮(无 video result 时禁用+说明 tooltip)→ 导出对话框(按 position.x 升序默认全选、复选/上下移手动排序、输出名本地预校验同 `_assertPlainFileName` 规则、busy 态 indeterminate、成功 snackbar+复制绝对路径、ffmpeg_not_found 专门文案 `exportVideoFfmpegMissing`);`ExportController` 承担画布相对→项目相对路径换算(补 `canvases//` 前缀)。**EX-1′ 落地**:默认序改 narrative 链序(经 `export/util/export_order.dart` + `canvas/util/node_artifacts.dart`,无链退回 position.x)+ 行首 16:9 缩略图。后续:转码/分辨率归一(EX-2,D-M4-6 待拍板)、打包二进制评估 | | 本地素材 / 产物画廊(借鉴 InvokeAI) | 🔵 | **首切片落地**:`features/gallery`——project 维度只读聚合(result 节点 image_url/video_url + batch_results 成功 slot,`listSuccessByProject` 一次跨画布查询 + 与节点主图同路径去重,createdAt 倒序)→ 网格 UI(图片 tile 走 fileResolver + 图片 lightbox;视频 tile 图标+时长占位);入口=Studio 项目卡菜单「Gallery」,路由走 `currentGalleryProjectProvider`(同 canvasId 语义)。**视频缩略图/播放(GA-1/2/7,PR-3 #208)与筛选/搜索+存为角色(GA-3/4,PR-4)已随 Polish Wave 1 落地**;后续:拖入画布(GA-5,D-M4-2 待拍板)、删除(GA-6,D-M4-3 待拍板);视频时长已全链路打通(#177 写侧 + XM-1b 存量启动回填);fake `listSuccessByProject` join 派生列由种子行提供(契约见注释,PG 集成测兜真语义) | | 模型扩展(自定义 Provider,BYO-key) | 🔵 | **首切片落地**:`custom_providers.json`(逐条校验 + 损坏兜底)→ 协议模板 `openai-image` 派生 capabilities → `OpenAICompatibleImageProvider`(extends SyncProviderBase) → 启动期一次性注册(改 json 重启生效);key 复用 `provider.custom:.api_key`,设置页/门控/banner 零改动生效。PROVIDER-API §13 已重写为唯一方案,ADR-0009 已修订(2026-07-02)。后续:设置页编辑 UI、运行时增删(registry 变异非 invalidate)、更多协议模板 | @@ -114,7 +114,9 @@ | **SB-5 narrative 链排序 util(M4 E1/E4 公共地基;MASTERPLAN 关键路径首件)**:纯函数 `orderByNarrativeChain({nodes, edges, include})`——只认 narrative 边且两端在场(悬空边忽略);链头=参与图且入度 0,按 (dx,dy,id);显式栈 DFS 前序,同源多出边按 (sortOrder,边 id);visited **出栈时**标记(菱形汇合不重复输出、环不死循环);遍历走不到的(纯环+孤立)按 (dx,dy,id) 追加——**任何形态都产出确定性全序,输出恒为输入的一个排列**(少一节点=导出少一镜);`include` 只滤输出不打断链序(滤掉链中间节点,两侧仍保持链相对序——EX-1′ 用法)。15 例覆盖单链/多链/分叉/汇合/环/尾环/孤立/悬空边/include/全序不变量。**解锁 SB-6 序列预览 + EX-1′ 导出叙事序** | #219 | | **EX-1′ 导出 narrative 自动排序 + 行首缩略图(M4 E4;E4「完整态」三件之一收口)**:导出对话框默认序从 position.x 改为 narrative 链序。**关键判断**:video result 节点挂在 config 节点下(sourceNodeId)、**自己不在 narrative 链上**——直接把 result 集合喂给 `orderByNarrativeChain` 一条边都找不到,会静默退化成 position.x("看起来能跑,实际卡等于没做")。正解=先按链排 config/shot 节点,再经**新建的 artifacts util** 映射到各自产物。产出:①`canvas/util/node_artifacts.dart`(`resultsFor` 新→旧 + `latestResultFor`;**SB-6 只消费不再建**——卡面裁决)②`export/util/export_order.dart`(`orderVideoNodesForExport` + `exportableVideoNodes` 从 widget 迁入,消除 util→widgets 层级倒置)③`CanvasNode.createdAt`(`row_reader` 加 `optDateTime`;**取最新产物不能取列表序末位**——`listByCanvas` 主序是 z_index,由用户拖动层级决定,与生成时间无关)④行首 16:9 缩略图(GA-1 同源读 `thumbnail_url`,ME-26 按宽解码,缺失/解码失败回退图标)⑤排序移出对话框(两入口顶栏+命令面板同一条路径)。**只改顺序不改候选集**:同一镜多 take 仍全部列出、成组跟在该镜位置新→旧(收窄默认选中是产品行为变更,超出卡面,另记债)。+19 例 | #220 | | **SB-3 镜头级参数(M4 E1;解锁 SB-6 最后一张前置)**:shot 面板加「预期时长」(固定档 3/5/10/15s → `duration_ms`,与 video 面板同键同单位,SB-4 直达视频原样透传)与「预期运镜」(**列全量 CameraMovement**)。**语义分界**:shot 记的是导演意图,此刻还没选 provider——所以不按 `supportedCameras`/`supportedDurations` 钳制(video 面板在无能力表时整段隐藏,shot 面板必须照列),能不能真做到由生成时 video inspector 的现有钳制收口;面板加一行说明把这条讲给用户。运镜文案抽 `canvas/util/camera_labels.dart` 双面板共享(video inspector 改 import,行为零变化)。两处健壮性:不在档位表的历史值 / 认不出的枚举名一律当未设置,不硬塞进 `DropdownButton.value`(会命中「value 不在 items」断言崩面板);选回「未设置」**显式写 null**——`patchTypeConfig` 是 `type_config \|\| patch::jsonb` 合并,省略键会保留旧值,用户点了清不掉。ARB +4 键。+8 例 | #221 | -| **SB-6 序列预览(M4 E1;分镜线首个「打开就能看见」的成果)**:新 `features/storyboard`——`models/sequence_shot.dart` + `util/sequence_builder.dart`(纯函数,全单测)+ `widgets/sequence_preview_dialog.dart`;排序复用 SB-5、产物查找复用 EX-1′ 首建的 artifacts util(**本卡只消费,不再造第二套**——卡面裁决)。**关键设计=「折叠」**:`_generateImageFromNotes` 建出的真实拓扑是 shot──narrative──>image config──sourceNodeId──>image result,**config 节点也在链上**;照单每节点一镜会把 3 镜播成 6 条(占位/出图交替),而那是当前把图接进分镜链的**唯一**入口——不折叠等于本卡不可用。规则=节点把产物借给前面的 shot 后不再单独成镜,已借走的产物不被第二个 shot 再借(多 shot 指向同一 config 时)。**推进两套**:图片/占位走定时器(缺省 3s);视频以真实播放进度推进(position≥duration)**外加兜底定时器**——打不开文件/进度流不动/时长拿不到时不至于永远卡死在一镜(余量 +1.5s)。**media_kit 生命周期**(卡面点名泄漏高发):全对话框只 create 一个 handle、换镜靠 open 换源、只在 initState 建只在 dispose 放、**纯图片序列完全不 create**;`_epoch` 计数器防迟到回调推进已翻过的镜。入口=顶栏「序列预览」(有 narrative 边才 enable,可用性只 watch 边,拖节点不重建顶栏)。ARB +11 键。+30 例(折叠/时长优先级/handle 生命周期/推进语义/入口门控);顺带被仓库硬编码视觉值闸门抓到一处 `InkSpacing.xs / 2`,改整档而非放松守卫 | 本 PR | +| **SB-6 序列预览(M4 E1;分镜线首个「打开就能看见」的成果)**:新 `features/storyboard`——`models/sequence_shot.dart` + `util/sequence_builder.dart`(纯函数,全单测)+ `widgets/sequence_preview_dialog.dart`;排序复用 SB-5、产物查找复用 EX-1′ 首建的 artifacts util(**本卡只消费,不再造第二套**——卡面裁决)。**关键设计=「折叠」**:`_generateImageFromNotes` 建出的真实拓扑是 shot──narrative──>image config──sourceNodeId──>image result,**config 节点也在链上**;照单每节点一镜会把 3 镜播成 6 条(占位/出图交替),而那是当前把图接进分镜链的**唯一**入口——不折叠等于本卡不可用。规则=节点把产物借给前面的 shot 后不再单独成镜,已借走的产物不被第二个 shot 再借(多 shot 指向同一 config 时)。**推进两套**:图片/占位走定时器(缺省 3s);视频以真实播放进度推进(position≥duration)**外加兜底定时器**——打不开文件/进度流不动/时长拿不到时不至于永远卡死在一镜(余量 +1.5s)。**media_kit 生命周期**(卡面点名泄漏高发):全对话框只 create 一个 handle、换镜靠 open 换源、只在 initState 建只在 dispose 放、**纯图片序列完全不 create**;`_epoch` 计数器防迟到回调推进已翻过的镜。入口=顶栏「序列预览」(有 narrative 边才 enable,可用性只 watch 边,拖节点不重建顶栏)。ARB +11 键。+30 例(折叠/时长优先级/handle 生命周期/推进语义/入口门控);顺带被仓库硬编码视觉值闸门抓到一处 `InkSpacing.xs / 2`,改整档而非放松守卫 | #222 | +| **SB-4 shot→video 直达(M4 E1)**:shot 面板第二个生成入口——以本镜备注为 prompt 建 **video** config 节点 + narrative 边,并把 SB-3 的预期时长/预期运镜一并写进去(省得在 video 面板重填)。**带的是意图不是承诺**:video 面板按所选 provider 的 supportedDurations/supportedCameras 钳制,不支持的值落地即丢回未设置——本卡不做任何能力判断(此刻还没选 provider);未设置的参数**不写空键**,让 video 面板照常按 provider 取默认而非被显式 null 顶掉。两个生成入口的公共主体抽 `_generateFromNotes(type, extraConfig)`——原先只有图片一条,现在两条,复制一遍迟早发散(notifier/文案 await 前取好、节点与连线失败分开报这些约束一处都不能漏)。ARB +1 键。+5 例 | #223 | +| **SB-1 规则脚本拆分器(M4 E1;D-M4-1 拍板后首件)**:`storyboard/util/script_splitter.dart` 纯 Dart 零 Flutter 依赖——`ShotDraft{label,notes}` + `splitScript(text, strategy)`,策略 blankLine(空行分段)/perLine。**剥行首编号**是本卡最易写漏处:用户粘来的文本十有八九带 `1.`/`镜头1`/`第3镜`/`SHOT 1`/`# `,留着会污染 prompt(「1. 山径破晓」让模型去画数字);但不能剥过头——「1920 年代的街道」「3D 渲染质感」里的数字是内容,故**光秃秃的数字必须跟分隔符才算编号**,有词锚的(镜头/第N镜/shot/scene)才允许无分隔符。markdown 井号单独一趟剥(可独立出现也可叠在编号前,`### Shot 1 dawn`)。只剥**段首那一行**(「2 号机位跟拍」是内容不是第 2 镜);剥完为空的行丢弃让后续行顶上。CRLF 与单独 CR 都归一 LF;「空行」按 trim 判定(Word/网页粘贴常带空格 tab);label=段首行截 60 字而 notes 恒为全文。22 例覆盖两策略/六类编号/不误伤/退化输入/顺序稳定 | 本 PR | ## M1 补遗(审计发现的悬空项) diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index 3ec6939..aadb55d 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -262,7 +262,7 @@ lib/ │ │ └── widgets/ # StartupErrorView (full-screen error + retry + open-log-dir) │ ├── storyboard/ # Sequence preview (SB-6) — play the narrative chain end to end │ │ ├── models/ # sequence_shot.dart (what each shot shows, and for how long) -│ │ ├── util/ # sequence_builder.dart (nodes+edges → playlist; pure) +│ │ ├── util/ # sequence_builder.dart (nodes+edges → playlist) + script_splitter.dart (SB-1 rule-based, no LLM); both pure │ │ └── widgets/ # sequence_preview_dialog.dart (playback + advance only) │ └── studio/ # Project / workspace shell (home + open-canvas + first-run onboarding dialog; ON-1/ON-2) │ ├── studio_home_screen.dart diff --git a/docs/MASTERPLAN.md b/docs/MASTERPLAN.md index 0502579..c871df1 100644 --- a/docs/MASTERPLAN.md +++ b/docs/MASTERPLAN.md @@ -314,7 +314,8 @@ beta 全周期零数据事故;官网稳定;SLA 演练过一次。 | D-4 数据升级政策(=U7) ✅ **已拍→ADR-0012** | QG-4/5、SCRAM 覆盖面、**每个 schema PR**、LB-12 manifest 策略 | ~~下一个 schema 变更前~~ 2026-07-08 拍板 | | D-5 Win 安装物(=U6)/ D-6 Win 证书(=U2) | PKG-4、beta 准入 | PKG-4 实测后即拍 | | D-7 画布保真度 d4-d7 / D-8 ⌘K ✅ **已拍(2026-07-09)** | ~~CV-1~~ 已解锁 | ~~CV-1 开工前~~ 已拍板 | -| D-M4-1~8 | GA-5/6、AG-4/5、EX-2、SB-1、CH-3 | M4 对应 Wave 开工前 | +| D-M4-1 脚本拆分 ✅ **已拍 A(2026-08-08)** | ~~SB-1~~ 已解锁(SB-1 落地,SB-2 可开) | ~~SB-1 开工前~~ 已拍板 | +| D-M4-2~8(其余七项) | GA-5/6、AG-4/5、EX-2、CH-3 | M4 对应 Wave 开工前 | | D-BE-1/2 | LB-13A、LB-11/12 | 对应卡开工前 | | D-9 任务状态载体 / D-10 数据目录选址 ✅ **D-10 已拍(2026-07-09)** | 规划运维 / ~~LB-10~~ 已解锁(新增迁移卡) | 首个 M4/M5 卡完成前 / ~~LB-10 前~~ 已拍板 | @@ -343,7 +344,10 @@ beta 全周期零数据事故;官网稳定;SLA 演练过一次。 - **D-8 ⌘K 命令面板** ✅ **已拍(2026-07-09):A 做真**(首版 ≤6 动作;已随 #174 交付 PL-1)。 **功能模块系(D-M4-1~8,详见 features 明细文档;开 M4 Wave 前拍)**: -- **D-M4-1 脚本拆分**:A 规则拆分(推荐)/ B LLM / C A 先行后加 AI 按钮。 +- **D-M4-1 脚本拆分**:✅ **已拍 A 规则拆分**(2026-08-08;SB-1 落地 + `storyboard/util/script_splitter.dart`)。理由:纯规则零 LLM——没有 API key 也能用、 + 行为可断言可单测不花钱;LLM 辅助质量不可断言且成本 ≥L,留作 AG-5 chat 模板后的可选增强 + (即 C 档,不废弃只延后)。**解锁 SB-1 → SB-2**。 - **D-M4-2 画廊→画布形态**:A 菜单动作(推荐)/ B 画布内抽屉真拖拽(后续)/ C OS 拖放。 - **D-M4-3 画廊删除语义**:A 永久删除 行+文件(推荐)/ B 仅隐藏 / C 软删保文件。 - **D-M4-4 聚合器生效时机**:A 编辑+重启(推荐先做)→ B 追加运行时新增 / C 全运行时(永不)。 diff --git a/lib/features/storyboard/README.md b/lib/features/storyboard/README.md index 92d0da0..b261184 100644 --- a/lib/features/storyboard/README.md +++ b/lib/features/storyboard/README.md @@ -7,9 +7,29 @@ ``` models/sequence_shot.dart 播放清单条目(一镜放什么、放多久) util/sequence_builder.dart 画布节点+边 → 播放清单(纯函数,全单测) +util/script_splitter.dart 粘贴的文本 → 分镜草稿(纯 Dart,无 Flutter 依赖) widgets/sequence_preview_dialog.dart 只负责"放"与"推进" ``` +## 脚本拆分(SB-1) + +`splitScript(text, strategy)` 把一段粘来的文本拆成 `ShotDraft{label, notes}`。 +**纯规则,零 LLM**(D-M4-1 拍 A 档)—— 没有 API key 也能用,行为可断言、可单测、不花钱。 + +两种策略:`blankLine`(空行分段,适合有段落结构的剧本)、`perLine`(每行一镜)。 + +最容易写漏的是**剥行首编号**。用户粘来的文本十有八九带 `1.` / `镜头1` / `第3镜` / +`SHOT 1` / `# `,留着会污染 prompt("1. 山径破晓" 会让模型去画一个数字 1)。但也不能 +剥过头 —— "1920 年代的街道"、"3D 渲染质感"里的数字是内容。分界: + +- **有词锚的**(`镜头N` / `第N镜` / `SHOT N` / `Scene N`)→ 分隔符可有可无 +- **光秃秃的数字** → **必须**跟分隔符(`.` `)` `、` `:` `-`),否则不认 + +只剥**段首那一行**:段内的 "2 号机位跟拍" 是内容,不是第 2 镜。markdown 井号单独一趟剥, +因为它既可独立出现(`# 山径破晓`)也可叠在编号前(`### Shot 1 dawn`)。 + +`label` 是段首行截 60 字(展示用),`notes` 恒为全文 —— 截断不该丢内容。 + 排序复用 SB-5 的 `canvas/util/narrative_order.dart`,产物查找复用 EX-1′ 首建的 `canvas/util/node_artifacts.dart` —— 本 feature 不再造第二套(卡面裁决)。 diff --git a/lib/features/storyboard/util/script_splitter.dart b/lib/features/storyboard/util/script_splitter.dart new file mode 100644 index 0000000..cfdd617 --- /dev/null +++ b/lib/features/storyboard/util/script_splitter.dart @@ -0,0 +1,134 @@ +// SB-1 规则脚本拆分器:一段粘贴来的文本 → 一串分镜草稿。 +// +// **纯规则,零 LLM**(D-M4-1 拍 A 档)。理由:没有 API key 也能用,行为可断言、 +// 可单测、不花钱;而 LLM 辅助拆分的质量无法断言,成本 ≥L,留作日后可选增强。 +// +// 纯 Dart,不 import Flutter——拆分是文本处理,与 UI 无关。 + +/// 一条待落地的分镜草稿。SB-2 把它变成真的 shot 节点。 +class ShotDraft { + const ShotDraft({required this.label, required this.notes}); + + /// 节点标题:段首行(已剥编号),超长截断。 + final String label; + + /// 分镜备注:整段原文(已剥段首编号),保留段内换行。 + final String notes; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ShotDraft && label == other.label && notes == other.notes; + + @override + int get hashCode => Object.hash(label, notes); + + @override + String toString() => 'ShotDraft($label)'; +} + +/// 拆分策略。 +enum ScriptSplitStrategy { + /// 空行分段——一段一镜。适合有段落结构的剧本/大纲。 + blankLine, + + /// 每行一镜。适合已经一行一镜的清单。 + perLine, +} + +/// 节点标题的展示上限。超出只截 [label],[ShotDraft.notes] 永远是全文。 +const int kShotLabelMaxLength = 60; + +/// 把 [text] 按 [strategy] 拆成分镜草稿。 +/// +/// 处理顺序:统一换行 → 按策略切块 → 剥段首编号 → 丢弃空块。 +/// 结果顺序即输入顺序;空文本得到空清单。 +List splitScript( + String text, { + ScriptSplitStrategy strategy = ScriptSplitStrategy.blankLine, +}) { + // 统一换行:CRLF / 单独的 CR(老 Mac、某些编辑器)都归一成 LF, + // 否则 \r 会留在行尾污染 prompt。 + final normalized = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); + + final blocks = switch (strategy) { + ScriptSplitStrategy.blankLine => _splitByBlankLine(normalized), + ScriptSplitStrategy.perLine => normalized.split('\n'), + }; + + final out = []; + for (final block in blocks) { + final notes = _stripLeadingIndex(_tidy(block)); + if (notes.isEmpty) continue; + out.add( + ShotDraft(label: _labelOf(notes), notes: notes), + ); + } + return out; +} + +/// 按空行切块。「空行」= 只含空白的行,因为从 Word / 网页粘过来的 +/// "空行" 常常带着空格或 tab。 +List _splitByBlankLine(String text) { + final blocks = []; + final buffer = []; + for (final line in text.split('\n')) { + if (line.trim().isEmpty) { + if (buffer.isNotEmpty) { + blocks.add(buffer.join('\n')); + buffer.clear(); + } + continue; + } + buffer.add(line); + } + if (buffer.isNotEmpty) blocks.add(buffer.join('\n')); + return blocks; +} + +/// 逐行去掉行尾空白(缩进有语义可能是排版,行尾空白纯属噪音), +/// 再去掉整块首尾空白。 +String _tidy(String block) => + block.split('\n').map((l) => l.trimRight()).join('\n').trim(); + +/// markdown 标题井号。单独一趟剥——它可以独立出现(`# 山径破晓`), +/// 也可以叠在编号前面(`### Shot 1 dawn`)。 +final RegExp _mdHeading = RegExp(r'^\s*#{1,6}\s*'); + +/// 段首编号前缀。**只匹配行开头**,且要求编号后跟分隔符—— +/// 否则 "1920 年代的街道" 会被啃成 "年代的街道"。 +/// +/// 覆盖:`1.` / `1)` / `1、` / `01.`、中文「镜头N」「第N镜」、 +/// 英文 `SHOT N` / `Scene N`(大小写不敏感)。 +final RegExp _indexPrefix = RegExp( + r'^(?:' + // 镜头 1 / 第 3 镜 / SHOT 1 / Scene 2——这类有明确的词做锚, + // 分隔符可有可无。 + r'(?:镜头|第)\s*\d+\s*(?:镜)?\s*[::..、))\-—]*\s*' + r'|(?:shot|scene)\s*\d+\s*[::..、))\-—]*\s*' + // 光秃秃的数字:**必须**跟分隔符,不然分不清它是编号还是内容。 + r'|\d{1,3}\s*[::..、))\-—]+\s*' + r')', + caseSensitive: false, +); + +/// 剥掉**段首那一行**的编号;段内后续行原样保留 +/// ("2 号机位跟拍" 是内容,不是第 2 镜)。 +String _stripLeadingIndex(String block) { + if (block.isEmpty) return block; + final lines = block.split('\n'); + lines[0] = lines[0] + .replaceFirst(_mdHeading, '') + .replaceFirst(_indexPrefix, '') + .trim(); + // 首行被剥空时(整行就是个 "1.")丢掉该行,让后续行顶上来。 + if (lines[0].isEmpty) lines.removeAt(0); + return lines.join('\n').trim(); +} + +String _labelOf(String notes) { + final first = notes.split('\n').first.trim(); + return first.length <= kShotLabelMaxLength + ? first + : first.substring(0, kShotLabelMaxLength); +} diff --git a/test/features/storyboard/util/script_splitter_test.dart b/test/features/storyboard/util/script_splitter_test.dart new file mode 100644 index 0000000..32a53f7 --- /dev/null +++ b/test/features/storyboard/util/script_splitter_test.dart @@ -0,0 +1,164 @@ +// SB-1 规则脚本拆分器。 +// +// 用户从别处(剧本、大纲、聊天记录)粘一段文本过来,要变成一串分镜草稿。 +// 纯规则、零 LLM——没有 API key 也能用,这是 D-M4-1 拍 A 档的理由。 +// +// 最容易写漏的是**剥行首编号**:用户粘过来的文本十有八九带 `1.` / `镜头1` / +// `SHOT 1` / `#` 这类前缀,留着它们会污染 prompt("1. 山径破晓" 会让模型 +// 去画一个数字 1)。但也不能剥过头——"1920 年代的街道"里的数字是内容。 + +import 'package:flutter_test/flutter_test.dart'; +import 'package:inkframe/features/storyboard/util/script_splitter.dart'; + +List _notes(List d) => d.map((e) => e.notes).toList(); +List _labels(List d) => d.map((e) => e.label).toList(); + +void main() { + group('splitScript · blankLine 策略', () { + test('空行分段', () { + final out = splitScript('第一段\n还是第一段\n\n第二段'); + expect(_notes(out), ['第一段\n还是第一段', '第二段']); + }); + + test('连续多个空行只算一个分隔', () { + final out = splitScript('A\n\n\n\nB'); + expect(_notes(out), ['A', 'B']); + }); + + test('只含空白的行也算空行(用户从 Word 粘过来常见)', () { + final out = splitScript('A\n \t \nB'); + expect(_notes(out), ['A', 'B']); + }); + + test('首尾空行不产生空草稿', () { + final out = splitScript('\n\n \nA\n\n\n'); + expect(_notes(out), ['A']); + }); + + test('CRLF 与 CR 统一成 LF', () { + expect(_notes(splitScript('A\r\n\r\nB')), ['A', 'B']); + expect(_notes(splitScript('A\r\rB')), ['A', 'B']); + }); + + test('段内每行各自 trim 行尾空白,但保留换行结构', () { + final out = splitScript('第一行 \n第二行\t\n\n下一段'); + expect(out.first.notes, '第一行\n第二行'); + }); + }); + + group('splitScript · perLine 策略', () { + test('每行一镜,空行被跳过', () { + final out = splitScript( + 'A\n\nB\nC', + strategy: ScriptSplitStrategy.perLine, + ); + expect(_notes(out), ['A', 'B', 'C']); + }); + + test('perLine 下段落里的换行不再粘在一起', () { + final out = splitScript( + '山径破晓\n渡索桥', + strategy: ScriptSplitStrategy.perLine, + ); + expect(out, hasLength(2)); + }); + }); + + group('剥行首编号', () { + test('阿拉伯数字 + 常见分隔符', () { + for (final prefix in [ + '1. ', + '1) ', + '1、', + '1.', + '01. ', + '12.', + ]) { + expect( + splitScript('$prefix山径破晓').single.notes, + '山径破晓', + reason: '未剥掉前缀「$prefix」', + ); + } + }); + + test('中文「镜头N」/「第N镜」', () { + expect(splitScript('镜头1 山径破晓').single.notes, '山径破晓'); + expect(splitScript('镜头 2:渡索桥').single.notes, '渡索桥'); + expect(splitScript('第3镜 茶棚避雨').single.notes, '茶棚避雨'); + expect(splitScript('第 4 镜、竹林夜行').single.notes, '竹林夜行'); + }); + + test('英文 SHOT N / Scene N(大小写不敏感)', () { + expect(splitScript('SHOT 1 dawn ridge').single.notes, 'dawn ridge'); + expect(splitScript('shot 2: rope bridge').single.notes, 'rope bridge'); + expect(splitScript('Scene 3 - tea shed').single.notes, 'tea shed'); + }); + + test('markdown 标题井号', () { + expect(splitScript('# 山径破晓').single.notes, '山径破晓'); + expect(splitScript('### Shot 1 dawn').single.notes, 'dawn'); + }); + + test('只剥**段首那一行**的编号,段内后续行不动', () { + final out = splitScript('1. 山径破晓\n2 号机位跟拍'); + expect(out.single.notes, '山径破晓\n2 号机位跟拍'); + }); + + test('不误伤:数字是内容的一部分时不剥', () { + // 后面没有分隔符也没有空格 → 不是编号。 + expect(splitScript('1920 年代的街道').single.notes, '1920 年代的街道'); + expect(splitScript('3D 渲染质感').single.notes, '3D 渲染质感'); + }); + + test('剥完只剩空 → 整段丢弃,不产生空草稿', () { + expect(splitScript('1.\n\n真正的内容'), hasLength(1)); + expect(splitScript('1.\n\n真正的内容').single.notes, '真正的内容'); + }); + }); + + group('label', () { + test('取段首行', () { + final out = splitScript('山径破晓\n晨光初现,旅人只是山脊线上的剪影'); + expect(out.single.label, '山径破晓'); + }); + + test('超长首行截到 60 字,不留半个省略号以外的残缺', () { + final long = 'x' * 200; + final out = splitScript(long); + expect(out.single.label.length, lessThanOrEqualTo(60)); + // notes 保留全文——截断只影响展示用的 label。 + expect(out.single.notes.length, 200); + }); + + test('label 也剥编号(否则节点标题会是「1. 山径破晓」)', () { + expect(splitScript('1. 山径破晓\n正文').single.label, '山径破晓'); + }); + }); + + group('退化输入', () { + test('空文本 / 纯空白 → 空清单', () { + expect(splitScript(''), isEmpty); + expect(splitScript(' \n\t\n '), isEmpty); + expect(splitScript('\r\n\r\n'), isEmpty); + }); + + test('单行无分隔 → 一镜', () { + expect(splitScript('就一句话'), hasLength(1)); + }); + + test('结果顺序即输入顺序', () { + final out = splitScript('A\n\nB\n\nC'); + expect(_labels(out), ['A', 'B', 'C']); + }); + }); + + group('ShotDraft', () { + test('值相等', () { + const a = ShotDraft(label: 'x', notes: 'y'); + const b = ShotDraft(label: 'x', notes: 'y'); + expect(a, b); + expect(a.hashCode, b.hashCode); + }); + }); +}