From 2b28958627f7c417940d237282b2db3a62152616 Mon Sep 17 00:00:00 2001 From: Shanzoon <82895720+Shanzoon@users.noreply.github.com> Date: Fri, 17 Jul 2026 05:06:20 +0800 Subject: [PATCH 01/11] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=AE=A1=E8=A7=86?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=EF=BC=8C=E5=AE=A1=E6=9F=A5=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E5=BC=8F=EF=BC=9B=E6=B8=85=E7=90=86=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E8=BF=87=E7=A8=8B=E4=BA=A7=E7=89=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 review_server 与审视工作台前端,setup-app 回归纯初始化 Dialog - 审查数据链路保留(review-decisions.json → apply),写入方改为主代理代写 - 删除 PRODUCT.md / DESIGN.md / .impeccable(Impeccable 工具产物) - SKILL.md 审查章节改为对话式流程,要求 apply 后复述打回意见 Co-Authored-By: Claude Fable 5 --- .impeccable/design.json | 212 -------- .impeccable/live/config.json | 9 - CHANGELOG.md | 19 + DESIGN.md | 246 --------- PRODUCT.md | 39 -- README.md | 2 +- SKILL.md | 21 +- assets/review-app/app.js | 304 ------------ assets/review-app/index.html | 57 --- assets/review-app/styles.css | 126 ----- assets/setup-app/index.html | 93 +--- assets/setup-app/setup.css | 227 +-------- assets/setup-app/setup.js | 715 +-------------------------- references/project-schema.md | 4 +- references/prompt-authoring.md | 4 +- scripts/bootstrap_review_package.mjs | 4 +- scripts/review_server.mjs | 180 ------- scripts/run_project.mjs | 2 +- 18 files changed, 56 insertions(+), 2208 deletions(-) delete mode 100644 .impeccable/design.json delete mode 100644 .impeccable/live/config.json delete mode 100644 DESIGN.md delete mode 100644 PRODUCT.md delete mode 100644 assets/review-app/app.js delete mode 100644 assets/review-app/index.html delete mode 100644 assets/review-app/styles.css delete mode 100644 scripts/review_server.mjs diff --git a/.impeccable/design.json b/.impeccable/design.json deleted file mode 100644 index f6cf184..0000000 --- a/.impeccable/design.json +++ /dev/null @@ -1,212 +0,0 @@ -{ - "schemaVersion": 2, - "generatedAt": "2026-07-16T19:19:35.000Z", - "title": "Design System: 漫剧资产工作台", - "extensions": { - "colorMeta": { - "primary-brick": { - "role": "primary", - "displayName": "沉稳砖红", - "canonical": "#8E3F23", - "tonalRamp": ["#35130C", "#552014", "#71311B", "#8E3F23", "#AD5A3C", "#CC8066", "#E7B3A2", "#F8E4DD"] - }, - "ink": { - "role": "neutral", - "displayName": "温黑墨色", - "canonical": "#272320", - "tonalRamp": ["#171412", "#272320", "#4C443F", "#756E67", "#9C958F", "#C4BFBA", "#E4E1DE", "#F6F5F4"] - }, - "workspace": { - "role": "neutral", - "displayName": "暖灰工作区", - "canonical": "#F5F0E8", - "tonalRamp": ["#2D2924", "#514A42", "#756C61", "#9A8F81", "#BDB3A5", "#D8D0C4", "#EBE5DC", "#F5F0E8"] - }, - "surface": { - "role": "neutral", - "displayName": "清洁表面", - "canonical": "#FFFDF9", - "tonalRamp": ["#302D29", "#56514A", "#7D766C", "#A49C91", "#C9C2B8", "#E4DED6", "#F4F0EA", "#FFFDF9"] - }, - "success": { - "role": "secondary", - "displayName": "审核绿色", - "canonical": "#1F6A46", - "tonalRamp": ["#0B281A", "#123C28", "#195238", "#1F6A46", "#4D8969", "#7CAB91", "#B0D0BC", "#E5F2E9"] - }, - "danger": { - "role": "tertiary", - "displayName": "返工红色", - "canonical": "#9E3D31", - "tonalRamp": ["#35120F", "#571E19", "#7A2D25", "#9E3D31", "#BD6155", "#D88C82", "#EABCB6", "#FAE9E6"] - } - }, - "typographyMeta": { - "project-title": { - "displayName": "项目标题", - "purpose": "只用于项目名称和最高层创作标题。" - }, - "headline": { - "displayName": "界面标题", - "purpose": "详情章节和 Dialog 主标题。" - }, - "body": { - "displayName": "正文", - "purpose": "资产说明、提示词和状态解释。" - }, - "label": { - "displayName": "标签", - "purpose": "字段名、状态和辅助信息。" - } - }, - "shadows": [ - { - "name": "dialog-lift", - "value": "0 24px 64px rgba(39, 35, 32, 0.22)", - "purpose": "首次设置 Dialog 和阻断背景操作的确认浮层。" - }, - { - "name": "compact-popover", - "value": "0 8px 20px rgba(39, 35, 32, 0.14)", - "purpose": "小型菜单、Toast 和大图预览入口。" - } - ], - "motion": [ - { - "name": "state-fast", - "value": "160ms cubic-bezier(0.22, 1, 0.36, 1)", - "purpose": "按钮、筛选器和选中状态反馈。" - }, - { - "name": "disclosure", - "value": "220ms cubic-bezier(0.22, 1, 0.36, 1)", - "purpose": "辅助信息的展开收起。" - } - ], - "breakpoints": [ - { - "name": "laptop", - "value": "1180px" - }, - { - "name": "desktop", - "value": "1440px" - } - ] - }, - "components": [ - { - "name": "主要按钮", - "kind": "button", - "refersTo": "button-primary", - "description": "用于保存决定、确认设置和推进当前阶段。", - "html": "", - "css": ".ds-btn-primary { border: 0; border-radius: 8px; padding: 10px 16px; color: #FFFDF9; background: #8E3F23; font: 700 14px/1.45 \"PingFang SC\", system-ui, sans-serif; cursor: pointer; transition: background 160ms cubic-bezier(0.22,1,0.36,1); } .ds-btn-primary:hover { background: #71311B; } .ds-btn-primary:focus-visible { outline: 3px solid rgba(170,101,61,.24); outline-offset: 2px; }" - }, - { - "name": "次要按钮", - "kind": "button", - "refersTo": "button-secondary", - "description": "用于重新载入、取消和非主要操作。", - "html": "", - "css": ".ds-btn-secondary { border: 1px solid #DED5CA; border-radius: 8px; padding: 9px 14px; color: #5E4E43; background: #FFFDF9; font: 700 14px/1.45 \"PingFang SC\", system-ui, sans-serif; cursor: pointer; transition: border-color 160ms cubic-bezier(0.22,1,0.36,1), background 160ms cubic-bezier(0.22,1,0.36,1); } .ds-btn-secondary:hover { border-color: #AA653D; background: #F5F0E8; } .ds-btn-secondary:focus-visible { outline: 3px solid rgba(170,101,61,.2); outline-offset: 2px; }" - }, - { - "name": "搜索字段", - "kind": "input", - "refersTo": "field", - "description": "用于筛选角色、状态、场景和道具。", - "html": "", - "css": ".ds-field { display: grid; gap: 6px; color: #5E4E43; font: 600 11px/1.4 \"PingFang SC\", system-ui, sans-serif; } .ds-field input { width: 280px; border: 1px solid #DED5CA; border-radius: 8px; padding: 9px 11px; color: #272320; background: #FFFDF9; font: 400 13px/1.65 \"PingFang SC\", system-ui, sans-serif; outline: none; } .ds-field input:focus { border-color: #AA653D; box-shadow: 0 0 0 3px rgba(170,101,61,.14); }" - }, - { - "name": "选中筛选器", - "kind": "chip", - "refersTo": "chip-selected", - "description": "表示当前资产类型或筛选范围。", - "html": "", - "css": ".ds-chip-selected { border: 0; border-radius: 999px; padding: 4px 9px; color: #FFFDF9; background: #8E3F23; font: 600 11px/1.4 \"PingFang SC\", system-ui, sans-serif; cursor: pointer; } .ds-chip-selected:focus-visible { outline: 3px solid rgba(170,101,61,.2); outline-offset: 2px; }" - }, - { - "name": "工作流导航", - "kind": "nav", - "description": "显示固定生产阶段和当前进度。", - "html": "", - "css": ".ds-workflow { width: 220px; padding: 8px; background: #EBE4D9; font-family: \"PingFang SC\", system-ui, sans-serif; } .ds-stage { display: grid; width: 100%; gap: 3px; border: 0; border-radius: 8px; padding: 10px 12px; color: #5E4E43; background: transparent; text-align: left; cursor: pointer; } .ds-stage span { font-size: 13px; font-weight: 700; } .ds-stage small { color: #756E67; font-size: 11px; } .ds-stage:hover { background: rgba(255,253,249,.55); } .ds-stage.is-current { color: #272320; background: #FFFDF9; } .ds-stage.is-current span { color: #8E3F23; }" - }, - { - "name": "资产列表项", - "kind": "custom", - "description": "在左侧列表中显示资产缩略图、名称和审视状态。", - "html": "", - "css": ".ds-asset-row { display: grid; grid-template-columns: 40px 1fr auto; align-items: center; gap: 10px; width: 320px; border: 0; border-bottom: 1px solid #DED5CA; padding: 10px 12px; color: #272320; background: #FFFDF9; text-align: left; cursor: pointer; font-family: \"PingFang SC\", system-ui, sans-serif; } .ds-asset-row:hover { background: #F5F0E8; } .ds-thumb { width: 40px; height: 40px; border-radius: 6px; background: #EBE4D9; } .ds-asset-copy { display: grid; gap: 3px; } .ds-asset-copy strong { font-size: 13px; } .ds-asset-copy small { color: #756E67; font-size: 11px; } .ds-status { border-radius: 999px; padding: 3px 7px; color: #875D25; background: #F8EBCB; font-size: 10px; font-weight: 700; } .ds-asset-row:focus-visible { outline: 3px solid rgba(170,101,61,.2); outline-offset: -3px; }" - }, - { - "name": "初始化 Dialog", - "kind": "custom", - "refersTo": "dialog", - "description": "首次进入项目时承载视觉范式和角色构图选择。", - "html": "
项目初始化

建立视觉基线

选择视觉范式和默认角色构图后,工作台将继续解析剧本。
", - "css": ".ds-dialog { width: 680px; overflow: hidden; border-radius: 10px; color: #272320; background: #F5F0E8; box-shadow: 0 24px 64px rgba(39,35,32,.22); font-family: \"PingFang SC\", system-ui, sans-serif; } .ds-dialog header, .ds-dialog footer { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; background: #FFFDF9; } .ds-dialog header { border-bottom: 1px solid #DED5CA; } .ds-dialog footer { justify-content: flex-end; border-top: 1px solid #DED5CA; } .ds-dialog h2 { margin: 3px 0 0; font: 600 22px/1.2 \"Songti SC\", STSong, serif; } .ds-dialog small { color: #756E67; } .ds-dialog header > button { border: 0; color: #756E67; background: transparent; font-size: 22px; cursor: pointer; } .ds-dialog-body { min-height: 220px; padding: 24px; color: #5E4E43; } .ds-dialog-confirm { border: 0; border-radius: 8px; padding: 10px 16px; color: #FFFDF9; background: #8E3F23; font-weight: 700; cursor: pointer; } .ds-dialog-confirm:hover { background: #71311B; }" - } - ], - "narrative": { - "northStar": "安静的主美工作台", - "overview": "这套界面应像一张长期使用的数字工作桌:信息稳定地留在原位,图片、角色关系和生产状态比界面本身更醒目。暖灰和砖红保留现有创作气质,布局与控件则遵循生产工具的秩序,减少展示页式文案、虚构背景和重复容器。\n\n工作台采用连续的桌面产品结构。初始化 Dialog、工作流进度、资产列表和资产详情共享同一套导航、排版和状态语言。系统明确拒绝炫技的 AI 科幻控制台,也拒绝大量同质卡片、层层容器和重复摘要组成的 SaaS 仪表盘。", - "keyCharacteristics": [ - "暖灰工作区,砖红色只用于主要操作和当前选择。", - "项目标题可使用宋体,其余界面统一使用系统无衬线字体。", - "左侧工作流与资产列表保持紧凑,右侧详情为主要阅读和编辑区域。", - "大部分表面保持扁平,使用留白、分隔线和背景层建立层级。", - "前端展示正式状态并提交受控意图,不表现为自由实时协同编辑器。" - ], - "rules": [ - { - "name": "The One Action Color Rule", - "body": "砖红只表示主要操作、当前选择和需要注意的状态;不得把它用于标题装饰、大片背景或无意义图形。", - "section": "colors" - }, - { - "name": "The Status Pair Rule", - "body": "成功、警告和错误状态始终使用深色文字配同色浅底,不能只依赖文字颜色表达。", - "section": "colors" - }, - { - "name": "The One Serif Role Rule", - "body": "宋体只能用于项目名称或最高层创作标题。按钮、表单、导航、状态和数据禁止使用宋体。", - "section": "typography" - }, - { - "name": "The Chinese First Rule", - "body": "面向用户的界面文案以中文为主;英文只保留不可替代的模型名、格式名和短技术标识。", - "section": "typography" - }, - { - "name": "The Flat Workspace Rule", - "body": "工作区、导航、资产列表和详情面板在静止状态下不得使用投影;层级由背景色和一像素分隔线表达。", - "section": "elevation" - }, - { - "name": "The One Floating Layer Rule", - "body": "同一时刻最多有一个主要浮层。不得在 Dialog 内继续堆叠带阴影卡片。", - "section": "elevation" - } - ], - "dos": [ - "Do 使用暖灰工作区、清洁详情表面和一像素分隔线建立稳定的桌面工具结构。", - "Do 把图片、资产名称、审视状态和下一步操作放在视觉层级前面。", - "Do 使用左侧资产列表与右侧详情,让一个角色及其状态保持在同一上下文中。", - "Do 让初始化 Dialog 与主工作台共用颜色、字体、按钮和状态组件。", - "Do 让 Loading、折叠和预览组件解决明确的信息或等待问题。" - ], - "donts": [ - "Don't 制作炫技的 AI 科幻控制台、霓虹 HUD 或无功能意义的动态效果。", - "Don't 使用大量同质卡片、层层容器和重复摘要组成 SaaS 仪表盘。", - "Don't 使用横向宽度超过正常笔记本视口的大表格作为主要资产审视方式。", - "Don't 在普通容器上同时叠加一像素边框和宽而柔的装饰阴影。", - "Don't 在每个章节上方重复小号英文大写标签或编号眉题。", - "Don't 使用超过 10px 的普通容器圆角,或把所有按钮做成胶囊形。", - "Don't 让前端和 Agent 同时自由修改 canonical 项目状态。" - ] - } -} diff --git a/.impeccable/live/config.json b/.impeccable/live/config.json deleted file mode 100644 index fcf2de1..0000000 --- a/.impeccable/live/config.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "files": [ - "assets/setup-app/index.html", - "assets/review-app/index.html" - ], - "insertBefore": "", - "commentSyntax": "html", - "cspChecked": true -} diff --git a/CHANGELOG.md b/CHANGELOG.md index a1de513..0c274cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,31 @@ 后续接手此 skill 的窗口在修改前应先阅读本文件、`SKILL.md` 与相关数据契约。记录应说明行为变化、兼容性和验证方式,而不是逐行重复代码差异。 +## 2026-07-17 — 移除审视界面,审查改为对话式 + +- 移除资产审视前端与其服务:删除 `scripts/review_server.mjs`;`assets/setup-app/` 回归纯初始化 Dialog(index.html / setup.js / setup.css 全部重写为 setup 单一职责,删除三栏工作台、资产列表、检查器与相关样式);`setup_server.mjs` 移除 `/api/capabilities`。 +- 审查数据链路完整保留:`review-decisions.json`(bootstrap_review_package 初始化)→ `apply_review_decisions.mjs` → validate → plan_work_dag 不变,schema v3 不变。差别只在写入方:此前由审视界面 PATCH,现在由主代理依据用户在对话中的确认代写稀疏 patch(status / note / identityPrompt / deltaPrompt / 默认态锁定),baseRevision 乐观锁语义不变。存量项目和已锁定资产零影响。 +- 决策依据:目标用户(美术/导演/编导)在 Agent 对话里直接改提示词比切到独立界面更顺;审视界面的维护成本(预览一致性、并发端口、返工闭环)大于收益。 +- 同时清理设计过程产物:删除 `PRODUCT.md`、`DESIGN.md`、`.impeccable/`(Impeccable 工具的设计系统状态,不参与 skill 运行时)。 +- SKILL.md"审查"章节改为对话式流程,并新增硬要求:apply 之后必须向用户复述被打回资产及意见,不让 changes_requested 静默滞留(此前该闭环仅靠约定,DAG 不感知返工)。 +- 验证方式:全新项目初始化 → Dialog 随机端口正常渲染与提交 → apply_setup_decisions 固化;手写审查 patch → apply_review_decisions 正确应用。 + ## 0.1.0 — 首次公开发布 - 以 MIT 许可证发布;新增面向安装、更新、反馈和安全报告的公开仓库说明。 - 新增中性视觉范式 `cg-stylized-3d`(显示名“柔和三维动画”);仅调整预设标识与预览资源路径,不改变对应提示词种子。 - 维护方式为 Issues 与 Discussions 收集反馈,不接收外部 Pull Request。 +## 2026-07-17 — 初始化与审视合并为共享资产工作台 + +- `assets/setup-app/` 现承载初始化和资产审视共用的唯一前端代码;`setup_server.mjs` 与 `review_server.mjs` 通过 `/api/capabilities` 选择运行模式。历史目录名保留以避免无必要的资源迁移。 +- `bootstrap_review_package.mjs` 不再把 HTML、CSS 和 JavaScript 复制到每个项目的 `review/` 目录,只初始化 `review-decisions.json`。`review_server.mjs` 直接从 skill 资源提供 `/review/`,旧项目内已有的 `review/` 副本不会被读取或删除。 +- 审视界面改为“资产列表 + 图片预览 + 详情检查器”的三栏桌面工作台;移除常驻工作流侧栏,顶部阶段入口只展示只读项目进度和历史产物入口,不允许直接回退 canonical 工作流。 +- 右侧只保留两个主要信息区域:剧情层的“角色描述 / 场景说明 / 道具说明”,以及按来源分段的“最终生图提示词”。项目风格、渲染规范和默认构图只读;默认角色的 `identityPrompt` 与当前资产的 `deltaPrompt` 仍通过现有稀疏 patch 局部编辑。 +- 修复审视预览与正式建批不一致:前端最终提示词现包含 `assetPresentation.characterDefault.prompt`,并按与后端 `compilePrompt()` 相同的顺序去重拼接。未引入 `promptOverride`,schema、prompt hash、批次和 AWB 链路保持不变。 +- 图片展示优先使用项目内 `localPath`,再回退到远程 URL / backendPath,避免结果已下载但远程地址不可用时预览空白。 +- 验证:初始化模式 12 个视觉选项和 5 个构图选项可切换与提交;真实项目显示 43 个可见状态行;1600×900 与 1180×800 无页面级横向溢出;浏览器无 console error / 失败请求;前端完整提示词与后端编译结果逐字一致;隔离项目中的 identityPrompt、审视备注和 changes_requested patch 可保存并由 `apply_review_decisions.mjs` 正确应用。 + ## 2026-07-17 — 统一工作台产品与设计基线 - 新增 `PRODUCT.md`,将前端定义为覆盖初始化、解析、资产审视、生图和结果状态的统一桌面工作台;初始化选择是工作台首次启动的 Dialog。 diff --git a/DESIGN.md b/DESIGN.md deleted file mode 100644 index 4edc614..0000000 --- a/DESIGN.md +++ /dev/null @@ -1,246 +0,0 @@ ---- -name: "漫剧资产工作台" -description: "专业、可靠、克制的 AI 漫剧资产生产界面" -colors: - primary-brick: "#8E3F23" - primary-brick-hover: "#71311B" - ink: "#272320" - ink-soft: "#5E4E43" - muted: "#756E67" - workspace: "#F5F0E8" - surface: "#FFFDF9" - surface-muted: "#EBE4D9" - divider: "#DED5CA" - focus: "#AA653D" - success: "#1F6A46" - success-soft: "#DCEFE2" - warning: "#875D25" - warning-soft: "#F8EBCB" - danger: "#9E3D31" - danger-soft: "#F6DFD7" -typography: - project-title: - fontFamily: "\"Songti SC\", STSong, Georgia, serif" - fontSize: "26px" - fontWeight: 600 - lineHeight: 1.2 - letterSpacing: "-0.02em" - headline: - fontFamily: "\"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", system-ui, sans-serif" - fontSize: "18px" - fontWeight: 700 - lineHeight: 1.35 - title: - fontFamily: "\"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", system-ui, sans-serif" - fontSize: "14px" - fontWeight: 700 - lineHeight: 1.45 - body: - fontFamily: "\"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", system-ui, sans-serif" - fontSize: "13px" - fontWeight: 400 - lineHeight: 1.65 - label: - fontFamily: "\"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", system-ui, sans-serif" - fontSize: "11px" - fontWeight: 600 - lineHeight: 1.4 -rounded: - sm: "6px" - md: "8px" - lg: "10px" - pill: "999px" -spacing: - xs: "4px" - sm: "8px" - md: "12px" - lg: "16px" - xl: "24px" - xxl: "32px" -components: - button-primary: - backgroundColor: "{colors.primary-brick}" - textColor: "{colors.surface}" - typography: "{typography.title}" - rounded: "{rounded.md}" - padding: "10px 16px" - button-primary-hover: - backgroundColor: "{colors.primary-brick-hover}" - textColor: "{colors.surface}" - rounded: "{rounded.md}" - button-secondary: - backgroundColor: "{colors.surface}" - textColor: "{colors.ink-soft}" - typography: "{typography.title}" - rounded: "{rounded.md}" - padding: "9px 14px" - field: - backgroundColor: "{colors.surface}" - textColor: "{colors.ink}" - typography: "{typography.body}" - rounded: "{rounded.md}" - padding: "9px 11px" - chip-selected: - backgroundColor: "{colors.primary-brick}" - textColor: "{colors.surface}" - typography: "{typography.label}" - rounded: "{rounded.pill}" - padding: "4px 9px" - dialog: - backgroundColor: "{colors.workspace}" - textColor: "{colors.ink}" - rounded: "{rounded.lg}" - padding: "24px" ---- - -# Design System: 漫剧资产工作台 - -## Overview - -**Creative North Star: "安静的主美工作台"** - -这套界面应像一张长期使用的数字工作桌:信息稳定地留在原位,图片、角色关系和生产状态比界面本身更醒目。暖灰和砖红保留现有创作气质,布局与控件则遵循生产工具的秩序,减少展示页式文案、虚构背景和重复容器。 - -工作台采用连续的桌面产品结构。初始化 Dialog、工作流进度、资产列表和资产详情共享同一套导航、排版和状态语言。桌面大屏是主场景,常见笔记本宽度通过收窄列表、折叠辅助信息和保持详情区可滚动来适配,不设计手机和平板布局。 - -系统明确拒绝炫技的 AI 科幻控制台,也拒绝大量同质卡片、层层容器和重复摘要组成的 SaaS 仪表盘。动画只解释状态变化;Loading、展开收起和图片预览必须减少认知负担,否则不应加入。 - -**Key Characteristics:** - -- 暖灰工作区,砖红色只用于主要操作和当前选择。 -- 项目标题可使用宋体,其余界面统一使用系统无衬线字体。 -- 左侧工作流与资产列表保持紧凑,右侧详情为主要阅读和编辑区域。 -- 大部分表面保持扁平,使用留白、分隔线和背景层建立层级。 -- 前端展示正式状态并提交受控意图,不表现为自由实时协同编辑器。 - -## Colors - -色彩应像稳定的创作工具,而不是内容本身。暖灰承载长时间工作,砖红提供稀少而明确的行动信号。 - -### Primary - -- **沉稳砖红** (`primary-brick`):用于主要操作、当前步骤、当前资产和明确确认状态;同一屏幕上的占比必须克制。 -- **深砖红** (`primary-brick-hover`):仅用于主操作的悬停与按下反馈。 - -### Secondary - -- **安静橄榄色**:仅用于风格分类、辅助元信息或非行动性的视觉区分,不承担主要按钮职责。 - -### Neutral - -- **温黑墨色** (`ink`):标题、关键数值和主要文本。 -- **柔和墨棕** (`ink-soft`):说明文本、资产摘要和次级操作。 -- **暖灰工作区** (`workspace`):应用主背景。 -- **清洁表面** (`surface`):详情编辑区、输入框和图片查看区。 -- **导航暖灰** (`surface-muted`):左侧导航、列表选中前的分层背景。 -- **柔和分隔线** (`divider`):结构分隔,不用于装饰性描边。 - -### Named Rules - -**The One Action Color Rule.** 砖红只表示主要操作、当前选择和需要注意的状态;不得把它用于标题装饰、大片背景或无意义图形。 - -**The Status Pair Rule.** 成功、警告和错误状态始终使用深色文字配同色浅底,不能只依赖文字颜色表达。 - -## Typography - -**Display Font:** 宋体(`Songti SC`,回退到 `STSong`、`Georgia`、`serif`) -**Body Font:** 系统中文无衬线(`PingFang SC`、`Hiragino Sans GB`、`Microsoft YaHei`、`system-ui`) -**Label/Mono Font:** 标签继续使用系统中文无衬线;稳定 ID 可使用系统等宽字体。 - -**Character:** 宋体只给项目名称和极少数高层标题带来创作气质。操作标签、状态、按钮、输入框和正文全部使用清晰的系统无衬线字体,避免产品界面出现杂志式排版。 - -### Hierarchy - -- **Project Title**(600,26px,1.2):项目名称和工作台最高层标题,仅少量出现。 -- **Headline**(700,18px,1.35):详情区章节标题、Dialog 主标题。 -- **Title**(700,14px,1.45):资产名称、字段分组、主要按钮。 -- **Body**(400,13px,1.65):资产说明、提示词、状态解释;连续说明文本限制在约 70 个中文字符的舒适行宽内。 -- **Label**(600,11px,1.4):字段名、状态标签、辅助信息;不使用整句英文大写标签。 - -### Named Rules - -**The One Serif Role Rule.** 宋体只能用于项目名称或最高层创作标题。按钮、表单、导航、状态和数据禁止使用宋体。 - -**The Chinese First Rule.** 面向用户的界面文案以中文为主;英文只保留不可替代的模型名、格式名和短技术标识。 - -## Elevation - -系统默认扁平。主工作台通过背景层、分隔线、固定栏位和留白建立结构,不为每个资产、统计数字或字段制造独立悬浮卡片。阴影只用于初始化 Dialog、菜单浮层、Toast 和大图预览浮层,表达它们临时位于工作台之上。 - -### Shadow Vocabulary - -- **Dialog Lift** (`0 24px 64px rgba(39, 35, 32, 0.22)`):仅用于首次设置 Dialog 和需要阻断背景操作的确认浮层;浮层使用阴影时不再叠加装饰性边框。 -- **Compact Popover** (`0 8px 20px rgba(39, 35, 32, 0.14)`):用于小型菜单、图片放大入口和 Toast。 - -### Named Rules - -**The Flat Workspace Rule.** 工作区、导航、资产列表和详情面板在静止状态下不得使用投影;层级由背景色和一像素分隔线表达。 - -**The One Floating Layer Rule.** 同一时刻最多有一个主要浮层。不得在 Dialog 内继续堆叠带阴影卡片。 - -## Components - -### Buttons - -- **Shape:** 紧凑圆角矩形(8px),不使用夸张胶囊造型作为主要按钮。 -- **Primary:** 沉稳砖红底、清洁表面色文字,内边距 10px × 16px;标签使用“动词 + 对象”,例如“保存审视决定”。 -- **Hover / Focus:** 悬停变为深砖红;焦点使用 3px 半透明砖红焦点环,不通过位移制造跳动。 -- **Secondary:** 清洁表面底、柔和墨棕文字和柔和分隔线;不与主按钮争夺视觉重量。 - -### Chips - -- **Style:** 仅用于类型、状态和过滤条件。状态使用深色文字配同色浅底,过滤选中态使用砖红底。 -- **State:** 标签不可承担主要操作;可点击过滤器必须有明确的选中与未选中差异。 - -### Cards / Containers - -- **Corner Style:** 普通容器最大 10px 圆角;资产列表项不做独立卡片。 -- **Background:** 工作区、导航层和清洁详情表面形成三层结构。 -- **Shadow Strategy:** 工作区容器无阴影;只有浮层遵循 Elevation 规则。 -- **Border:** 仅使用 1px 柔和分隔线表达真实结构边界。 -- **Internal Padding:** 紧凑区域 12~16px,主要详情章节 16~24px。 - -### Inputs / Fields - -- **Style:** 清洁表面底、1px 柔和分隔线、8px 圆角。提示词编辑器按内容需要提供足够高度,不把所有字段永久展开。 -- **Focus:** 边框切换为焦点砖色,并显示 3px 低透明度焦点环。 -- **Error / Disabled:** 错误使用危险色浅底和说明文字;禁用态降低对比度但仍保持可读。 - -### Navigation - -- 工作台左侧第一层显示固定工作流阶段;第二层显示当前阶段的资产列表或任务列表。 -- 当前阶段和当前资产使用背景层与砖红色小范围强调,不使用粗侧边色条。 -- 笔记本小屏允许收窄或折叠第一层工作流导航,但不能隐藏当前阶段和返回路径。 - -### Asset Master–Detail - -- 左侧资产列表按角色、场景、道具分组,显示名称、状态和必要缩略图。 -- 右侧详情集中展示当前资产的图片、视觉需求、分层提示词、审视意见和执行状态。 -- 角色默认态与后续状态在同一角色分组内展示,避免把关联状态拆成横向超宽表格。 -- 长提示词、技术 ID 和执行记录默认折叠,用户需要时再展开。 - -### Loading and Disclosure - -- 数据加载使用与真实布局一致的轻量骨架,不在页面中央放置持续旋转的大型 Loading。 -- 展开收起只用于长提示词、技术信息、历史执行记录和辅助预览。 -- 过渡时间控制在 150~220ms;用户启用减少动画时立即切换。 - -## Do's and Don'ts - -### Do: - -- **Do** 使用暖灰工作区、清洁详情表面和一像素分隔线建立稳定的桌面工具结构。 -- **Do** 把图片、资产名称、审视状态和下一步操作放在视觉层级前面。 -- **Do** 使用左侧资产列表与右侧详情,让一个角色及其状态保持在同一上下文中。 -- **Do** 让初始化 Dialog 与主工作台共用颜色、字体、按钮和状态组件。 -- **Do** 让 Loading、折叠和预览组件解决明确的信息或等待问题。 - -### Don't: - -- **Don't** 制作炫技的 AI 科幻控制台、霓虹 HUD 或无功能意义的动态效果。 -- **Don't** 使用大量同质卡片、层层容器和重复摘要组成 SaaS 仪表盘。 -- **Don't** 使用横向宽度超过正常笔记本视口的大表格作为主要资产审视方式。 -- **Don't** 在普通容器上同时叠加一像素边框和宽而柔的装饰阴影。 -- **Don't** 在每个章节上方重复小号英文大写标签或编号眉题。 -- **Don't** 使用超过 10px 的普通容器圆角,或把所有按钮做成胶囊形。 -- **Don't** 让前端和 Agent 同时自由修改 canonical 项目状态。 diff --git a/PRODUCT.md b/PRODUCT.md deleted file mode 100644 index f557f8a..0000000 --- a/PRODUCT.md +++ /dev/null @@ -1,39 +0,0 @@ -# Product - -## Register - -product - -## Users - -面向中文创作者和漫剧资产生产人员。使用者通常熟悉基本创作与生图流程,希望在桌面大屏或常见笔记本上完成项目初始化、剧本解析跟踪、资产查阅与审视、生图执行和结果选择。 - -## Product Purpose - -将 `comic-assets` 的固定工作流呈现为一个连续的资产工作台。初始化 Dialog 是工作台的首次启动浮层;后续解析进度、角色/场景/道具资产、提示词、图片和执行状态在同一产品结构中持续呈现。 - -前端负责读取项目事实、展示状态,并提交风格选择、审视决定或其他明确意图。Agent 和确定性脚本负责验证这些输入、修改 canonical 项目状态、编写提示词、执行生图和处理恢复。成功意味着用户始终知道当前阶段、项目状态和下一步,同时项目文件保持可恢复、可审计和不被并发覆盖。 - -## Brand Personality - -专业、可靠、克制。界面应像稳定的创作生产工具,图片与资产信息是视觉中心,装饰退后。 - -## Anti-references - -- 炫技的 AI 科幻控制台、霓虹 HUD、无功能意义的动态效果。 -- 大量同质卡片、层层容器和重复摘要组成的 SaaS 仪表盘。 -- 为了展示设计感而牺牲信息密度、阅读顺序或操作确定性的界面。 -- 前端和 Agent 同时自由修改 canonical 状态的实时协同模式。 - -## Design Principles - -1. 一个项目对应一套连续的工作空间,初始化、解析、审视和生成不应像彼此无关的产品。 -2. 当前阶段、当前状态和下一步始终清楚,状态表达优先于装饰。 -3. 资产图片、角色关系和生产信息优先,采用左侧资产列表与右侧详情的主从结构。 -4. 前端提交意图,Agent 和确定性脚本验证并执行;canonical 项目状态始终只有一个受控写入路径。 -5. 功能优先。Loading、折叠、预览等轻量组件只有在减少等待焦虑或信息负担时才加入。 -6. 稳定性和可恢复性优先于自由实时交互;新能力应逐层增加,不破坏现有文件与 patch 契约。 - -## Accessibility & Inclusion - -主要支持桌面大屏,并保证常见笔记本小屏可完整使用。不为手机和平板设计,不建设额外的复杂键盘操作或无障碍系统。保留浏览器原生交互、基本焦点状态、文本可读性和必要的减少动画兜底,不为此引入重型依赖。 diff --git a/README.md b/README.md index cba7286..a4ad378 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ ## 前置条件 - 已安装并登录 Codex。 -- 已安装 Node.js(用于本地工作台和确定性脚本)。 +- 已安装 Node.js(用于初始化 Dialog 和确定性脚本)。 - 如需正式生图,另行安装并登录 `lj-awb`;生图、积分与账号授权均由该环境处理。 ## 安装 diff --git a/SKILL.md b/SKILL.md index 3ca4011..9da5240 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: comic-assets -description: 将用户上传的剧本直接转成可审查、可恢复、逐角色流式生成的 AI 漫剧资产图片项目。用于从剧本提取角色/状态/场景/道具,生成分层视觉提示词,打开资产审查台,依赖 lj-awb 估价、批量生图、轮询、重试和收割结果;也用于继续中断项目、只重跑失败资产或导入上游资产清单。 +description: 将用户上传的剧本直接转成可审查、可恢复、逐角色流式生成的 AI 漫剧资产图片项目。用于从剧本提取角色/状态/场景/道具,生成分层视觉提示词,在对话中完成资产确认,依赖 lj-awb 估价、批量生图、轮询、重试和收割结果;也用于继续中断项目、只重跑失败资产或导入上游资产清单。 --- # 漫剧资产生图 @@ -13,7 +13,7 @@ description: 将用户上传的剧本直接转成可审查、可恢复、逐角 ## 零配置入口 -要求 Node.js >=20。真实生图还要求安装 lj-awb skill、联网并完成 AWB 登录;依赖不可用时仍交付完整项目、审查台和供应商无关批次。 +要求 Node.js >=20。真实生图还要求安装 lj-awb skill、联网并完成 AWB 登录;依赖不可用时仍交付完整项目、审查数据和供应商无关批次。 收到新剧本后直接初始化,不要求用户命名目录: @@ -73,14 +73,17 @@ node "/scripts/preflight.mjs" --bootstrap 详细数据契约见 [references/project-schema.md](references/project-schema.md),子代理交接见 [references/subagent-contracts.md](references/subagent-contracts.md),创作原则见 [references/creative-charter.md](references/creative-charter.md),Prompt Agent 契约见 [references/prompt-authoring.md](references/prompt-authoring.md)。 -## 审查 +## 审查(对话式) -~~~bash -node "/scripts/bootstrap_review_package.mjs" "<项目目录>" -node "/scripts/review_server.mjs" "<项目目录>" -~~~ +审查在 Agent 对话中完成,没有独立的审查界面。`bootstrap_review_package.mjs` 初始化 `review-decisions.json`(run_project 已自动执行)。 + +主代理向用户展示待审内容并收集决定: -把终端输出的 /review/ URL 发给用户。审查台只保存实际修改字段;筛选和搜索不会丢编辑。保存后运行: +- 提示词:按来源分段展示(项目风格 / 渲染规格 / 构图 / 身份锚点 / 状态差量)加最终拼接文本;分段仅为展示,不写回状态。 +- 图片:给出项目内 `localPath` 或 URL,用户直接查看文件。 +- 用户的确认、修改意见或直接给出的新文本,由主代理转写为稀疏审查 patch 写入 `review-decisions.json`:`status`(approved / changes_requested)、`note`、`identityPrompt` / `deltaPrompt` 局部修改、默认态锁定(`lockedReference` + `artifactId`)。patch 的 `baseRevision` 必须等于该文件当前 `revision`。不提供、也不接受直接覆盖完整编译结果的整段替换。 + +写入后依次运行: ~~~bash node "/scripts/apply_review_decisions.mjs" "<项目目录>/project-state.json" @@ -88,6 +91,8 @@ node "/scripts/validate_project_state.mjs" "<项目目录>/project- node "/scripts/plan_work_dag.mjs" "<项目目录>/project-state.json" ~~~ +apply 之后立即向用户复述哪些资产被打回及其意见,作为下一轮提示词重写的输入;不要让 changes_requested 静默滞留。 + ## 增量生图 只从 canonical ready 构建批次;approved、submitted、succeeded 不得直接或重复入批: diff --git a/assets/review-app/app.js b/assets/review-app/app.js deleted file mode 100644 index 1893f55..0000000 --- a/assets/review-app/app.js +++ /dev/null @@ -1,304 +0,0 @@ -const app = { - state: null, - saved: { schemaVersion: 3, projectId: "", revision: 0, decisions: {} }, - pending: {}, - type: "all", - search: "", - dirty: false -}; - -const body = document.querySelector("#asset-table-body"); -const summary = document.querySelector("#summary"); -const title = document.querySelector("#project-title"); -const subtitle = document.querySelector("#project-subtitle"); -const toast = document.querySelector("#toast"); - -function escapeHtml(value) { - return String(value ?? "").replace(/[&<>'"]/g, (character) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ })[character]); -} - -function escapedAttribute(value) { - return escapeHtml(value).replace(/\x60/g, "`"); -} - -function notify(message, error = false) { - toast.textContent = message; - toast.classList.toggle("error", error); - toast.classList.add("visible"); - window.clearTimeout(notify.timer); - notify.timer = window.setTimeout(() => toast.classList.remove("visible"), 3500); -} - -async function requestJson(url, options) { - const response = await fetch(url, options); - const payload = await response.json().catch(() => ({})); - if (!response.ok) { - const error = new Error(payload.error || "请求失败(" + response.status + ")"); - error.status = response.status; - error.payload = payload; - throw error; - } - return payload; -} - -function imageValue(image) { - if (!image || typeof image !== "object") return ""; - return image.url ?? image.backendPath ?? image.localPath ?? ""; -} - -function executionStatus(asset) { - return asset.execution?.status ?? (["submitted", "succeeded", "failed"].includes(asset.status) ? asset.status : "idle"); -} - -function canonical(row) { - const asset = row.asset; - const promptStatus = asset.review?.promptStatus ?? (["needs_review", "changes_requested", "approved"].includes(asset.status) ? asset.status : "needs_review"); - return { - status: promptStatus, - imageStatus: asset.review?.imageStatus ?? "needs_review", - identityPrompt: row.isDefault ? row.character?.identityPrompt ?? "" : "", - deltaPrompt: asset.deltaPrompt ?? asset.prompt ?? "", - imageUrl: imageValue(asset.image), - artifactId: asset.image?.artifactId ?? "", - lockedReference: Boolean(asset.referenceAnchor?.artifactId), - note: asset.review?.note ?? "" - }; -} - -function combinedPatch(id) { - return { ...(app.saved.decisions?.[id] ?? {}), ...(app.pending[id] ?? {}) }; -} - -function effective(row) { - const base = canonical(row); - const patch = combinedPatch(row.id); - for (const [field, value] of Object.entries(patch)) { - if (field === "updatedAt" || value == null) continue; - base[field] = value; - } - return base; -} - -function setPending(row, field, value) { - const base = canonical(row)[field]; - const savedHasField = Object.hasOwn(app.saved.decisions?.[row.id] ?? {}, field); - const patch = { ...(app.pending[row.id] ?? {}) }; - if (JSON.stringify(value) === JSON.stringify(base)) { - if (savedHasField) patch[field] = null; - else delete patch[field]; - } else { - patch[field] = value; - } - if (Object.keys(patch).length) app.pending[row.id] = patch; - else delete app.pending[row.id]; - app.dirty = Object.keys(app.pending).length > 0; -} - -function uniqueSegments(values) { - const seen = new Set(); - return values.map((value) => String(value ?? "").trim().replace(/\s+/g, " ")).filter((value) => { - if (!value) return false; - const key = value.toLocaleLowerCase("zh-CN"); - if (seen.has(key)) return false; - seen.add(key); - return true; - }); -} - -function compiledPrompt(row) { - const current = effective(row); - const profile = app.state.config?.promptProfile ?? {}; - const identityPrompt = row.isDefault ? current.identityPrompt : row.character?.identityPrompt; - const positive = uniqueSegments([profile.style, profile.render, identityPrompt, current.deltaPrompt]); - return positive.join("。"); -} - -function createRows() { - const rows = []; - for (const character of app.state.assets?.characters ?? []) { - const states = character.states ?? []; - const anchor = states.find((item) => item.sequence === 0); - for (const asset of states) { - rows.push({ - kind: "character", - id: asset.id, - asset, - character, - label: character.name, - secondaryLabel: asset.name, - description: [character.description, asset.visualBrief].filter(Boolean).join("\n"), - isDefault: asset.sequence === 0, - reference: asset.sequence > 0 && anchor ? anchor : null - }); - } - } - for (const [kind, group] of [["location", "locations"], ["prop", "props"]]) { - for (const asset of app.state.assets?.[group] ?? []) { - rows.push({ kind, id: asset.id, asset, character: null, label: asset.name, secondaryLabel: kind === "location" ? "场景资产" : "道具资产", description: asset.description ?? asset.visualBrief ?? "", isDefault: false, reference: null }); - } - } - return rows; -} - -function statusClass(status) { - return "status-" + String(status).replace(/[^a-z_]/g, ""); -} - -function renderSummary(rows) { - const promptApproved = rows.filter((row) => effective(row).status === "approved").length; - const ready = rows.filter((row) => row.asset.status === "ready").length; - const succeeded = rows.filter((row) => executionStatus(row.asset) === "succeeded").length; - const pending = rows.filter((row) => ["needs_review", "changes_requested"].includes(effective(row).status)).length; - summary.innerHTML = [ - ["待审 / 返工", pending], - ["提示词已通过", promptApproved], - ["当前可执行", ready], - ["已生成图片", succeeded] - ].map(([label, value]) => '
' + label + '
' + value + "
").join(""); -} - -function rowHtml(row) { - const current = effective(row); - const execution = executionStatus(row.asset); - const image = current.imageUrl; - const imagePreview = image - ? '' + escapedAttribute(row.label) + '图片' - : "尚未生成图片"; - let referenceText = row.isDefault ? "这是该角色的身份锚点;图片生成并通过后锁定。" : "独立资产,无角色身份参考。"; - if (row.reference) { - const artifactId = row.reference.referenceAnchor?.artifactId; - referenceText = "依赖:" + row.reference.name + (artifactId ? "(已锁定 " + artifactId + ")" : "(待生成/锁定)"); - } - const identityEditor = row.isDefault && row.kind === "character" - ? '" - : ""; - const lockButton = row.isDefault && row.kind === "character" - ? '" - : ""; - return '' - + "
" + escapeHtml(row.label) + "
" + escapeHtml(row.secondaryLabel) + "
" - + '
' + (row.kind === "character" ? "角色" : row.kind === "location" ? "场景" : "道具") + '审查 ' + escapeHtml(current.status) + '执行 ' + escapeHtml(execution) + "
" - + '' + escapeHtml(row.id) + "" - + '
' + escapeHtml(row.description || "待补充视觉需求") + "
" - + "" + identityEditor + '
查看提交时完整提示词
' + escapeHtml(compiledPrompt(row)) + "
" - + '
' + imagePreview + '
' + escapeHtml(referenceText) + "
" - + '
' + lockButton + "
"; -} - -function render() { - const rows = createRows(); - renderSummary(rows); - const query = app.search.trim().toLowerCase(); - const visible = rows.filter((row) => (app.type === "all" || row.kind === app.type) && (!query || [row.label, row.secondaryLabel, row.description, row.id].join(" ").toLowerCase().includes(query))); - body.innerHTML = visible.length ? visible.map(rowHtml).join("") : '没有匹配的资产。'; -} - -function rowById(id) { - return createRows().find((row) => row.id === id); -} - -async function save() { - if (!app.dirty) { - notify("没有需要保存的修改。"); - return; - } - try { - const result = await requestJson("/api/review-decisions", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ projectId: app.state.project?.id, baseRevision: app.saved.revision ?? 0, decisions: app.pending }) - }); - app.saved = result; - app.pending = {}; - app.dirty = false; - notify("稀疏审查 patch 已保存。"); - render(); - } catch (error) { - notify(error.status === 409 ? "审查数据已被更新,请先重新载入后再保存。" : "保存失败:" + error.message, true); - } -} - -function exportDecisions() { - const data = { schemaVersion: 3, projectId: app.state.project?.id, baseRevision: app.saved.revision ?? 0, decisions: app.pending }; - const blob = new Blob([JSON.stringify(data, null, 2) + "\n"], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = "review-patch.json"; - link.click(); - URL.revokeObjectURL(url); - notify("已导出当前未保存 patch。"); -} - -async function load(force = false) { - if (app.dirty && !force && !window.confirm("存在未保存修改,确定重新载入吗?")) return; - try { - const [state, saved] = await Promise.all([requestJson("/api/project-state"), requestJson("/api/review-decisions")]); - if (saved.projectId !== state.project?.id) throw new Error("审查文件与当前项目不匹配"); - app.state = state; - app.saved = saved; - app.pending = {}; - app.dirty = false; - title.textContent = (state.project?.title ?? "未命名项目") + " · 资产审查台"; - const style = state.config?.styleProfile?.name ?? "尚未设置美术风格"; - subtitle.textContent = "项目 revision " + (state.revision ?? 0) + " · 风格:" + style + " · 只保存实际改动字段"; - render(); - } catch (error) { - title.textContent = "无法加载审查项目"; - subtitle.textContent = "请通过 review_server.mjs 打开,并检查 project-state.json 与 review-decisions.json。"; - body.innerHTML = '' + escapeHtml(error.message) + ""; - notify("项目数据加载失败。", true); - } -} - -document.querySelector("#type-filters").addEventListener("click", (event) => { - const button = event.target.closest("[data-type]"); - if (!button) return; - app.type = button.dataset.type; - document.querySelectorAll("[data-type]").forEach((item) => item.classList.toggle("active", item === button)); - render(); -}); - -document.querySelector("#search-input").addEventListener("input", (event) => { - app.search = event.target.value; - render(); -}); - -body.addEventListener("input", (event) => { - const input = event.target.closest("[data-field][data-id]"); - if (!input) return; - const row = rowById(input.dataset.id); - if (!row) return; - setPending(row, input.dataset.field, input.dataset.field === "imageUrl" ? input.value.trim() : input.value); -}); - -body.addEventListener("click", (event) => { - const button = event.target.closest("[data-action]"); - if (!button) return; - const row = rowById(button.dataset.id); - if (!row) return; - if (button.dataset.action === "approve") setPending(row, "status", "approved"); - if (button.dataset.action === "revise") setPending(row, "status", "changes_requested"); - if (button.dataset.action === "lock") { - const artifactId = row.asset.image?.artifactId; - if (executionStatus(row.asset) !== "succeeded" || !artifactId || !imageValue(row.asset.image)) { - notify("只有已成功生成且带 artifactId 的默认状态图片可以锁定。", true); - return; - } - setPending(row, "artifactId", artifactId); - setPending(row, "lockedReference", true); - setPending(row, "imageStatus", "approved"); - } - render(); -}); - -document.querySelector("#save-button").addEventListener("click", save); -document.querySelector("#export-button").addEventListener("click", exportDecisions); -document.querySelector("#reload-button").addEventListener("click", () => load()); -window.addEventListener("beforeunload", (event) => { - if (!app.dirty) return; - event.preventDefault(); - event.returnValue = ""; -}); - -load(true); diff --git a/assets/review-app/index.html b/assets/review-app/index.html deleted file mode 100644 index f13d7d9..0000000 --- a/assets/review-app/index.html +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - AI 漫剧资产审查台 - - - -
-
-
-

AI COMIC · REVIEW DESK

-

资产审查台

-

加载项目状态中…

-
-
- - - -
-
- -
- -
-
- - - - -
- -
- -
- - - - - - - - - - - -
资产 / 状态视觉需求分层提示词(只编辑差量)图片与参考图(可直接填入)审查 patch
-
-
-
- - - diff --git a/assets/review-app/styles.css b/assets/review-app/styles.css deleted file mode 100644 index 11914a1..0000000 --- a/assets/review-app/styles.css +++ /dev/null @@ -1,126 +0,0 @@ -:root { - color-scheme: light; - font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif; - color: #241e1a; - background: #f4f0e8; - font-synthesis: none; -} - -* { box-sizing: border-box; } - -body { - margin: 0; - min-width: 320px; - background: - radial-gradient(circle at 84% 0%, rgba(211, 164, 88, .16), transparent 28rem), - linear-gradient(120deg, #f8f4ec 0%, #efe9dd 100%); -} - -button, input, textarea { font: inherit; } -button { cursor: pointer; } - -.page-shell { - width: min(100% - 32px, 2100px); - margin: 0 auto; - padding: 36px 0 64px; -} - -.hero { - display: flex; - justify-content: space-between; - gap: 32px; - align-items: end; - padding: 8px 4px 28px; -} - -.eyebrow { - margin: 0 0 7px; - color: #a25c2d; - font-size: 12px; - font-weight: 800; - letter-spacing: .13em; -} - -h1 { margin: 0; font-family: "Songti SC", STSong, serif; font-size: clamp(28px, 3vw, 42px); letter-spacing: .03em; } -.subtitle { margin: 10px 0 0; max-width: 700px; color: #70655d; line-height: 1.65; } - -.header-actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: flex-end; } -.button, .filter, .review-action { - border: 1px solid transparent; - border-radius: 9px; - padding: 9px 13px; - font-weight: 700; - transition: transform .16s ease, background .16s ease, border-color .16s ease; -} -.button:hover, .filter:hover, .review-action:hover { transform: translateY(-1px); } -.button.primary { color: #fffaf2; background: #8e3f23; box-shadow: 0 4px 12px rgba(99, 46, 23, .21); } -.button.secondary { color: #5f4635; border-color: #d2c1ad; background: rgba(255, 252, 246, .7); } - -.summary { display: grid; grid-template-columns: repeat(4, minmax(150px, 1fr)); gap: 12px; margin-bottom: 18px; } -.summary-card { min-height: 94px; padding: 17px 20px; border: 1px solid #e2d8c9; border-radius: 13px; background: rgba(255, 253, 248, .77); box-shadow: 0 9px 24px rgba(86, 60, 39, .05); } -.summary-label { color: #7d6e62; font-size: 13px; } -.summary-number { margin-top: 5px; font-family: "Songti SC", STSong, serif; font-size: 30px; font-weight: 700; } - -.toolbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin: 20px 0 12px; } -.filter-group { display: flex; flex-wrap: wrap; gap: 8px; } -.filter { color: #715c4c; border-color: #d9ccbd; background: rgba(255,255,255,.42); } -.filter.active { color: #fffaf4; border-color: #8e3f23; background: #8e3f23; } -.search-field { display: flex; align-items: center; gap: 8px; color: #75675d; font-size: 13px; font-weight: 700; } -.search-field input { width: 270px; border: 1px solid #d9ccbd; border-radius: 8px; padding: 9px 11px; color: #332820; background: rgba(255,255,255,.7); outline: none; } -.search-field input:focus, textarea:focus, .image-url:focus, .note-input:focus { border-color: #aa653d; box-shadow: 0 0 0 3px rgba(170,101,61,.12); } - -.table-shell { overflow-x: auto; border: 1px solid #d8cbb9; border-radius: 15px; background: #fffdf9; box-shadow: 0 16px 36px rgba(78, 51, 34, .09); } -table { width: 100%; min-width: 1760px; border-collapse: separate; border-spacing: 0; } -th { position: sticky; top: 0; z-index: 1; padding: 14px 16px; text-align: left; color: #715a48; border-bottom: 1px solid #d8cbb9; background: #f7f0e5; font-size: 13px; letter-spacing: .04em; } -td { padding: 15px 16px; vertical-align: top; border-bottom: 1px solid #eadfd2; background: rgba(255,253,249,.94); } -tbody tr:last-child td { border-bottom: 0; } -tbody tr:hover td { background: #fffaf1; } -.identity-column { width: 210px; }.brief-column { width: 280px; }.prompt-column { width: 565px; }.image-column { width: 455px; }.review-column { width: 250px; } - -.asset-name { color: #39291f; font-size: 16px; font-weight: 800; } -.state-name { margin-top: 6px; color: #6a594d; font-size: 13px; line-height: 1.45; } -.asset-id { display: inline-block; max-width: 180px; margin-top: 9px; overflow: hidden; color: #9a8778; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } -.chips { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 9px; } -.chip { padding: 3px 7px; border-radius: 999px; background: #eee2d1; color: #765338; font-size: 11px; font-weight: 800; } -.chip.status-approved, .chip.status-ready, .chip.status-succeeded { color: #1f6a46; background: #dcefe2; } -.chip.status-changes_requested, .chip.status-failed, .chip.status-blocked { color: #9e3d31; background: #f6dfd7; } -.chip.status-needs_style, .chip.status-needs_review, .chip.status-draft { color: #875d25; background: #f8ebcb; } - -.brief { color: #5e4e43; font-size: 13px; line-height: 1.7; white-space: pre-wrap; } -.muted { color: #9b8d81; } -textarea, .image-url, .note-input { width: 100%; border: 1px solid #d9ccbd; border-radius: 8px; color: #36291f; background: #fffefa; outline: none; } -textarea { min-height: 176px; padding: 11px 12px; resize: vertical; line-height: 1.6; font-size: 13px; } -.field-label { display: block; margin: 0 0 6px; color: #745643; font-size: 11px; font-weight: 800; } -.field-label:not(:first-child) { margin-top: 10px; } -.identity-input { min-height: 108px; background: #fffaf0; } -.delta-input { min-height: 120px; } -.compiled-preview { margin-top: 9px; padding: 8px 10px; border: 1px solid #e2d5c5; border-radius: 8px; color: #725f51; background: #faf7f1; font-size: 12px; line-height: 1.6; } -.compiled-preview summary { cursor: pointer; font-weight: 800; } -.compiled-preview div { margin-top: 7px; white-space: pre-wrap; } -.prompt-empty { border-color: #d4966c; background: #fff8ed; } -.image-stack { display: grid; grid-template-columns: minmax(154px, .75fr) minmax(200px, 1fr); gap: 12px; } -.image-preview { display: grid; min-height: 175px; place-items: center; overflow: hidden; border: 1px dashed #cdbda9; border-radius: 9px; color: #968273; background: #faf6ef; font-size: 12px; text-align: center; } -.image-preview img { width: 100%; height: 100%; min-height: 175px; object-fit: cover; } -.image-details { display: flex; flex-direction: column; gap: 8px; } -.image-url { padding: 8px 9px; font-size: 12px; } -.reference-box { padding: 8px 10px; border-left: 3px solid #bd7c48; border-radius: 4px; color: #715e50; background: #faf0e3; font-size: 12px; line-height: 1.55; word-break: break-all; } -.reference-box strong { color: #714324; } -.note-input { min-height: 60px; padding: 8px 9px; resize: vertical; font-size: 12px; line-height: 1.45; } -.review-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; margin-top: 9px; } -.review-action { padding: 7px 8px; color: #695244; border-color: #d8c9b6; background: #fffdf9; font-size: 12px; } -.review-action.approve { color: #236641; border-color: #aad5b8; background: #eff9f1; } -.review-action.revise { color: #a34635; border-color: #e4b5a9; background: #fff4f0; } -.review-action.lock { grid-column: 1 / -1; color: #825226; border-color: #dfbf96; background: #fff6e8; } -.empty-row td { padding: 60px; color: #8d7e72; text-align: center; } - -.toast { position: fixed; right: 22px; bottom: 22px; z-index: 4; max-width: 360px; padding: 12px 15px; border-radius: 9px; color: #fffaf2; background: #352820; box-shadow: 0 12px 30px rgba(0,0,0,.2); opacity: 0; transform: translateY(12px); pointer-events: none; transition: opacity .2s ease, transform .2s ease; } -.toast.visible { opacity: 1; transform: translateY(0); } -.toast.error { background: #8a3329; } - -@media (max-width: 760px) { - .page-shell { width: min(100% - 20px, 2100px); padding-top: 24px; } - .hero, .toolbar { align-items: stretch; flex-direction: column; } - .header-actions { justify-content: flex-start; } - .summary { grid-template-columns: repeat(2, 1fr); } - .search-field input { width: min(100%, 280px); } -} diff --git a/assets/setup-app/index.html b/assets/setup-app/index.html index aa90d8e..eb5fdef 100644 --- a/assets/setup-app/index.html +++ b/assets/setup-app/index.html @@ -3,99 +3,12 @@ - 漫剧资产工作台 + 项目初始化 · 漫剧资产 + -
-
-
- - 漫剧资产工作台 -
- -
-

正在读取项目

- - -
-
-
- - - 修改已保存 - -
-
- -
- - -
-
-
-
-

选择一个资产

-
-
-

从左侧列表选择角色、场景或道具。

-
-
-
-
- 暂无资产图片 - 图片生成后会在这里作为主要审视对象展示。 -
-
-
-
- - -
-
- - + +
diff --git a/assets/viewer-app/viewer.css b/assets/viewer-app/viewer.css index 65ceb4d..1554afb 100644 --- a/assets/viewer-app/viewer.css +++ b/assets/viewer-app/viewer.css @@ -105,8 +105,33 @@ button:disabled { cursor: not-allowed; opacity: .58; } } .button.secondary { color: var(--ink-soft); border-color: var(--line); background: var(--surface); } .button.secondary:hover { border-color: var(--focus); background: var(--workspace); } +.button.primary { color: var(--surface); background: var(--primary); } +.button.primary:hover { background: var(--primary-hover); } + +.connection-state { color: var(--danger); font-size: .6875rem; font-weight: 700; white-space: nowrap; } .workspace { display: grid; grid-template-columns: 320px minmax(400px, 1fr) 350px; min-height: 0; } +.app-shell.waiting .workspace { display: none; } +.workflow-wait { min-height: 0; place-items: center; padding: 32px; background: #f9f7f3; } +.app-shell.waiting .workflow-wait { display: grid; } +.wait-content { display: grid; justify-items: center; width: min(620px, 100%); text-align: center; } +.wait-indicator { display: inline-flex; gap: 5px; height: 16px; align-items: center; } +.wait-indicator i { width: 6px; height: 6px; border-radius: 50%; background: var(--primary); animation: wait-pulse 1.2s ease-in-out infinite; } +.wait-indicator i:nth-child(2) { animation-delay: 140ms; } +.wait-indicator i:nth-child(3) { animation-delay: 280ms; } +.wait-stage { margin: 14px 0 5px; color: var(--primary); font-size: .6875rem; font-weight: 700; } +.wait-content h2 { margin: 0; font-family: "Songti SC", STSong, Georgia, serif; font-size: 1.5rem; font-weight: 600; letter-spacing: -.02em; } +.wait-copy { max-width: 58ch; margin: 9px 0 0; color: var(--muted); font-size: .75rem; line-height: 1.7; } +.wait-milestones { display: flex; justify-content: center; flex-wrap: wrap; gap: 8px; margin-top: 20px; } +.wait-milestone { display: inline-flex; align-items: center; gap: 7px; min-height: 30px; padding: 5px 9px; border-radius: var(--radius-md); color: var(--muted); background: var(--surface-muted); font-size: .6875rem; } +.wait-milestone::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: #b8b1aa; } +.wait-milestone.complete { color: var(--success); background: var(--success-soft); } +.wait-milestone.complete::before { background: var(--success); } +.wait-milestone.current { color: var(--warning); background: var(--warning-soft); } +.wait-milestone.current::before { background: var(--warning); } +.agent-handoff { display: grid; gap: 5px; width: min(480px, 100%); margin: 24px 0 0; padding-top: 16px; border-top: 1px solid var(--line); color: var(--muted); font-size: .75rem; line-height: 1.6; } +.agent-handoff b { color: var(--ink-soft); font-size: .8125rem; } +@keyframes wait-pulse { 0%, 70%, 100% { opacity: .28; transform: translateY(0); } 35% { opacity: 1; transform: translateY(-2px); } } .asset-browser, .preview-workspace, .inspector { min-width: 0; min-height: 0; } .asset-browser { display: grid; grid-template-rows: auto minmax(0, 1fr); border-right: 1px solid var(--line); background: var(--surface); } .browser-tools { display: grid; gap: 10px; padding: var(--space-3); border-bottom: 1px solid var(--line); } @@ -234,6 +259,64 @@ button:disabled { cursor: not-allowed; opacity: .58; } .toast.visible { opacity: 1; transform: translateY(0); } .toast.error { background: var(--danger); } +.setup-overlay { position: fixed; z-index: 30; inset: 0; display: grid; place-items: center; padding: 24px; background: rgba(39, 35, 32, .36); } +.setup-dialog { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: min(1180px, calc(100vw - 48px)); height: min(760px, calc(100vh - 48px)); overflow: hidden; border-radius: var(--radius-lg); background: var(--workspace); box-shadow: 0 24px 64px rgba(39, 35, 32, .22); } +.setup-head, .setup-foot { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); padding: 14px 18px; background: var(--surface); } +.setup-head { border-bottom: 1px solid var(--line); } +.setup-head span { color: var(--muted); font-size: .6875rem; } +.setup-head h2 { margin: 3px 0 0; font-family: "Songti SC", STSong, Georgia, serif; font-size: 1.375rem; letter-spacing: -.02em; } +.setup-head p { margin: 0; color: var(--muted); font-size: .75rem; } +.setup-body { display: grid; grid-template-columns: 160px minmax(0, 1fr); min-height: 0; } +.setup-nav { padding: 12px 8px; border-right: 1px solid var(--line); background: var(--surface-muted); } +.setup-step { display: grid; gap: 3px; width: 100%; border: 0; border-radius: var(--radius-md); padding: 10px 12px; color: var(--ink-soft); background: transparent; text-align: left; } +.setup-step:hover { background: rgba(255, 253, 249, .58); } +.setup-step.active { color: var(--ink); background: var(--surface); } +.setup-step b { font-size: .75rem; } +.setup-step span { color: var(--muted); font-size: .625rem; } +.setup-panel { display: none; grid-template-columns: minmax(0, 1fr) 340px; min-width: 0; min-height: 0; } +.setup-panel.active { display: grid; } +.setup-catalog, .setup-detail { min-height: 0; overflow: auto; } +.setup-catalog { padding: 18px; } +.setup-catalog h3 { margin: 0; font-size: .9375rem; } +.setup-catalog > p { margin: 5px 0 14px; color: var(--muted); font-size: .6875rem; line-height: 1.6; } +.setup-detail { padding: 18px; border-left: 1px solid var(--line); background: var(--surface); } +.preset-default { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 10px; width: 100%; border: 1px solid var(--line); border-radius: var(--radius-md); padding: 11px 12px; color: var(--ink); background: var(--surface); text-align: left; } +.preset-default:hover, .style-card:hover, .layout-card:hover { border-color: #c8b7a8; } +.preset-default.selected, .style-card.selected, .layout-card.selected { border-color: var(--primary); background: #f8eee7; } +.preset-default b { display: block; font-size: .75rem; } +.preset-default small { display: block; margin-top: 3px; color: var(--muted); font-size: .625rem; line-height: 1.5; } +.preset-default em { color: var(--primary); font-style: normal; font-weight: 700; } +.style-group { margin-top: 14px; } +.style-group h4 { margin: 0 0 7px; font-size: .75rem; } +.style-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; } +.style-card, .layout-card { position: relative; overflow: hidden; border: 1px solid var(--line); border-radius: var(--radius-md); padding: 0; color: var(--ink); background: var(--surface); text-align: left; } +.style-card.selected::after, .layout-card.selected::after { content: "✓"; position: absolute; top: 6px; right: 6px; display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; color: var(--surface); background: var(--primary); font-size: .6875rem; font-weight: 800; } +.style-thumb { display: block; aspect-ratio: 1 / 1; overflow: hidden; background: var(--surface-muted); } +.style-thumb img, .layout-image img { width: 100%; height: 100%; object-fit: cover; } +.style-name { display: block; padding: 7px 8px 8px; } +.style-name small { display: block; color: var(--muted); font-size: .5625rem; } +.style-name b { display: block; margin-top: 3px; font-size: .6875rem; } +.detail-title { margin: 0; font-family: "Songti SC", STSong, Georgia, serif; font-size: 1.25rem; } +.detail-copy { margin: 7px 0 12px; color: var(--muted); font-size: .6875rem; line-height: 1.65; } +.reference-head { display: flex; justify-content: space-between; padding-top: 10px; border-top: 1px solid var(--line); color: var(--muted); font-size: .625rem; } +.examples { display: grid; gap: 8px; margin-top: 8px; } +.example { margin: 0; overflow: hidden; border-radius: var(--radius-sm); background: var(--workspace); } +.example img { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: cover; } +.example figcaption { padding: 6px 8px; color: var(--muted); font-size: .5625rem; } +.setup-empty { display: grid; min-height: 260px; place-items: center; color: var(--muted); text-align: center; font-size: .6875rem; line-height: 1.65; } +.layout-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } +.layout-image { display: block; aspect-ratio: 16 / 9; background: var(--surface-muted); } +.layout-image.placeholder { display: grid; place-items: center; color: var(--muted); font-size: .625rem; } +.layout-card b { display: block; padding: 8px 9px 3px; font-size: .75rem; } +.layout-card small { display: block; padding: 0 9px 9px; color: var(--muted); font-size: .625rem; } +.layout-spec { display: grid; gap: 8px; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--line); } +.layout-spec span { color: var(--muted); font-size: .625rem; } +.layout-spec b { display: block; margin-top: 2px; color: var(--ink); font-size: .75rem; } +.setup-foot { border-top: 1px solid var(--line); } +.setup-foot > div { display: grid; grid-template-columns: auto minmax(100px, 1fr) auto minmax(130px, 1fr); align-items: center; gap: 5px 9px; min-width: 0; font-size: .6875rem; } +.setup-foot span { color: var(--muted); } +.setup-foot b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .skeleton-list { display: grid; gap: 1px; background: var(--line); } .skeleton-list i { display: block; height: 52px; background: linear-gradient(90deg, var(--surface) 0%, #f1ece5 50%, var(--surface) 100%); background-size: 200% 100%; animation: skeleton 1.2s linear infinite; } @keyframes skeleton { to { background-position: -200% 0; } } @@ -242,6 +325,7 @@ button:disabled { cursor: not-allowed; opacity: .58; } .workspace { grid-template-columns: 280px minmax(360px, 1fr) 320px; } .project-heading h1 { max-width: 220px; } .topbar-meta { display: none; } + .style-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } } @media (prefers-reduced-motion: reduce) { diff --git a/assets/viewer-app/viewer.js b/assets/viewer-app/viewer.js index ee34d89..e7c7c31 100644 --- a/assets/viewer-app/viewer.js +++ b/assets/viewer-app/viewer.js @@ -1,10 +1,18 @@ const app = { state: null, + setup: null, + manifest: null, + visualPresetId: "script", + characterLayoutId: "closeup-front-back", selectedId: null, type: "all", status: "all", search: "", - expandedCharacters: new Set() + expandedCharacters: new Set(), + etag: null, + refreshing: false, + retryDelay: 2000, + pollTimer: null }; const $ = (selector) => document.querySelector(selector); @@ -34,11 +42,17 @@ function notify(message, error = false) { notify.timer = setTimeout(() => toast.classList.remove("visible"), 3000); } -async function requestJson(url) { - const response = await fetch(url); +async function requestJson(url, options = {}) { + const response = await fetch(url, options); + if (response.status === 304) return { notModified: true, response }; const payload = await response.json().catch(() => ({})); if (!response.ok) throw new Error(payload.error || `请求失败(${response.status})`); - return payload; + return { payload, response }; +} + +function setupAssetUrl(value) { + if (!value) return ""; + return "/setup-assets/" + String(value).split(/[\\/]+/).filter(Boolean).map(encodeURIComponent).join("/"); } function remoteUrl(value) { @@ -77,6 +91,9 @@ function statusLabel(status) { changes_requested: "需修改", approved: "已通过", ready: "可生成", + prepared: "等待提交", + submitting: "正在提交", + submission_unknown: "提交待核对", submitted: "生成中", succeeded: "已生成", failed: "失败", @@ -86,7 +103,7 @@ function statusLabel(status) { function statusTone(status) { if (["approved", "ready", "succeeded"].includes(status)) return "success"; - if (["changes_requested", "failed", "blocked"].includes(status)) return "danger"; + if (["changes_requested", "failed", "blocked", "submission_unknown"].includes(status)) return "danger"; if (["idle", "draft"].includes(status)) return "neutral"; return ""; } @@ -191,19 +208,20 @@ function workflowModel() { const state = app.state; const rows = createRows(); const setupDone = !state.workflow?.setup || state.workflow.setup.status === "complete"; + const setupDecisionSaved = app.setup?.decisions?.status === "complete"; const storyDone = state.workflow?.storyBible?.status === "complete"; const directionDone = state.workflow?.artDirection?.status === "complete"; const assetsReady = rows.length > 0; const reviewPending = rows.some((row) => !["approved", "ready", "submitted", "succeeded"].includes(displayStatus(row))); - const hasGenerating = rows.some((row) => ["ready", "submitted", "failed"].includes(displayStatus(row))); + const hasGenerationActivity = rows.some((row) => ["prepared", "submitting", "submission_unknown", "submitted", "succeeded", "failed"].includes(displayStatus(row))); const allSucceeded = rows.length > 0 && rows.every((row) => displayStatus(row) === "succeeded"); let current = "setup"; if (setupDone && (!storyDone || !directionDone || !assetsReady)) current = "script"; - if (setupDone && storyDone && directionDone && assetsReady) current = reviewPending ? "assets" : hasGenerating ? "generation" : "assets"; + if (setupDone && storyDone && directionDone && assetsReady) current = hasGenerationActivity ? "generation" : "assets"; if (allSucceeded) current = "results"; const labels = { setup: "项目设置", script: "剧本解析", assets: "资产整理", generation: "资产生成", results: "结果确认" }; const items = [ - { id: "setup", label: labels.setup, status: setupDone ? "已完成" : "待确认", tone: setupDone ? "complete" : current === "setup" ? "current" : "" }, + { id: "setup", label: labels.setup, status: setupDone ? "已完成" : setupDecisionSaved ? "等待 Agent 应用" : "待确认", tone: setupDone ? "complete" : current === "setup" ? "current" : "" }, { id: "script", label: labels.script, @@ -215,8 +233,8 @@ function workflowModel() { { id: "assets", label: labels.assets, - status: current === "assets" ? "进行中" : assetsReady && !reviewPending ? "已完成" : "尚未开始", - tone: current === "assets" ? "current" : assetsReady && !reviewPending ? "complete" : "" + status: current === "assets" ? "进行中" : assetsReady && !reviewPending ? "已完成" : hasGenerationActivity ? "仍有待审资产" : "尚未开始", + tone: current === "assets" || (hasGenerationActivity && reviewPending) ? "current" : assetsReady && !reviewPending ? "complete" : "" }, { id: "generation", @@ -226,7 +244,75 @@ function workflowModel() { }, { id: "results", label: labels.results, status: current === "results" ? "可确认" : "尚未开始", tone: current === "results" ? "current" : "" } ]; - return { current, label: `${labels[current]} · ${current === "results" ? "可确认" : "进行中"}`, rows: items }; + return { current, label: `${labels[current]} · ${current === "results" ? "可确认" : "进行中"}`, rows: items, setupDone, storyDone, directionDone, assetsReady }; +} + +function milestone(label, tone = "") { + return `${escapeHtml(label)}`; +} + +function waitingModel() { + const workflow = workflowModel(); + const decisionSaved = app.setup?.decisions?.status === "complete"; + if (!workflow.setupDone && decisionSaved) { + return { + stage: "项目设置 · 已保存", + title: "初始化设置已经保存", + copy: "Agent 将校验并应用视觉范式与角色构图。页面保持打开后,会自动进入剧本解析阶段。", + milestones: [milestone("选择已保存", "complete"), milestone("等待 Agent 应用", "current")], + handoff: true + }; + } + if (!workflow.setupDone) { + return { + stage: "项目设置 · 待确认", + title: "完成视觉基线设置", + copy: "请在初始化窗口中选择视觉范式和默认角色构图。", + milestones: [milestone("视觉范式", "current"), milestone("角色构图")], + handoff: false + }; + } + if (!workflow.storyDone) { + return { + stage: "剧本解析 · 进行中", + title: "正在建立故事资产圣经", + copy: "全文理解 Agent 会整理世界观、角色身份演变、场景、道具和证据。请在 Agent 对话中继续流程。", + milestones: [milestone("项目设置", "complete"), milestone("全文理解", "current"), milestone("创作方向")], + handoff: true + }; + } + if (!workflow.directionDone) { + return { + stage: "剧本解析 · 创作方向", + title: "故事资产圣经已经锁定", + copy: "下一步由 AI 美术总监提出项目创作方向,并在 Agent 对话中等待确认。", + milestones: [milestone("全文理解", "complete"), milestone("创作方向", "current"), milestone("资产编译")], + handoff: true + }; + } + return { + stage: "资产整理 · 进行中", + title: "正在整理角色、场景和道具", + copy: "资产和中文分层提示词完成后会自动出现在工作台中。", + milestones: [milestone("剧本解析", "complete"), milestone("资产编译", "current"), milestone("提示词整理")], + handoff: true + }; +} + +function renderWorkspaceMode() { + const rows = createRows(); + const workflow = workflowModel(); + const wait = waitingModel(); + const waiting = !workflow.setupDone || !workflow.storyDone || !workflow.directionDone || !rows.length; + $("#app-shell").classList.toggle("waiting", waiting); + $("#workflow-wait").hidden = !waiting; + $("#setup-overlay").hidden = workflow.setupDone || app.setup?.decisions?.status === "complete"; + if (!waiting) return; + $("#wait-stage").textContent = wait.stage; + $("#wait-title").textContent = wait.title; + $("#wait-copy").textContent = wait.copy; + $("#wait-milestones").innerHTML = wait.milestones.join(""); + $("#agent-handoff").hidden = !wait.handoff; } function renderHeader() { @@ -382,20 +468,137 @@ function renderInspector(row) { `; } +function styleGroups() { + const groups = new Map(); + for (const item of app.manifest?.styles?.filter((entry) => entry.id !== "script") ?? []) { + if (!groups.has(item.family)) groups.set(item.family, []); + groups.get(item.family).push(item); + } + return groups; +} + +function renderSetupStyleDetail() { + const item = app.manifest?.styles?.find((entry) => entry.id === app.visualPresetId); + if (!item) return; + $("#selected-style").textContent = item.name; + const examples = item.examples?.length + ? `
方向样例,仅用于选择${item.examples.length} 张
${item.examples.map((source, index) => `
${escapedAttribute(item.name)}样例${index + 1}
参考样例 ${index + 1}
`).join("")}
` + : `
不固定二维、CG 或真人。AI 美术总监将根据剧本、世界观和用户意图决定视觉表现。
`; + $("#setup-style-detail").innerHTML = `

${escapeHtml(item.name)}

${escapeHtml(item.stylePromptSeed || "不使用预设风格种子。")}

${examples}`; +} + +function renderSetupStyles() { + const script = app.manifest?.styles?.find((item) => item.id === "script"); + if (!script) return; + const defaultCard = ``; + const groups = [...styleGroups()].map(([family, items]) => `

${escapeHtml(family)}

${items.map((item) => ``).join("")}
`).join(""); + $("#setup-style-catalog").innerHTML = defaultCard + groups; + renderSetupStyleDetail(); +} + +function renderSetupLayoutDetail() { + const item = app.manifest?.layouts?.find((entry) => entry.id === app.characterLayoutId); + if (!item) return; + $("#selected-layout").textContent = item.name; + $("#setup-layout-detail").innerHTML = `

${escapeHtml(item.name)}

${escapeHtml(item.prompt)}

建议画幅${escapeHtml(item.ratio)}
作用范围仅角色默认锚点图
`; +} + +function renderSetupLayouts() { + $("#setup-layout-catalog").innerHTML = (app.manifest?.layouts ?? []).map((item) => ``).join(""); + renderSetupLayoutDetail(); +} + +async function submitSetup() { + const button = $("#setup-submit"); + button.disabled = true; + button.textContent = "正在保存"; + try { + const { payload } = await requestJson("/api/setup-decisions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + schemaVersion: app.state.schemaVersion, + projectId: app.state.project.id, + visualPresetId: app.visualPresetId, + characterLayoutId: app.characterLayoutId + }) + }); + app.setup = { ...app.setup, decisions: payload.decisions }; + button.textContent = "设置已保存"; + renderWorkspaceMode(); + notify("设置已保存。请返回 Agent 对话继续,本页面会自动更新。"); + } catch (error) { + button.disabled = false; + button.textContent = "确认设置"; + notify(error.message, true); + } +} + function render() { renderHeader(); renderAssetList(); + renderWorkspaceMode(); } -async function load() { - const state = await requestJson("/api/project-state"); - app.state = state; +async function loadState(conditional = false) { + const headers = conditional && app.etag ? { "If-None-Match": app.etag } : {}; + const result = await requestJson("/api/project-state", { headers }); + if (result.notModified) return false; + app.state = result.payload; + app.etag = result.response.headers.get("etag") ?? app.etag; const rows = createRows(); if (!app.selectedId && rows.length) { app.selectedId = rows[0].id; if (rows[0].character) app.expandedCharacters.add(rows[0].character.id); } + return true; +} + +async function loadSetup() { + const { payload } = await requestJson("/api/setup"); + app.setup = payload; + app.manifest = payload.manifest; + app.visualPresetId = payload.decisions?.visualPresetId ?? "script"; + app.characterLayoutId = payload.decisions?.characterLayoutId ?? "closeup-front-back"; + $("#setup-project-title").textContent = payload.project?.title ?? ""; + renderSetupStyles(); + renderSetupLayouts(); +} + +function setConnectionState(disconnected) { + $("#connection-state").hidden = !disconnected; +} + +function schedulePoll(delay = document.hidden ? 9000 : 2000) { + clearTimeout(app.pollTimer); + app.pollTimer = setTimeout(refresh, delay); +} + +async function refresh(options = {}) { + if (app.refreshing) return; + app.refreshing = true; + try { + const changed = await loadState(!options.force); + if (app.state.workflow?.setup?.status !== "complete") await loadSetup(); + if (changed || options.force) render(); + else renderWorkspaceMode(); + setConnectionState(false); + app.retryDelay = 2000; + if (options.notify) notify("已重新载入。"); + } catch (error) { + setConnectionState(true); + if (options.notify) notify(error.message, true); + app.retryDelay = Math.min(Math.max(app.retryDelay * 1.7, 3000), 15000); + } finally { + app.refreshing = false; + schedulePoll(app.retryDelay === 2000 ? undefined : app.retryDelay); + } +} + +async function boot() { + await Promise.all([loadState(), loadSetup()]); render(); + schedulePoll(); } $("#stage-button").addEventListener("click", () => { @@ -458,11 +661,35 @@ inspector.addEventListener("click", async (event) => { } }); -$("#reload-button").addEventListener("click", () => load().then(() => notify("已重新载入。")).catch((error) => notify(error.message, true))); +document.querySelectorAll(".setup-step").forEach((button) => button.addEventListener("click", () => { + document.querySelectorAll(".setup-step").forEach((item) => item.classList.toggle("active", item === button)); + document.querySelectorAll(".setup-panel").forEach((panel) => panel.classList.toggle("active", panel.id === `setup-${button.dataset.setupPanel}-panel`)); +})); + +$("#setup-style-catalog").addEventListener("click", (event) => { + const button = event.target.closest("[data-style]"); + if (!button) return; + app.visualPresetId = button.dataset.style; + renderSetupStyles(); +}); + +$("#setup-layout-catalog").addEventListener("click", (event) => { + const button = event.target.closest("[data-layout]"); + if (!button) return; + app.characterLayoutId = button.dataset.layout; + renderSetupLayouts(); +}); + +$("#setup-submit").addEventListener("click", submitSetup); +$("#reload-button").addEventListener("click", () => refresh({ force: true, notify: true })); + +document.addEventListener("visibilitychange", () => schedulePoll(document.hidden ? 9000 : 300)); -load().catch((error) => { +boot().catch((error) => { $("#project-title").textContent = "无法加载项目"; $("#project-meta").textContent = error.message; - assetList.innerHTML = `
请通过 asset_viewer_server.mjs 打开查看台,并检查 project-state.json。
`; - notify("项目数据加载失败。", true); + assetList.innerHTML = `
请通过 workspace_server.mjs 打开工作台,并检查 project-state.json。
`; + setConnectionState(true); + notify("项目数据加载失败,正在尝试重新连接。", true); + schedulePoll(3000); }); diff --git a/references/project-schema.md b/references/project-schema.md index d86e46b..d47942d 100644 --- a/references/project-schema.md +++ b/references/project-schema.md @@ -95,7 +95,7 @@ schemaVersion 必须精确等于 3。当前版本不自动迁移旧项目。每 ## 项目初始化选择 -新项目先进入 `workflow.setup: draft`。`setup_server.mjs` 提供仅本机监听的初始化 Dialog,用户单选视觉范式和默认角色资产构图,保存结果到项目内 `setup-decisions.json`;`apply_setup_decisions.mjs` 再将经过 manifest 校验的选择写入 `config.visualPreset`、`config.assetPresentation.characterDefault` 与 `workflow.setup.contentHash`。选择不是聊天上下文,也不依赖浏览器状态;任一窗口接手都可从项目文件恢复。 +新项目先进入 `workflow.setup: draft`。`workspace_server.mjs` 提供仅本机监听的统一工作台,初始化 Dialog 是它的首次浮层。用户单选视觉范式和默认角色资产构图,选择只写入项目内 `setup-decisions.json`;`apply_setup_decisions.mjs` 再将经过 manifest 校验的选择写入 `config.visualPreset`、`config.assetPresentation.characterDefault` 与 `workflow.setup.contentHash`。提交后工作台服务保持运行,通过 `project-state.json` 自动切换五阶段;选择不是聊天上下文,也不依赖浏览器状态,任一窗口接手都可从项目文件恢复。 预览资源位于 skill 内的 `assets/setup-app/`,仅用于用户理解差异,永不作为生成参考图上传。真正影响生图的是 `visualPreset.stylePromptSeed`(在确认创作方向时合并进公共视觉语言)和默认角色态的 `characterDefault.prompt` / `ratio`。选择“按剧本生成”时风格种子可以为空,由 AI 美术总监从剧本与创作方向自主决定视觉语言。这套构图仅作用于每个角色的 `sequence: 0` 默认资产,避免污染后续状态、场景和道具。 diff --git a/scripts/asset_viewer_server.mjs b/scripts/asset_viewer_server.mjs index 4e8a643..500ad05 100644 --- a/scripts/asset_viewer_server.mjs +++ b/scripts/asset_viewer_server.mjs @@ -1,96 +1,4 @@ #!/usr/bin/env node -// 只读资产查看台:三栏浏览角色/场景/道具、图片预览与分段提示词展示。 -// 不提供任何写入端点——审查意见与修改一律在 Agent 对话中收集。 -// 用法: node asset_viewer_server.mjs <项目目录> [--host 127.0.0.1] [--port 0] -import { createServer } from "node:http"; -import { access, readFile, stat } from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { readJson } from "./lib/project.mjs"; - -function option(name, fallback) { - const position = process.argv.indexOf(name); - return position >= 0 ? process.argv[position + 1] : fallback; -} - -const [projectDirectory] = process.argv.slice(2); -if (!projectDirectory || projectDirectory.startsWith("--")) { - console.error("用法: node asset_viewer_server.mjs <项目目录> [--host 127.0.0.1] [--port 0]"); - process.exit(1); -} - -const root = path.resolve(projectDirectory); -const host = option("--host", "127.0.0.1"); -// 默认随机可用端口,避免多项目并行时端口冲突或在浏览器里看到错误项目;需要固定端口时用 --port 指定。 -const port = Number(option("--port", "0")); -const statePath = path.join(root, "project-state.json"); -const assetRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../assets/viewer-app"); -const mimeTypes = new Map([ - [".html", "text/html; charset=utf-8"], - [".js", "text/javascript; charset=utf-8"], - [".css", "text/css; charset=utf-8"], - [".json", "application/json; charset=utf-8"], - [".png", "image/png"], - [".jpg", "image/jpeg"], - [".jpeg", "image/jpeg"], - [".webp", "image/webp"], - [".gif", "image/gif"], - [".svg", "image/svg+xml"], - [".md", "text/plain; charset=utf-8"] -]); - -try { - await access(statePath); -} catch { - console.error("未找到项目状态文件:" + statePath); - process.exit(1); -} - -function send(response, statusCode, body, contentType = "text/plain; charset=utf-8", headers = {}) { - response.writeHead(statusCode, { "Content-Type": contentType, "Cache-Control": "no-store", ...headers }); - response.end(body); -} - -const server = createServer(async (request, response) => { - try { - if (request.method !== "GET" && request.method !== "HEAD") return send(response, 405, "Method Not Allowed(查看台只读;修改请在 Agent 对话中进行)"); - const url = new URL(request.url ?? "/", "http://" + (request.headers.host ?? "localhost")); - if (url.pathname === "/api/project-state") { - try { - return send(response, 200, JSON.stringify(await readJson(statePath)), "application/json; charset=utf-8"); - } catch (error) { - return send(response, 500, JSON.stringify({ error: error.message }), "application/json; charset=utf-8"); - } - } - if (url.pathname === "/" || url.pathname === "/viewer") return send(response, 302, "Redirecting", "text/plain; charset=utf-8", { Location: "/viewer/" }); - let file; - if (url.pathname === "/viewer/" || url.pathname.startsWith("/viewer/")) { - const requested = url.pathname === "/viewer/" ? "index.html" : decodeURIComponent(url.pathname.slice("/viewer/".length)); - file = path.resolve(assetRoot, requested); - const relative = path.relative(assetRoot, file); - if (relative.startsWith("..") || path.isAbsolute(relative)) return send(response, 403, "Forbidden"); - } else if (url.pathname.startsWith("/project/")) { - const requested = decodeURIComponent(url.pathname.slice("/project/".length)); - file = path.resolve(root, requested); - const relative = path.relative(root, file); - if (relative.startsWith("..") || path.isAbsolute(relative)) return send(response, 403, "Forbidden"); - } else { - return send(response, 404, "Not Found"); - } - const info = await stat(file); - if (!info.isFile()) return send(response, 404, "Not Found"); - const content = await readFile(file); - send(response, 200, content, mimeTypes.get(path.extname(file).toLowerCase()) ?? "application/octet-stream"); - } catch (error) { - send(response, error.code === "ENOENT" ? 404 : 500, error.code === "ENOENT" ? "Not Found" : "Server error: " + error.message); - } -}); - -server.listen(port, host, () => { - const address = server.address(); - const actualPort = typeof address === "object" && address ? address.port : port; - const displayHost = host === "0.0.0.0" ? "<本机局域网 IP>" : host; - console.log("资产查看台(只读)已启动:http://" + displayHost + ":" + actualPort + "/viewer/"); -}); - -process.on("SIGINT", () => server.close(() => process.exit(0))); +// 兼容入口:只读资产查看台已并入统一项目工作台。 +console.warn("asset_viewer_server.mjs 已并入统一项目工作台;正在启动同一常驻界面。"); +await import("./workspace_server.mjs"); diff --git a/scripts/merge_agent_patches.mjs b/scripts/merge_agent_patches.mjs index e336270..58ffb73 100644 --- a/scripts/merge_agent_patches.mjs +++ b/scripts/merge_agent_patches.mjs @@ -10,7 +10,10 @@ if (!stateFile || !patchFiles.length) { const state = await readJson(stateFile); state.workflow = { appliedOps: [], ...(state.workflow ?? {}) }; const applied = new Set(state.workflow.appliedOps ?? []); -const existing = () => new Map(listAssets(state).map((record) => [record.asset.id, record])); +const existing = () => new Map([ + ...(state.assets?.characters ?? []).map((asset) => [asset.id, { asset, character: null, assetType: "character" }]), + ...listAssets(state).map((record) => [record.asset.id, record]) +]); const conflicts = []; const patches = []; @@ -56,7 +59,7 @@ for (const { file, patch } of patches) { } const allowed = record.assetType === "character-state" ? new Set(["name", "visualBrief", "deltaPrompt", "sourceRefs"]) - : new Set(["name", "description", "visualBrief", "deltaPrompt", "sourceRefs", "usages"]); + : new Set(["name", "description", "identityPrompt", "visualBrief", "deltaPrompt", "sourceRefs", "usages"]); for (const [field, value] of Object.entries(operation.changes)) { if (!allowed.has(field)) { conflicts.push(file + " 不允许更新 " + operation.id + "." + field); diff --git a/scripts/run_project.mjs b/scripts/run_project.mjs index ba6483f..b5820cd 100644 --- a/scripts/run_project.mjs +++ b/scripts/run_project.mjs @@ -83,8 +83,8 @@ run("validate_project_state.mjs", [stateFile]); run("plan_work_dag.mjs", [stateFile]); state = await readJson(stateFile); console.log("项目目录:" + projectRoot); -if (state.workflow?.setup?.status !== "complete") console.log("第一步:启动初始化界面:node " + JSON.stringify(path.join(scriptRoot, "setup_server.mjs")) + " " + JSON.stringify(projectRoot) + ",在浏览器完成视觉范式与角色构图选择后运行 apply_setup_decisions.mjs。"); +if (state.workflow?.setup?.status !== "complete") console.log("第一步:启动统一项目工作台:node " + JSON.stringify(path.join(scriptRoot, "workspace_server.mjs")) + " " + JSON.stringify(projectRoot) + "。在同一页面完成视觉范式与角色构图选择;提交后页面保持在线,再运行 apply_setup_decisions.mjs。"); else if (state.workflow?.storyBible?.status !== "complete") console.log("下一步:让全文理解 Agent 填写 story-asset-bible.md,再运行 record_story_bible.mjs。"); else if (state.workflow?.artDirection?.status !== "complete") console.log("下一步:让 AI 美术总监填写 art-direction-brief.md;用户确认后运行 record_art_direction_brief.mjs。"); else if (!(state.assets?.characters?.length || state.assets?.locations?.length || state.assets?.props?.length)) console.log("下一步:根据故事资产圣经编译 canonical 资产 patch 并合并;切片仅用于核验或补漏。"); -else console.log("下一步:在对话中向用户展示提示词与图片结果并收集确认;将决定写入 review-decisions.json 并运行 apply_review_decisions.mjs,再按 ready 范围增量建批。"); +else console.log("下一步:保持或启动统一项目工作台,在同一页面浏览提示词与图片结果;审查决定仍在 Agent 对话中收集并写入 review-decisions.json,再按 ready 范围增量建批。"); diff --git a/scripts/setup_server.mjs b/scripts/setup_server.mjs index 0548b04..bde84f8 100644 --- a/scripts/setup_server.mjs +++ b/scripts/setup_server.mjs @@ -1,95 +1,4 @@ #!/usr/bin/env node -import { createServer } from "node:http"; -import { readFile } from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { hashValue, readJson, schemaVersion, writeJsonAtomic } from "./lib/project.mjs"; - -function option(name, fallback) { - const position = process.argv.indexOf(name); - return position >= 0 ? process.argv[position + 1] : fallback; -} - -const [projectDir] = process.argv.slice(2); -if (!projectDir || projectDir.startsWith("--")) { - console.error("用法: node setup_server.mjs <项目目录> [--port 4317]"); - process.exit(1); -} - -const projectRoot = path.resolve(projectDir); -const statePath = path.join(projectRoot, "project-state.json"); -const state = await readJson(statePath); -if (state.schemaVersion !== schemaVersion) { - console.error("schemaVersion 不匹配:期望 " + schemaVersion + ",实际为 " + state.schemaVersion); - process.exit(1); -} -const setupPath = path.resolve(projectRoot, state.workflow?.setup?.path ?? "setup-decisions.json"); -const assetRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../assets/setup-app"); -const manifest = JSON.parse(await readFile(path.join(assetRoot, "style-presets.json"), "utf8")); -const visualIds = new Set((manifest.styles ?? []).map((item) => item.id)); -const layoutIds = new Set((manifest.layouts ?? []).map((item) => item.id)); -// 默认随机可用端口,避免多项目并行时端口冲突或在浏览器里打开错误项目;需要固定端口时用 --port 指定。 -const port = Number(option("--port", "0")); - -const contentTypes = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json; charset=utf-8", ".jpg": "image/jpeg", ".jpeg": "image/jpeg" }; -function send(response, status, body, type = "application/json; charset=utf-8") { - response.writeHead(status, { "content-type": type, "cache-control": "no-store" }); - response.end(typeof body === "string" || Buffer.isBuffer(body) ? body : JSON.stringify(body)); -} -function readBody(request) { - return new Promise((resolve, reject) => { - let body = ""; - request.on("data", (chunk) => { - body += chunk; - if (body.length > 100_000) reject(new Error("请求过大")); - }); - request.on("end", () => resolve(body)); - request.on("error", reject); - }); -} - -const server = createServer(async (request, response) => { - const url = new URL(request.url, "http://127.0.0.1"); - try { - if (request.method === "GET" && url.pathname === "/api/setup") { - const latest = await readJson(statePath); - let decisions = null; - try { decisions = await readJson(setupPath); } catch (error) { if (error.code !== "ENOENT") throw error; } - return send(response, 200, { project: latest.project, setup: latest.workflow?.setup ?? null, decisions, manifest }); - } - if (request.method === "POST" && url.pathname === "/api/decisions") { - const payload = JSON.parse(await readBody(request)); - if (!visualIds.has(payload.visualPresetId) || !layoutIds.has(payload.characterLayoutId)) return send(response, 400, { error: "未知视觉范式或角色构图" }); - const latest = await readJson(statePath); - const decisions = { - schemaVersion, - projectId: latest.project.id, - visualPresetId: payload.visualPresetId, - characterLayoutId: payload.characterLayoutId, - status: "complete", - updatedAt: new Date().toISOString(), - contentHash: hashValue({ manifestVersion: manifest.version, visualPresetId: payload.visualPresetId, characterLayoutId: payload.characterLayoutId }) - }; - await writeJsonAtomic(setupPath, decisions); - console.log("初始化选择已保存:" + decisions.visualPresetId + " / " + decisions.characterLayoutId); - send(response, 200, { ok: true, decisions }); - // 提交即完成本界面的使命:自动退出,避免残留进程占用端口或让后续项目误入本项目的界面。 - setTimeout(() => server.close(() => process.exit(0)), 1000); - return; - } - if (request.method !== "GET") return send(response, 405, { error: "方法不允许" }); - const requested = url.pathname === "/" ? "index.html" : decodeURIComponent(url.pathname.slice(1)); - const target = path.resolve(assetRoot, requested); - if (!target.startsWith(assetRoot + path.sep) && target !== assetRoot) return send(response, 403, { error: "路径无效" }); - const file = await readFile(target); - return send(response, 200, file, contentTypes[path.extname(target).toLowerCase()] ?? "application/octet-stream"); - } catch (error) { - return send(response, error.code === "ENOENT" ? 404 : 500, { error: error.message }); - } -}); -server.listen(port, "127.0.0.1", () => { - const address = server.address(); - const actualPort = typeof address === "object" && address ? address.port : port; - console.log("项目初始化界面:http://127.0.0.1:" + actualPort); - console.log("用户提交选择后本服务会自动退出;随后运行 apply_setup_decisions.mjs 固化。"); -}); +// 兼容入口:初始化 Dialog 已并入统一项目工作台。 +console.warn("setup_server.mjs 已并入统一项目工作台;正在启动同一常驻界面。"); +await import("./workspace_server.mjs"); diff --git a/scripts/workspace_server.mjs b/scripts/workspace_server.mjs new file mode 100644 index 0000000..f5b5fce --- /dev/null +++ b/scripts/workspace_server.mjs @@ -0,0 +1,343 @@ +#!/usr/bin/env node +// 统一项目工作台服务:同一地址贯穿初始化、解析、资产整理、生成与结果确认。 +// 前端只写初始化选择;其余项目状态由 Agent 与确定性脚本维护。 +// 用法: node workspace_server.mjs <项目目录> [--host 127.0.0.1] [--port 0] +import { createHash } from "node:crypto"; +import { createServer } from "node:http"; +import { readFile, realpath, stat } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { hashValue, readJson, schemaVersion, writeJsonAtomic } from "./lib/project.mjs"; + +function option(name, fallback) { + const position = process.argv.indexOf(name); + return position >= 0 ? process.argv[position + 1] : fallback; +} + +const [projectDirectory] = process.argv.slice(2); +if (!projectDirectory || projectDirectory.startsWith("--")) { + console.error("用法: node workspace_server.mjs <项目目录> [--host 127.0.0.1] [--port 0]"); + process.exit(1); +} + +const host = option("--host", "127.0.0.1"); +const requestedPort = Number(option("--port", "0")); +if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65535) { + console.error("端口无效:" + requestedPort); + process.exit(1); +} + +const projectRoot = path.resolve(projectDirectory); +const statePath = path.join(projectRoot, "project-state.json"); +const scriptRoot = path.dirname(fileURLToPath(import.meta.url)); +const workspaceAssetRoot = path.resolve(scriptRoot, "../assets/viewer-app"); +const setupAssetRoot = path.resolve(scriptRoot, "../assets/setup-app"); +const presetManifestPath = path.resolve(scriptRoot, "../assets/setup-app/style-presets.json"); + +let initialState; +let manifest; +let canonicalProjectRoot; +let canonicalWorkspaceAssetRoot; +let canonicalSetupAssetRoot; +try { + initialState = await readJson(statePath); + if (initialState.schemaVersion !== schemaVersion) { + throw new Error(`schemaVersion 不匹配:期望 ${schemaVersion},实际为 ${initialState.schemaVersion}`); + } + if (!initialState.project?.id) throw new Error("project-state.json 缺少 project.id"); + manifest = await readJson(presetManifestPath); + canonicalProjectRoot = await realpath(projectRoot); + canonicalWorkspaceAssetRoot = await realpath(workspaceAssetRoot); + canonicalSetupAssetRoot = await realpath(setupAssetRoot); +} catch (error) { + console.error("无法启动统一项目工作台:" + error.message); + process.exit(1); +} + +const visualPresetIds = new Set((manifest.styles ?? []).map((item) => item.id)); +const characterLayoutIds = new Set((manifest.layouts ?? []).map((item) => item.id)); +const mimeTypes = new Map([ + [".html", "text/html; charset=utf-8"], + [".js", "text/javascript; charset=utf-8"], + [".mjs", "text/javascript; charset=utf-8"], + [".css", "text/css; charset=utf-8"], + [".json", "application/json; charset=utf-8"], + [".png", "image/png"], + [".jpg", "image/jpeg"], + [".jpeg", "image/jpeg"], + [".webp", "image/webp"], + [".gif", "image/gif"], + [".svg", "image/svg+xml"], + [".ico", "image/x-icon"], + [".md", "text/plain; charset=utf-8"], + [".txt", "text/plain; charset=utf-8"], + [".woff", "font/woff"], + [".woff2", "font/woff2"] +]); + +function send(response, statusCode, body = "", contentType = "text/plain; charset=utf-8", headers = {}) { + response.writeHead(statusCode, { + "Content-Type": contentType, + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + ...headers + }); + response.end(Buffer.isBuffer(body) || typeof body === "string" ? body : JSON.stringify(body)); +} + +function sendJson(response, statusCode, value, headers = {}) { + send(response, statusCode, JSON.stringify(value), "application/json; charset=utf-8", headers); +} + +function etagFor(content) { + return `"${createHash("sha256").update(content).digest("hex")}"`; +} + +function etagMatches(request, etag) { + const candidate = request.headers["if-none-match"]; + if (!candidate) return false; + return candidate.split(",").some((value) => value.trim() === etag || value.trim() === "*"); +} + +function readRequestJson(request) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + let settled = false; + request.on("data", (chunk) => { + if (settled) return; + size += chunk.length; + if (size > 100_000) { + settled = true; + const error = new Error("请求体超过 100 KB"); + error.statusCode = 413; + reject(error); + request.resume(); + return; + } + chunks.push(chunk); + }); + request.on("end", () => { + if (settled) return; + try { + const text = Buffer.concat(chunks).toString("utf8"); + const value = JSON.parse(text); + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("请求体必须是 JSON 对象"); + resolve(value); + } catch (error) { + error.statusCode = 400; + reject(error); + } + }); + request.on("error", (error) => { + if (!settled) reject(error); + }); + }); +} + +function resolveInside(root, requested) { + const target = path.resolve(root, requested); + const relative = path.relative(root, target); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + const error = new Error("路径无效"); + error.statusCode = 403; + throw error; + } + return target; +} + +async function readableFileInside(canonicalRoot, requested) { + const lexicalTarget = resolveInside(canonicalRoot, requested); + const canonicalTarget = await realpath(lexicalTarget); + const relative = path.relative(canonicalRoot, canonicalTarget); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + const error = new Error("路径无效"); + error.statusCode = 403; + throw error; + } + const information = await stat(canonicalTarget); + if (!information.isFile()) { + const error = new Error("文件不存在"); + error.code = "ENOENT"; + throw error; + } + return canonicalTarget; +} + +async function setupPathFor(state, verifyExistingTarget = false) { + const target = resolveInside(canonicalProjectRoot, state.workflow?.setup?.path ?? "setup-decisions.json"); + const canonicalParent = await realpath(path.dirname(target)); + const parentRelative = path.relative(canonicalProjectRoot, canonicalParent); + if (parentRelative.startsWith("..") || path.isAbsolute(parentRelative)) { + const error = new Error("初始化选择文件路径无效"); + error.statusCode = 403; + throw error; + } + if (verifyExistingTarget) { + try { + const canonicalTarget = await realpath(target); + const targetRelative = path.relative(canonicalProjectRoot, canonicalTarget); + if (targetRelative.startsWith("..") || path.isAbsolute(targetRelative)) { + const error = new Error("初始化选择文件路径无效"); + error.statusCode = 403; + throw error; + } + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + } + return target; +} + +async function optionalJson(file) { + try { + return await readJson(file); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } +} + +async function serveFile(response, canonicalRoot, requested) { + const file = await readableFileInside(canonicalRoot, requested); + const content = await readFile(file); + return send(response, 200, content, mimeTypes.get(path.extname(file).toLowerCase()) ?? "application/octet-stream"); +} + +const server = createServer(async (request, response) => { + let url; + try { + url = new URL(request.url ?? "/", "http://127.0.0.1"); + } catch { + return sendJson(response, 400, { error: "请求地址无效" }); + } + + try { + if (request.method === "GET" && url.pathname === "/api/health") { + return sendJson(response, 200, { ok: true, projectId: initialState.project.id }); + } + + if (request.method === "GET" && url.pathname === "/api/project-state") { + const content = await readFile(statePath); + const etag = etagFor(content); + if (etagMatches(request, etag)) { + response.writeHead(304, { ETag: etag, "Cache-Control": "no-cache" }); + return response.end(); + } + return send(response, 200, content, "application/json; charset=utf-8", { ETag: etag, "Cache-Control": "no-cache" }); + } + + if (request.method === "GET" && url.pathname === "/api/setup") { + const latest = await readJson(statePath); + const decisions = await optionalJson(await setupPathFor(latest, true)); + return sendJson(response, 200, { + project: latest.project, + setup: latest.workflow?.setup ?? null, + decisions, + manifest + }); + } + + if (request.method === "POST" && (url.pathname === "/api/setup-decisions" || url.pathname === "/api/decisions")) { + const payload = await readRequestJson(request); + const latest = await readJson(statePath); + if (latest.schemaVersion !== schemaVersion) { + return sendJson(response, 409, { error: `项目 schemaVersion 已变化:期望 ${schemaVersion},实际为 ${latest.schemaVersion}` }); + } + if (payload.schemaVersion !== schemaVersion) { + return sendJson(response, 400, { error: `schemaVersion 不匹配:期望 ${schemaVersion},实际为 ${payload.schemaVersion ?? "<缺失>"}` }); + } + if (payload.projectId !== latest.project?.id) { + return sendJson(response, 409, { error: `projectId 不匹配:期望 ${latest.project?.id ?? "<缺失>"},实际为 ${payload.projectId ?? "<缺失>"}` }); + } + if (latest.workflow?.setup?.status === "complete") { + return sendJson(response, 409, { error: "项目初始化已经固化;如需改变视觉基线,请派生新项目" }); + } + if (!visualPresetIds.has(payload.visualPresetId) || !characterLayoutIds.has(payload.characterLayoutId)) { + return sendJson(response, 400, { error: "未知视觉范式或角色构图" }); + } + const decisions = { + schemaVersion, + projectId: latest.project.id, + visualPresetId: payload.visualPresetId, + characterLayoutId: payload.characterLayoutId, + status: "complete", + updatedAt: new Date().toISOString(), + contentHash: hashValue({ + manifestVersion: manifest.version, + visualPresetId: payload.visualPresetId, + characterLayoutId: payload.characterLayoutId + }) + }; + await writeJsonAtomic(await setupPathFor(latest, true), decisions); + console.log("初始化选择已保存:" + decisions.visualPresetId + " / " + decisions.characterLayoutId); + return sendJson(response, 200, { ok: true, decisions }); + } + + if (request.method !== "GET") { + return sendJson(response, 405, { error: "方法不允许;工作台仅可写入初始化选择" }, { Allow: "GET, POST" }); + } + + if (url.pathname.startsWith("/api/")) return sendJson(response, 404, { error: "接口不存在" }); + + if (["/viewer", "/viewer/", "/setup", "/setup/"].includes(url.pathname)) { + return send(response, 302, "Redirecting", "text/plain; charset=utf-8", { Location: "/" }); + } + + if (url.pathname.startsWith("/project/")) { + let requested; + try { + requested = decodeURIComponent(url.pathname.slice("/project/".length)); + } catch { + return sendJson(response, 400, { error: "路径编码无效" }); + } + return await serveFile(response, canonicalProjectRoot, requested); + } + + if (url.pathname.startsWith("/setup-assets/")) { + let requested; + try { + requested = decodeURIComponent(url.pathname.slice("/setup-assets/".length)); + } catch { + return sendJson(response, 400, { error: "路径编码无效" }); + } + return await serveFile(response, canonicalSetupAssetRoot, requested); + } + + let requested; + try { + requested = url.pathname === "/" ? "index.html" : decodeURIComponent(url.pathname.slice(1)); + } catch { + return sendJson(response, 400, { error: "路径编码无效" }); + } + return await serveFile(response, canonicalWorkspaceAssetRoot, requested); + } catch (error) { + const statusCode = error.statusCode ?? (error.code === "ENOENT" ? 404 : 500); + return sendJson(response, statusCode, { error: statusCode === 404 ? "文件不存在" : error.message }); + } +}); + +server.listen(requestedPort, host, () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + const displayHost = host === "0.0.0.0" ? "<本机局域网 IP>" : host; + console.log("统一项目工作台已启动:http://" + displayHost + ":" + port + "/"); + console.log("项目:" + initialState.project.id + "(服务会持续运行,直到收到退出信号)"); +}); + +let closing = false; +function shutdown(signal) { + if (closing) return; + closing = true; + console.log(`收到 ${signal},正在关闭统一项目工作台…`); + server.close((error) => { + if (error) { + console.error("关闭服务失败:" + error.message); + process.exit(1); + } + process.exit(0); + }); +} + +process.on("SIGINT", () => shutdown("SIGINT")); +process.on("SIGTERM", () => shutdown("SIGTERM")); From 0eefa63c8192aa02ac18ce66060d20bec7d09df5 Mon Sep 17 00:00:00 2001 From: Shanzoon <82895720+Shanzoon@users.noreply.github.com> Date: Fri, 17 Jul 2026 06:52:16 +0800 Subject: [PATCH 05/11] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B5=84=E4=BA=A7?= =?UTF-8?q?=E5=91=88=E7=8E=B0=E9=85=8D=E7=BD=AE=E4=BD=9C=E7=94=A8=E5=9F=9F?= =?UTF-8?q?=E6=B1=A1=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 9 + SKILL.md | 8 +- assets/viewer-app/viewer.js | 66 ++++- references/project-schema.md | 46 +++- references/prompt-authoring.md | 15 +- scripts/apply_setup_decisions.mjs | 19 +- scripts/bootstrap_art_direction_brief.mjs | 2 +- scripts/build_image_batch.mjs | 9 +- scripts/lib/project.mjs | 116 +++++++- scripts/record_art_direction_brief.mjs | 18 +- scripts/run_project.mjs | 12 +- scripts/validate_project_state.mjs | 35 ++- .../presentation-scopes/project-state.json | 113 ++++++++ tests/presentation-scope.test.mjs | 255 ++++++++++++++++++ 14 files changed, 665 insertions(+), 58 deletions(-) create mode 100644 tests/fixtures/presentation-scopes/project-state.json create mode 100644 tests/presentation-scope.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 605bcad..f235bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ 后续接手此 skill 的窗口在修改前应先阅读本文件、`SKILL.md` 与相关数据契约。记录应说明行为变化、兼容性和验证方式,而不是逐行重复代码差异。 +## 2026-07-17 — 资产呈现作用域修复(第一阶段) + +- schemaVersion 继续保持 3,在配置内新增可判别的分域版本:`promptProfile.version: 1` 使用 `commonVisualLanguage` / `globalRenderLanguage`;`assetPresentation.version: 1` 明确拆分 `characterDefault`、`characterState`、`location`、`prop`。四类呈现各自保存 prompt 与 ratio,`config.image` 对新项目只承担质量等跨类型参数。 +- `compilePrompt()` 和 `imageSettingsForRecord()` 现在先解析资产类型,再只拼接该类型的呈现 prompt、只读取该类型的 ratio。角色默认设定板仍只作用于 `sequence: 0`;角色状态、场景和道具分别使用单一状态图、宽幅环境图和单一道具图默认配置。批次 item 新增 `presentation: { scope, id }`,便于审计最终比例来源。 +- 创作方向模板把“默认渲染规格”改为“全局渲染语言”;`record_art_direction_brief.mjs` 会拒绝把画幅比例、白底/影棚背景、胸像/半身像、多视图、正背面、站姿、角色局部细节和左右排版写入全局字段。尚未锁定的旧模板标题仍可读取,但内容按新边界校验。 +- `validate_project_state.mjs` 对新分域模型硬校验四类配置的 id / prompt / ratio、逐资产呈现 scope 与最终 ratio,并拒绝全局渲染语言中的专属交付版式。统一工作台的分段展示同步使用相同作用域和旧 render 过滤规则,避免审查文本与正式批次再次分叉。 +- 旧项目保持 v3 可读:历史 `style` / `render` 通过兼容解析器读取;污染的 render 分句只在编译/展示时过滤,不改写 canonical 状态;已有 `characterDefault` 的 setup-era 项目缺少角色状态、场景、道具配置时使用内置分域默认值,更早且完全没有呈现配置的项目仍保留历史比例兜底。重新运行相同 `apply_setup_decisions.mjs` 可选择性持久化四类配置,不改已锁定创作方向、成功 artifact 或锚点;新的 promptHash / generationKey 仍须重新审查和执行确认。 +- 新增 `tests/fixtures/presentation-scopes/project-state.json` 与 `tests/presentation-scope.test.mjs`。自动验证四类资产最终 prompt 与 ratio 不跨域、旧污染 render 只读过滤、validator 反例、`build_image_batch.mjs` 写出的四类 presentation 来源、创作方向录入拒绝专属版式,以及新项目初始化落盘。验证命令:`node --test tests/presentation-scope.test.mjs`(6/6 通过)。用户指定的真实回归项目仅做定向只读字段与编译解析检查,未修改、未建批、未提交 AWB、未下载或重新生成资产。 + ## 2026-07-17 — 统一工作台贯穿五阶段 - 新增 `scripts/workspace_server.mjs`:用一个仅本机监听的常驻服务承载初始化、剧本解析等待、资产整理、资产生成和结果确认;初始化提交后不再关闭服务或让浏览器断开。旧 `setup_server.mjs` 与 `asset_viewer_server.mjs` 保留为兼容入口,但都会启动同一工作台。 diff --git a/SKILL.md b/SKILL.md index be61c9f..8241283 100644 --- a/SKILL.md +++ b/SKILL.md @@ -37,13 +37,13 @@ node "/scripts/record_story_bible.mjs" "<项目目录>/project-stat 确认会锁定故事资产圣经。后续日常修改应落在美术风格、提示词差量、审查意见或图片锚点;如果发现全局剧情理解错误,保留当前项目和产物,另建或派生项目重新开始,不在原项目内改写圣经。 -故事资产圣经锁定后,由 AI 美术总监填写 `art-direction-brief.md`:提出创作命题、用户确认方向、公共视觉语言、默认渲染规格、正向设计重点与跨资产设计原则。用户确认后固化: +故事资产圣经锁定后,由 AI 美术总监填写 `art-direction-brief.md`:提出创作命题、用户确认方向、公共视觉语言、全局渲染语言、正向设计重点与跨资产设计原则。全局渲染语言只写所有资产共享的材质、光影、清晰度和一致性,不得承载白底、胸像、三视图、正背面、排版或比例。用户确认后固化: ~~~bash node "/scripts/record_art_direction_brief.mjs" "<项目目录>/project-state.json" ~~~ -它会把已确认的公共视觉语言和默认渲染规格写入 `styleProfile` / `promptProfile`。运行时只遵循 [references/creative-charter.md](references/creative-charter.md) 与 [references/prompt-authoring.md](references/prompt-authoring.md) 的短原则;不加载外部美术知识库原文作为逐条命令,也不使用负向提示词。 +它会把已确认的公共视觉语言和全局渲染语言写入 `styleProfile` / `promptProfile`。运行时只遵循 [references/creative-charter.md](references/creative-charter.md) 与 [references/prompt-authoring.md](references/prompt-authoring.md) 的短原则;不加载外部美术知识库原文作为逐条命令,也不使用负向提示词。 随后由编译 Agent 依据故事资产圣经和项目创作方向生成 canonical 资产 sidecar patch,再运行 `merge_agent_patches.mjs`。仅在需要核验覆盖率或补漏时,才按剧本切片: @@ -66,7 +66,7 @@ node "/scripts/preflight.mjs" --bootstrap 3. AI 美术总监依据故事资产圣经、用户意图和已选视觉范式提出 `art-direction-brief.md`;用户确认后运行 `record_art_direction_brief.mjs`,锁定项目创作方向。 4. 编译 Agent 依据这两个已锁定产物生成 canonical 资产 sidecar patch,按复用度准入规则取舍状态并在 patch 的 `downgraded` 数组回报降级清单(见 subagent-contracts)。圣经中的每条状态要么成为 state 要么进 downgraded;只出默认态视为编译不完整,主代理退回重编。主代理运行 `merge_agent_patches.mjs`,统一稳定 ID、revision 和冲突。 5. 剧本分片只用于核验与补漏。worker 不能在核验阶段自行创建最终 canonical ID;疑似新实体或别名冲突必须回报主代理。 -6. Prompt Agent 在公共方向已确认后自主用中文设计并写入分层提示词;提示词按“项目公共风格 + 角色身份锚点 + 状态差量”存储,完整 prompt 仅在出批时编译,不追加负向提示词。默认角色态额外继承用户选定的构图描述和比例;后续状态、场景和道具不误用该排版。专有名词可保留必要原文。 +6. Prompt Agent 在公共方向已确认后自主用中文设计并写入分层提示词;完整 prompt 按“公共视觉语言 + 全局渲染语言 + 当前资产呈现 + 角色身份锚点 + 状态差量”在出批时编译,不追加负向提示词。角色默认态、角色状态、场景和道具分别继承自己的呈现 prompt 与比例,禁止跨域读取。专有名词可保留必要原文。 7. 运行 apply_review_decisions.mjs 消费稀疏审查 patch,再运行 plan_work_dag.mjs。 8. 默认态按角色独立释放。某角色默认图成功且用户锁定具体 artifactId 后,只解锁该角色后续状态;不等待其他角色。 9. 场景和道具按各自 ready 状态独立入批。单个阻塞项不得阻止其他可执行项。 @@ -95,7 +95,7 @@ node "/scripts/workspace_server.mjs" "<项目目录>" 主代理向用户展示待审内容并收集决定: -- 提示词:查看台的分段展示,或在对话中按来源分段(项目风格 / 渲染规格 / 构图 / 身份锚点 / 状态差量)加最终拼接文本;分段仅为展示,不写回状态。 +- 提示词:查看台的分段展示,或在对话中按来源分段(公共视觉语言 / 全局渲染语言 / 当前资产呈现 / 身份锚点 / 状态差量)加最终拼接文本;分段仅为展示,不写回状态。 - 图片:查看台预览优先使用项目内 `localPath`(结果回收后由 `download_artifacts.mjs` 下载写回);对话中给本地文件路径,不要给带时效签名的远程 URL。 - 用户的确认、修改意见或直接给出的新文本,由主代理转写为稀疏审查 patch 写入 `review-decisions.json`:`status`(approved / changes_requested)、`note`、`identityPrompt` / `deltaPrompt` 局部修改、默认态锁定(`lockedReference` + `artifactId`)。patch 的 `baseRevision` 必须等于该文件当前 `revision`。不提供、也不接受直接覆盖完整编译结果的整段替换。 diff --git a/assets/viewer-app/viewer.js b/assets/viewer-app/viewer.js index e7c7c31..72a1c68 100644 --- a/assets/viewer-app/viewer.js +++ b/assets/viewer-app/viewer.js @@ -129,6 +129,59 @@ function uniqueSegments(values) { return output; } +const fallbackPresentations = { + characterState: { prompt: "单一角色状态资产图,完整呈现当前造型、服装、发型、妆容、姿态与随身物变化,主体清晰且便于复用", ratio: "16:9" }, + location: { prompt: "单一场景环境设定图,宽幅构图,清晰呈现空间布局、建筑结构、材质、光线与可复用环境细节", ratio: "16:9" }, + prop: { prompt: "单一道具设定图,完整清晰呈现主体轮廓、结构、材质、尺度与关键识别细节", ratio: "4:3" } +}; + +const dedicatedDeliveryLayoutPatterns = [ + /(?:^|[^\d])\d+\s*[::]\s*\d+(?:[^\d]|$)/u, + /(?:角色|人物)(?:资产)?(?:设定板|设定图|设定卡|排版)/u, + /白底|纯白背景|白色(?:无缝|纯色|影棚)?背景|影棚背景/u, + /胸像|半身像|上半身肖像/u, + /三视图?|多视角|转面图|正侧背/u, + /正背面|正反面|正面(?:全身)?(?:图)?(?:与|和|及|、|\+|+|\/).*(?:背面|反面)|(?:背面|反面)(?:全身)?(?:图)?/u, + /全身站立|自然站姿|端正站姿/u, + /面部|五官|手部|发型|服装|配饰|背部设计|人物比例/u, + /并列展示|版式|排版/u, + /(?:画面)?左侧.*(?:画面)?右侧|(?:画面)?右侧.*(?:画面)?左侧/u +]; + +function sanitizeGlobalRenderLanguage(value) { + return uniqueSegments( + String(value ?? "") + .trim() + .replace(/\s+/g, " ") + .split(/[。!?;;,,\n]+/u) + .filter((segment) => segment && !dedicatedDeliveryLayoutPatterns.some((pattern) => pattern.test(segment))) + ).join(","); +} + +function resolvedPromptProfile() { + const profile = app.state.config?.promptProfile ?? {}; + const scoped = profile.version === 1 + || Object.prototype.hasOwnProperty.call(profile, "commonVisualLanguage") + || Object.prototype.hasOwnProperty.call(profile, "globalRenderLanguage"); + return { + commonVisualLanguage: scoped ? profile.commonVisualLanguage ?? "" : profile.style ?? "", + globalRenderLanguage: sanitizeGlobalRenderLanguage(scoped ? profile.globalRenderLanguage : profile.render) + }; +} + +function presentationScope(row) { + if (row.kind === "character") return row.isDefault ? "characterDefault" : "characterState"; + return row.kind; +} + +function presentationForRow(row) { + const scope = presentationScope(row); + const configured = app.state.config?.assetPresentation; + if (configured?.[scope]) return configured[scope]; + const setupEraProject = configured?.version === 1 || Boolean(configured?.characterDefault); + return setupEraProject ? fallbackPresentations[scope] ?? null : null; +} + function createRows() { const rows = []; for (const character of app.state?.assets?.characters ?? []) { @@ -173,17 +226,16 @@ function rowById(id) { } function promptSegments(row) { - const profile = app.state.config?.promptProfile ?? {}; - const presentation = row.kind === "character" && row.isDefault - ? app.state.config?.assetPresentation?.characterDefault?.prompt ?? "" - : ""; + const profile = resolvedPromptProfile(); + const presentationScopeName = presentationScope(row); + const presentation = presentationForRow(row); const identity = row.kind === "character" ? row.character?.identityPrompt ?? "" : ""; const legacy = row.asset.prompt ?? ""; const delta = row.asset.deltaPrompt ?? ""; const segments = [ - { key: "style", label: "项目风格", value: profile.style ?? "", meta: "项目级" }, - { key: "render", label: "渲染规范", value: profile.render ?? "", meta: "项目级" }, - { key: "presentation", label: "构图要求", value: presentation, meta: "默认角色构图" } + { key: "style", label: "公共视觉语言", value: profile.commonVisualLanguage, meta: "所有资产共享" }, + { key: "render", label: "全局渲染语言", value: profile.globalRenderLanguage, meta: "所有资产共享" }, + { key: "presentation", label: "资产呈现", value: presentation?.prompt ?? "", meta: ({ characterDefault: "角色默认态", characterState: "角色状态", location: "场景", prop: "道具" })[presentationScopeName] } ]; if (row.kind === "character") { segments.push({ key: "identityPrompt", label: "角色视觉设定", value: identity, meta: row.isDefault ? "所有状态共享" : "继承默认状态" }); diff --git a/references/project-schema.md b/references/project-schema.md index d47942d..1b36c00 100644 --- a/references/project-schema.md +++ b/references/project-schema.md @@ -31,8 +31,9 @@ "approvedAt": "2026-07-16T10:00:00.000Z" }, "promptProfile": { - "style": "公共画风", - "render": "公共渲染和构图规格" + "version": 1, + "commonVisualLanguage": "公共画风与统一视觉语言", + "globalRenderLanguage": "所有资产共享的材质、光影、清晰度与一致性语言" }, "visualPreset": { "id": "cg-stylized-3d", @@ -42,14 +43,33 @@ "manifestVersion": 1 }, "assetPresentation": { + "version": 1, "characterDefault": { "id": "closeup-front-back", "name": "特写 + 正背面三视图", "ratio": "4:3", "prompt": "白色影棚背景的角色资产排版:左侧胸像特写,右侧全身正面和背面站立图,画面紧凑清晰" + }, + "characterState": { + "id": "single-character-state", + "name": "单一角色状态图", + "ratio": "16:9", + "prompt": "单一角色状态资产图,完整呈现当前造型、服装、发型、妆容、姿态与随身物变化,主体清晰且便于复用" + }, + "location": { + "id": "environment-wide", + "name": "宽幅场景设定图", + "ratio": "16:9", + "prompt": "单一场景环境设定图,宽幅构图,清晰呈现空间布局、建筑结构、材质、光线与可复用环境细节" + }, + "prop": { + "id": "single-prop", + "name": "单一道具设定图", + "ratio": "4:3", + "prompt": "单一道具设定图,完整清晰呈现主体轮廓、结构、材质、尺度与关键识别细节" } }, - "image": { "ratio": "4:3", "quality": "2k" }, + "image": { "quality": "2k" }, "execution": { "mode": "staged", "reviewGate": "streaming", @@ -89,15 +109,19 @@ } ~~~ -schemaVersion 必须精确等于 3。当前版本不自动迁移旧项目。每次 canonical 数据变化递增 revision;styleProfile、promptProfile、项目创作方向、源剧本或批次范围变化后,旧执行确认按 hash 失效。 +schemaVersion 必须精确等于 3。呈现分域是 v3 内的兼容扩展,以 `promptProfile.version: 1` 和 `assetPresentation.version: 1` 判别;每次 canonical 数据变化递增 revision。styleProfile、promptProfile、资产呈现配置、项目创作方向、源剧本或批次范围变化后,旧执行确认按 hash 失效。 ## 故事资产圣经 ## 项目初始化选择 -新项目先进入 `workflow.setup: draft`。`workspace_server.mjs` 提供仅本机监听的统一工作台,初始化 Dialog 是它的首次浮层。用户单选视觉范式和默认角色资产构图,选择只写入项目内 `setup-decisions.json`;`apply_setup_decisions.mjs` 再将经过 manifest 校验的选择写入 `config.visualPreset`、`config.assetPresentation.characterDefault` 与 `workflow.setup.contentHash`。提交后工作台服务保持运行,通过 `project-state.json` 自动切换五阶段;选择不是聊天上下文,也不依赖浏览器状态,任一窗口接手都可从项目文件恢复。 +新项目先进入 `workflow.setup: draft`。`workspace_server.mjs` 提供仅本机监听的统一工作台,初始化 Dialog 是它的首次浮层。用户单选视觉范式和默认角色资产构图,选择只写入项目内 `setup-decisions.json`;`apply_setup_decisions.mjs` 再将经过 manifest 校验的选择写入 `config.visualPreset`、`config.assetPresentation.characterDefault` 与 `workflow.setup.contentHash`,同时固化角色状态、场景、道具的独立默认呈现配置。提交后工作台服务保持运行,通过 `project-state.json` 自动切换五阶段;选择不是聊天上下文,也不依赖浏览器状态,任一窗口接手都可从项目文件恢复。 + +预览资源位于 skill 内的 `assets/setup-app/`,仅用于用户理解差异,永不作为生成参考图上传。真正影响生图的是 `visualPreset.stylePromptSeed`(在确认创作方向时合并进公共视觉语言)和四类 `assetPresentation`。`characterDefault` 只作用于每个角色的 `sequence: 0`;`characterState` 只作用于 `sequence > 0`;`location` 与 `prop` 只作用于各自资产类型。每类最终比例只读自身 `ratio`,不得读取 `characterDefault.ratio` 或全局 `config.image.ratio`。选择“按剧本生成”时风格种子可以为空,由 AI 美术总监从剧本与创作方向自主决定视觉语言。 + +`config.image` 只保存跨类型共享的质量等生成参数。历史 `config.image.ratio` 仍可读取,但只作为缺少角色默认态呈现配置的旧项目兜底;新分域项目的批次 item 不从它取比例。 -预览资源位于 skill 内的 `assets/setup-app/`,仅用于用户理解差异,永不作为生成参考图上传。真正影响生图的是 `visualPreset.stylePromptSeed`(在确认创作方向时合并进公共视觉语言)和默认角色态的 `characterDefault.prompt` / `ratio`。选择“按剧本生成”时风格种子可以为空,由 AI 美术总监从剧本与创作方向自主决定视觉语言。这套构图仅作用于每个角色的 `sequence: 0` 默认资产,避免污染后续状态、场景和道具。 +旧项目兼容采用只读适配,不提升顶层 schemaVersion:历史 `promptProfile.style` 读取为公共视觉语言;历史 `promptProfile.render` 按全局渲染语言读取,但其中命中角色交付版式的分句会在编译和查看时过滤,validator 给出警告。已存在 `characterDefault`、但缺少 `characterState` / `location` / `prop` 的 setup-era 项目,运行时分别使用内置分域默认值,不修改 `project-state.json`;更早、完全没有 `assetPresentation` 的项目继续使用历史 `config.image.ratio`,避免无关旧项目被强制改画幅。若希望把兼容结果持久化,可在备份或派生项目中用原 `setup-decisions.json` 重新运行 `apply_setup_decisions.mjs`;相同选择只补齐四类呈现配置,不改已锁定创作方向、成功 artifact 或参考锚点。任何因此产生的新 promptHash / generationKey 都必须重新审查和确认后才能提交。 项目创作方向锁定后,视觉范式和默认角色构图不允许在原项目更改;需要变化时派生项目重新选择。历史项目没有 `workflow.setup` 时保持兼容,不会因该字段缺失被阻塞。 @@ -109,9 +133,9 @@ schemaVersion 必须精确等于 3。当前版本不自动迁移旧项目。每 ## 项目创作方向 -`art-direction-brief.md` 是 AI 美术总监依据已锁定故事资产圣经提出、并由用户确认的项目视觉方向。它包含创作命题、用户确认方向、公共视觉语言、默认渲染规格、正向设计重点、跨资产设计原则和待确认项。 +`art-direction-brief.md` 是 AI 美术总监依据已锁定故事资产圣经提出、并由用户确认的项目视觉方向。它包含创作命题、用户确认方向、公共视觉语言、全局渲染语言、正向设计重点、跨资产设计原则和待确认项。 -`record_art_direction_brief.mjs` 要求故事资产圣经和项目初始化选择均已锁定,将创作方向的 `contentHash`、故事资产圣经 hash、初始化选择 hash 与剧本 hash 固化到 `workflow.artDirection`,同时更新 `config.styleProfile`、`config.promptProfile.style` 与 `config.promptProfile.render`。当 `promptLanguage` 为默认的 `zh-CN` 时,这两项必须以中文为主。确认后不能在原项目内修改;若故事资产圣经或初始化选择改变,创作方向自动失效,必须派生项目重新确认。 +`record_art_direction_brief.mjs` 要求故事资产圣经和项目初始化选择均已锁定,将创作方向的 `contentHash`、故事资产圣经 hash、初始化选择 hash 与剧本 hash 固化到 `workflow.artDirection`,同时更新 `config.styleProfile`、`promptProfile.commonVisualLanguage` 与 `promptProfile.globalRenderLanguage`。两项必须以中文为主;全局渲染语言只允许所有资产都适用的材质、光影、清晰度和一致性描述,白底、胸像、半身像、多视图、正背面、角色站姿、左右排版和画幅比例会被拒绝。确认后不能在原项目内修改;若故事资产圣经或初始化选择改变,创作方向自动失效,必须派生项目重新确认。 运行时只使用 `creative-charter.md` 和 `prompt-authoring.md` 的短原则,不将外部美术知识库原文视为强制提示词。模型可以自主推导材料、发型、配色和设计语言,但不得违反剧本事实、用户确认、生产不变量或明确待确认项。 @@ -149,7 +173,7 @@ schemaVersion 必须精确等于 3。当前版本不自动迁移旧项目。每 每名角色恰好一个 sequence: 0。后续状态直接引用该默认态,不得形成链式引用。identityPrompt 只存稳定可见身份;deltaPrompt 只存当前状态变化。默认 `promptLanguage: zh-CN` 时,两者均以中文为主。完整 prompt 由 build_image_batch.mjs 按固定顺序编译,不回写为重复长文本。 -向用户展示时,"角色描述"读取 `character.description`,用于帮助用户理解剧情身份,不参与模型输入。"最终生图提示词"只是对 `promptProfile.style`、`promptProfile.render`、默认角色构图、`identityPrompt`、`deltaPrompt` 和兼容 `prompt` 的分段展示;只有已存在的分层字段可通过审查 patch 局部修改,不得引入独立的最终 prompt 真相源。 +向用户展示时,"角色描述"读取 `character.description`,用于帮助用户理解剧情身份,不参与模型输入。"最终生图提示词"按固定顺序编译:公共视觉语言 → 全局渲染语言 → 当前资产类型的呈现 prompt → 角色身份锚点(仅角色)→ 当前差量 → 兼容 `prompt`。分段仅用于展示;只有已存在的分层字段可通过审查 patch 局部修改,不得引入独立的最终 prompt 真相源。 ## 独立审查轴与执行轴 @@ -163,7 +187,7 @@ readiness 必须实时检查: 1. 新项目的初始化选择已完成、已锁定。 2. 故事资产圣经已完成、已锁定,且文件内容匹配记录的 contentHash。 3. 项目创作方向已完成、已锁定,并绑定当前故事资产圣经和初始化选择。 -4. 风格、比例和质量存在;默认角色资产优先使用选择的构图比例。 +4. 风格、质量和当前资产类型的独立呈现配置存在;最终比例来自该呈现配置。 5. 分层 prompt 可编译且 promptStatus=approved。 6. 后续角色状态直接引用本角色默认态。 7. 默认态 execution.status=succeeded、图片有有效定位、用户锁定确切 artifactId。 @@ -226,7 +250,7 @@ review-decisions.json 只保存发生变化的字段: ## 批次与结果绑定 -批次项保存 promptHash、promptSourceHash、generationKey、结构化 reference 和 sourceStateRevision。generationKey 至少覆盖: +批次项保存 promptHash、promptSourceHash、generationKey、`presentation: { scope, id }`、结构化 reference 和 sourceStateRevision。generationKey 至少覆盖: - projectId 与 itemId - 编译后的 promptHash diff --git a/references/prompt-authoring.md b/references/prompt-authoring.md index bd5ec7a..98d1761 100644 --- a/references/prompt-authoring.md +++ b/references/prompt-authoring.md @@ -5,7 +5,7 @@ Prompt Agent 是 AI 美术总监的执行阶段,而不是规则填空器。运 ## 工作顺序 1. 从故事资产圣经读取事实、人物关系、状态变化、场景用途与不确定项。 -2. 从项目创作方向读取用户确认的视觉命题、公共视觉语言、默认渲染规格与正向设计重点;其中公共视觉语言已包含用户单选的视觉范式中文种子。 +2. 从项目创作方向读取用户确认的视觉命题、公共视觉语言、全局渲染语言与正向设计重点;其中公共视觉语言已包含用户单选的视觉范式中文种子。 3. 使用 `creative-charter.md` 自主推导视觉设计;先形成简短设计理由,再写 prompt。 4. 用中文仅把稳定可见身份写入 `identityPrompt`;用中文仅把本状态相对默认态的变化写入 `deltaPrompt`。 5. 场景和道具直接写其当前可见设计,但区分剧情事实与自主设计;不确定时保守并标记待确认。 @@ -13,10 +13,13 @@ Prompt Agent 是 AI 美术总监的执行阶段,而不是规则填空器。运 ## 字段边界 -- `promptProfile.style`:项目公共视觉语言。 -- `promptProfile.render`:默认资产的渲染和构图规格。 -- `config.visualPreset.stylePromptSeed`:初始化 Dialog 固化的视觉范式种子;由系统合并进 `promptProfile.style`,不需要也不得重写为参考图输入。 -- `config.assetPresentation.characterDefault`:仅 sequence: 0 的角色资产继承其正向排版描述和比例;不适用于后续状态、场景或道具。 +- `promptProfile.commonVisualLanguage`:项目公共视觉语言,所有资产共享。 +- `promptProfile.globalRenderLanguage`:所有资产共享的材质、光影、清晰度和一致性语言;不得包含白底、胸像、半身像、多视图、正背面、角色站姿、左右排版或画幅比例。 +- `config.visualPreset.stylePromptSeed`:初始化 Dialog 固化的视觉范式种子;由系统合并进 `promptProfile.commonVisualLanguage`,不需要也不得重写为参考图输入。 +- `config.assetPresentation.characterDefault`:仅 sequence: 0 的角色资产继承。 +- `config.assetPresentation.characterState`:仅 sequence > 0 的角色状态继承。 +- `config.assetPresentation.location`:仅场景资产继承。 +- `config.assetPresentation.prop`:仅道具资产继承。四类配置各自提供 prompt 与 ratio,不读取角色默认态或全局比例。 - `identityPrompt`:角色跨状态不变的面孔、体态、发型、固定穿戴和识别锚点。 - `deltaPrompt`:当前状态的服装、姿态、状态变化、环境适配与必要的剧情差量。 - `description`、`visualBrief`、`sourceRefs`:解释与证据,不直接当作供应商 prompt。 @@ -25,7 +28,7 @@ Prompt Agent 是 AI 美术总监的执行阶段,而不是规则填空器。运 初始化 Dialog 中的示例图只提供给用户识别视觉方向和构图类型,绝不随 prompt、批次或 AWB 请求发送。 -向用户展示最终 prompt 时可按来源分段说明(项目风格 / 渲染规格 / 构图 / 身份锚点 / 状态差量),但分段只属于展示层,不写回项目状态。项目风格、渲染规范和构图要求项目级只读;默认角色的 `identityPrompt` 与当前资产的 `deltaPrompt` 依据用户在对话中的意见,通过审查 patch(review-decisions.json → apply_review_decisions.mjs)局部修改。不提供、也不接受直接覆盖完整编译结果的整段替换。 +向用户展示最终 prompt 时可按来源分段说明(公共视觉语言 / 全局渲染语言 / 当前资产呈现 / 身份锚点 / 状态差量),但分段只属于展示层,不写回项目状态。公共语言和四类呈现配置只读;默认角色的 `identityPrompt` 与当前资产的 `deltaPrompt` 依据用户在对话中的意见,通过审查 patch(review-decisions.json → apply_review_decisions.mjs)局部修改。不提供、也不接受直接覆盖完整编译结果的整段替换。 ## 自检 diff --git a/scripts/apply_setup_decisions.mjs b/scripts/apply_setup_decisions.mjs index 51e6ee9..571faec 100644 --- a/scripts/apply_setup_decisions.mjs +++ b/scripts/apply_setup_decisions.mjs @@ -2,7 +2,7 @@ import { readFile, writeFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import path from "node:path"; -import { hashValue, nextRevision, readJson, schemaVersion, writeJsonAtomic } from "./lib/project.mjs"; +import { assetPresentationVersion, defaultScopedPresentations, hashValue, nextRevision, readJson, schemaVersion, writeJsonAtomic } from "./lib/project.mjs"; const [stateFile] = process.argv.slice(2); if (!stateFile || stateFile.startsWith("--")) { @@ -49,14 +49,18 @@ if (!preset || !layout) { const decisionsHash = hashValue({ manifestVersion: manifest.version, visualPresetId: preset.id, characterLayoutId: layout.id }); const existing = state.workflow?.setup; const same = existing?.status === "complete" && existing.contentHash === decisionsHash; -if (same) { +const currentPresentation = state.config?.assetPresentation ?? {}; +const scopedPresentationComplete = currentPresentation.version === assetPresentationVersion + && ["characterDefault", "characterState", "location", "prop"].every((scope) => currentPresentation[scope]?.prompt && currentPresentation[scope]?.ratio); +if (same && scopedPresentationComplete) { console.log("项目初始化配置已应用且未变化;无需重复写入。"); process.exit(0); } +const presentationOnlyUpgrade = same && !scopedPresentationComplete; const lockedDirection = state.workflow?.artDirection?.status === "complete"; // setup 功能上线前锁定的存量项目 setupHash 为空:允许补做初始化解锁流程,不改动任何已锁定内容。 const grandfathered = lockedDirection && state.workflow.artDirection.setupHash == null; -if (lockedDirection && !grandfathered) { +if (lockedDirection && !grandfathered && !presentationOnlyUpgrade) { console.error("项目创作方向已锁定,不能在原项目内更改视觉范式或角色构图。请派生项目后重新选择。"); process.exit(1); } @@ -100,6 +104,12 @@ state.config.visualPreset = { selectedAt: new Date().toISOString() }; state.config.assetPresentation = { + version: assetPresentationVersion, + characterState: { ...defaultScopedPresentations.characterState }, + location: { ...defaultScopedPresentations.location }, + prop: { ...defaultScopedPresentations.prop }, + ...currentPresentation, + version: assetPresentationVersion, characterDefault: { id: layout.id, name: layout.name, @@ -107,6 +117,9 @@ state.config.assetPresentation = { prompt: layout.prompt } }; +if (presentationOnlyUpgrade) { + console.log("存量项目:已补齐角色状态、场景和道具的独立呈现配置;创作方向与既有资产保持不变。"); +} if (grandfathered) { state.workflow.artDirection.setupHash = decisionsHash; console.log("存量项目:已回填创作方向的 setupHash;已锁定的创作方向文件与提示词保持不变。"); diff --git a/scripts/bootstrap_art_direction_brief.mjs b/scripts/bootstrap_art_direction_brief.mjs index 2c7ac0c..e48bfe6 100644 --- a/scripts/bootstrap_art_direction_brief.mjs +++ b/scripts/bootstrap_art_direction_brief.mjs @@ -43,7 +43,7 @@ try { const lockedSelection = selectedPreset ? `## 已选视觉范式(锁定输入)\n\n- 名称:${selectedPreset.name}\n- 提示词风格种子:${selectedPreset.stylePromptSeed}\n- 说明:这是用户在初始化 Dialog 中的一次性选择。保留其视觉范式意图;不要把预览图、样例主体或外部知识库细节当作模型参考图。\n\n` : "## 已选视觉范式(锁定输入)\n\n待完成初始化 Dialog 后自动写入。\n\n"; - await writeFile(outputPath, `# 项目创作方向\n\n> 由 AI 美术总监基于已锁定的故事资产圣经提出;用户确认后运行 record_art_direction_brief.mjs。\n> 不要复制外部美术知识库。请使用模型的自主设计能力,并遵守 references/creative-charter.md。\n> 最终 prompt 只使用正向描述;不要生成负向提示词、排除词清单或“避免……”补丁。\n> 本 skill 面向中文使用者:公共视觉语言、默认渲染规格和后续资产提示词均以中文为主;专有名词可保留必要原文。\n\n${lockedSelection}## 创作命题\n\n待补充:用一段话说明本项目希望观众首先感受到的世界、情绪和设计张力。\n\n## 用户确认的方向\n\n待补充:记录用户选择的方向、参考意图和创作自由度。\n\n## 公共视觉语言\n\n待补充:用中文将已确认的画风、视觉气质、色彩/材质倾向和统一设计语言写成可直接拼入 prompt 的短文本。本节与"默认渲染规格"会逐字拼进每一条生图提示词:只写描述画面本身的语言,不要写流程说明、分配规则或系统行为(如"后续资产如何选择构图")——这些由管线自动处理,写进来会成为每张图的提示词噪声。\n\n## 默认渲染规格\n\n待补充:用中文说明默认资产的构图、主体清晰度、背景控制、光线和一致性目标,写成可直接拼入 prompt 的短文本。同样只写画面语言,不写流程叙述。\n\n## 正向设计重点\n\n待补充:列出本项目最应被看见和保持一致的设计重点,不写排除词或反向清单。\n\n## 角色、场景与道具设计原则\n\n待补充:说明它们如何共享世界感、如何形成差异,以及哪些设计应保持稳定。\n\n## 待确认项\n\n待补充:列出不能由模型自行决定的分歧;若没有,请明确写“无”。\n`, "utf8"); + await writeFile(outputPath, `# 项目创作方向\n\n> 由 AI 美术总监基于已锁定的故事资产圣经提出;用户确认后运行 record_art_direction_brief.mjs。\n> 不要复制外部美术知识库。请使用模型的自主设计能力,并遵守 references/creative-charter.md。\n> 最终 prompt 只使用正向描述;不要生成负向提示词、排除词清单或“避免……”补丁。\n> 本 skill 面向中文使用者:公共视觉语言、全局渲染语言和后续资产提示词均以中文为主;专有名词可保留必要原文。\n> 白底、胸像、三视图、正背面、排版和画幅比例属于具体资产呈现配置,禁止写入全局渲染语言。\n\n${lockedSelection}## 创作命题\n\n待补充:用一段话说明本项目希望观众首先感受到的世界、情绪和设计张力。\n\n## 用户确认的方向\n\n待补充:记录用户选择的方向、参考意图和创作自由度。\n\n## 公共视觉语言\n\n待补充:用中文将已确认的画风、视觉气质、色彩/材质倾向和统一设计语言写成可直接拼入 prompt 的短文本。本节会逐字拼进每一条生图提示词;只写描述画面本身的语言,不写流程说明、分配规则或系统行为。\n\n## 全局渲染语言\n\n待补充:用中文说明所有资产都适用的材质表现、光影质量、细节清晰度和一致性目标,写成可直接拼入 prompt 的短文本。不得写白底、胸像、半身像、三视图、多视图、正背面、左右排版、角色站姿或任何画幅比例;这些由角色默认态、角色状态、场景、道具各自的 assetPresentation 配置提供。\n\n## 正向设计重点\n\n待补充:列出本项目最应被看见和保持一致的设计重点,不写排除词或反向清单。\n\n## 角色、场景与道具设计原则\n\n待补充:说明它们如何共享世界感、如何形成差异,以及哪些设计应保持稳定。\n\n## 待确认项\n\n待补充:列出不能由模型自行决定的分歧;若没有,请明确写“无”。\n`, "utf8"); } const next = { diff --git a/scripts/build_image_batch.mjs b/scripts/build_image_batch.mjs index 147c732..4591617 100644 --- a/scripts/build_image_batch.mjs +++ b/scripts/build_image_batch.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import path from "node:path"; import { access, readFile } from "node:fs/promises"; -import { hashValue, imageLocator, imageSettingsForRecord, listAssets, readJson, readiness, schemaVersion, styleHash, writeJsonAtomic } from "./lib/project.mjs"; +import { assetPresentation, assetPresentationScope, hashValue, imageLocator, imageSettingsForRecord, listAssets, readJson, readiness, schemaVersion, styleHash, writeJsonAtomic } from "./lib/project.mjs"; function option(name, fallback) { const position = process.argv.indexOf(name); @@ -88,6 +88,8 @@ for (const record of listAssets(state).filter(matchesScope)) { : null; const promptHash = hashValue(check.prompt.text); const image = imageSettingsForRecord(state, record); + const presentation = assetPresentation(state, record); + const presentationScope = assetPresentationScope(record); const reference = referenceState ? { stateId: referenceState.id, artifactId: referenceState.referenceAnchor.artifactId, @@ -111,6 +113,7 @@ for (const record of listAssets(state).filter(matchesScope)) { promptHash, promptSourceHash: check.prompt.sourceHash, generationKey, + presentation: presentation ? { scope: presentationScope, id: presentation.id ?? null } : null, image, reference }); @@ -122,12 +125,14 @@ if (errors.length) { } const createdAt = new Date().toISOString(); +const sharedImageConfig = { ...(state.config?.image ?? {}) }; +delete sharedImageConfig.ratio; const batchCore = { projectId: state.project?.id, sourceStateRevision: state.revision ?? 0, styleHash: styleHash(state), scope, - image: state.config?.image ?? null, + image: sharedImageConfig, items }; const batchHash = hashValue(batchCore); diff --git a/scripts/lib/project.mjs b/scripts/lib/project.mjs index 9ee7aa1..10fa183 100644 --- a/scripts/lib/project.mjs +++ b/scripts/lib/project.mjs @@ -3,6 +3,29 @@ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import path from "node:path"; export const schemaVersion = 3; +export const promptProfileVersion = 1; +export const assetPresentationVersion = 1; + +export const defaultScopedPresentations = Object.freeze({ + characterState: Object.freeze({ + id: "single-character-state", + name: "单一角色状态图", + ratio: "16:9", + prompt: "单一角色状态资产图,完整呈现当前造型、服装、发型、妆容、姿态与随身物变化,主体清晰且便于复用" + }), + location: Object.freeze({ + id: "environment-wide", + name: "宽幅场景设定图", + ratio: "16:9", + prompt: "单一场景环境设定图,宽幅构图,清晰呈现空间布局、建筑结构、材质、光线与可复用环境细节" + }), + prop: Object.freeze({ + id: "single-prop", + name: "单一道具设定图", + ratio: "4:3", + prompt: "单一道具设定图,完整清晰呈现主体轮廓、结构、材质、尺度与关键识别细节" + }) +}); export async function readJson(file) { return JSON.parse(await readFile(file, "utf8")); @@ -66,6 +89,53 @@ function uniqueSegments(values) { return output; } +const dedicatedDeliveryLayoutPatterns = [ + ["画幅比例", /(?:^|[^\d])\d+\s*[::]\s*\d+(?:[^\d]|$)/u], + ["角色设定板", /(?:角色|人物)(?:资产)?(?:设定板|设定图|设定卡|排版)/u], + ["白底或影棚背景", /白底|纯白背景|白色(?:无缝|纯色|影棚)?背景|影棚背景/u], + ["胸像或半身像", /胸像|半身像|上半身肖像/u], + ["多视图", /三视图?|多视角|转面图|正侧背/u], + ["正背面交付", /正背面|正反面|正面(?:全身)?(?:图)?(?:与|和|及|、|\+|+|\/).*(?:背面|反面)|(?:背面|反面)(?:全身)?(?:图)?/u], + ["角色站姿", /全身站立|自然站姿|端正站姿/u], + ["角色局部细节", /面部|五官|手部|发型|服装|配饰|背部设计|人物比例/u], + ["交付排版", /并列展示|版式|排版/u], + ["版式位置", /(?:画面)?左侧.*(?:画面)?右侧|(?:画面)?右侧.*(?:画面)?左侧/u] +]; + +export function findDedicatedDeliveryLayoutTerms(value) { + const text = cleanSegment(value); + if (!text) return []; + return dedicatedDeliveryLayoutPatterns.filter(([, pattern]) => pattern.test(text)).map(([label]) => label); +} + +export function sanitizeGlobalRenderLanguage(value) { + const text = cleanSegment(value); + if (!text) return ""; + return uniqueSegments( + text + .split(/[。!?;;,,\n]+/u) + .map((segment) => cleanSegment(segment)) + .filter((segment) => segment && findDedicatedDeliveryLayoutTerms(segment).length === 0) + ).join(","); +} + +export function resolvedPromptProfile(state) { + const profile = state.config?.promptProfile ?? {}; + const usesScopedProfile = profile.version === promptProfileVersion + || Object.hasOwn(profile, "commonVisualLanguage") + || Object.hasOwn(profile, "globalRenderLanguage"); + const commonVisualLanguage = cleanSegment(usesScopedProfile ? profile.commonVisualLanguage : profile.style); + const rawGlobalRenderLanguage = cleanSegment(usesScopedProfile ? profile.globalRenderLanguage : profile.render); + return { + version: usesScopedProfile ? promptProfileVersion : null, + commonVisualLanguage, + globalRenderLanguage: sanitizeGlobalRenderLanguage(rawGlobalRenderLanguage), + rawGlobalRenderLanguage, + removedDedicatedTerms: findDedicatedDeliveryLayoutTerms(rawGlobalRenderLanguage), + legacy: !usesScopedProfile + }; +} + export function isPrimarilyChinese(value) { const text = cleanSegment(value); if (!text) return false; @@ -75,12 +145,13 @@ export function isPrimarilyChinese(value) { } export function compilePrompt(state, record) { - const profile = state.config?.promptProfile ?? {}; + const profile = resolvedPromptProfile(state); const asset = record.asset; + const presentationScope = assetPresentationScope(record); const presentation = assetPresentation(state, record); const positive = uniqueSegments([ - profile.style, - profile.render, + profile.commonVisualLanguage, + profile.globalRenderLanguage, presentation?.prompt, record.character?.identityPrompt, asset.deltaPrompt, @@ -91,9 +162,12 @@ export function compilePrompt(state, record) { text, sourceHash: hashValue({ promptProfile: { - style: profile.style ?? null, - render: profile.render ?? null + version: profile.version, + commonVisualLanguage: profile.commonVisualLanguage || null, + globalRenderLanguage: profile.globalRenderLanguage || null, + rawGlobalRenderLanguage: profile.rawGlobalRenderLanguage || null }, + presentationScope, presentation: presentation ? { id: presentation.id ?? null, ratio: presentation.ratio ?? null, prompt: presentation.prompt ?? null } : null, identityPrompt: record.character?.identityPrompt ?? null, deltaPrompt: asset.deltaPrompt ?? null, @@ -111,17 +185,31 @@ export function hasCompletedSetup(state) { return !current || Boolean(current.status === "complete" && typeof current.contentHash === "string" && current.contentHash); } +export function assetPresentationScope(record) { + if (record.assetType === "character-state") return record.asset.sequence === 0 ? "characterDefault" : "characterState"; + if (record.assetType === "location") return "location"; + if (record.assetType === "prop") return "prop"; + return null; +} + export function assetPresentation(state, record) { - if (record.assetType !== "character-state" || record.asset.sequence !== 0) return null; - return state.config?.assetPresentation?.characterDefault ?? null; + const scope = assetPresentationScope(record); + if (!scope) return null; + const configuredPresentation = state.config?.assetPresentation; + const configured = configuredPresentation?.[scope]; + if (configured) return configured; + const setupEraProject = configuredPresentation?.version === assetPresentationVersion || Boolean(configuredPresentation?.characterDefault); + return setupEraProject ? defaultScopedPresentations[scope] ?? null : null; } export function imageSettingsForRecord(state, record) { const presentation = assetPresentation(state, record); - return { - ...(state.config?.image ?? {}), - ...(presentation?.ratio ? { ratio: presentation.ratio } : {}) - }; + const image = { ...(state.config?.image ?? {}) }; + const legacyRatio = image.ratio; + delete image.ratio; + const ratio = presentation?.ratio + ?? (state.config?.assetPresentation?.version === assetPresentationVersion ? null : legacyRatio); + return { ...image, ...(ratio ? { ratio } : {}) }; } export function styleHash(state) { @@ -187,9 +275,11 @@ export function readiness(record, state) { if (!image.quality) reasons.push("缺少 config.image.quality"); if (!prompt.text) reasons.push("缺少可编译提示词"); if (state.config?.promptLanguage === "zh-CN") { + const profile = resolvedPromptProfile(state); const languageFields = [ - ["项目公共视觉语言", state.config?.promptProfile?.style], - ["默认渲染规格", state.config?.promptProfile?.render], + ["项目公共视觉语言", profile.commonVisualLanguage], + ["全局渲染语言", profile.globalRenderLanguage], + ["资产呈现描述", assetPresentation(state, record)?.prompt], ["角色身份锚点", record.character?.identityPrompt], ["当前状态差量", record.asset.deltaPrompt ?? record.asset.prompt] ]; diff --git a/scripts/record_art_direction_brief.mjs b/scripts/record_art_direction_brief.mjs index ed10d51..042fbd1 100644 --- a/scripts/record_art_direction_brief.mjs +++ b/scripts/record_art_direction_brief.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { readFile } from "node:fs/promises"; import path from "node:path"; -import { hasCompletedSetup, hasCompletedStoryBible, hashValue, isPrimarilyChinese, nextRevision, readJson, schemaVersion, writeJsonAtomic } from "./lib/project.mjs"; +import { findDedicatedDeliveryLayoutTerms, hasCompletedSetup, hasCompletedStoryBible, hashValue, isPrimarilyChinese, nextRevision, promptProfileVersion, readJson, schemaVersion, writeJsonAtomic } from "./lib/project.mjs"; function option(name, fallback) { const position = process.argv.indexOf(name); @@ -26,7 +26,7 @@ if (!stateFile || stateFile.startsWith("--")) { process.exit(1); } -const requiredHeadings = ["创作命题", "用户确认的方向", "公共视觉语言", "默认渲染规格", "正向设计重点", "角色、场景与道具设计原则", "待确认项"]; +const requiredHeadings = ["创作命题", "用户确认的方向", "公共视觉语言", "全局渲染语言", "正向设计重点", "角色、场景与道具设计原则", "待确认项"]; const statePath = path.resolve(stateFile); const projectRoot = path.dirname(statePath); const state = await readJson(statePath); @@ -52,18 +52,25 @@ if (briefRelativePath.startsWith("..") || path.isAbsolute(briefRelativePath)) { } const brief = await readFile(briefPath, "utf8"); const values = Object.fromEntries(requiredHeadings.map((heading) => [heading, clean(section(brief, heading))])); +// 兼容已经生成但尚未锁定的旧模板;旧标题内容仍按新的全局边界校验。 +if (!values["全局渲染语言"]) values["全局渲染语言"] = clean(section(brief, "默认渲染规格")); const missing = requiredHeadings.filter((heading) => !values[heading] || /(^|\n)-?\s*待补充/.test(values[heading])); if (missing.length) { console.error("项目创作方向尚未完成以下章节:" + missing.join("、")); process.exit(1); } if (state.config?.promptLanguage === "zh-CN") { - const nonChinese = ["公共视觉语言", "默认渲染规格"].filter((heading) => !isPrimarilyChinese(values[heading])); + const nonChinese = ["公共视觉语言", "全局渲染语言"].filter((heading) => !isPrimarilyChinese(values[heading])); if (nonChinese.length) { console.error("以下章节必须以中文为主:" + nonChinese.join("、")); process.exit(1); } } +const dedicatedTerms = findDedicatedDeliveryLayoutTerms(values["全局渲染语言"]); +if (dedicatedTerms.length) { + console.error("全局渲染语言包含资产专属交付版式:" + dedicatedTerms.join("、") + "。请把白底、胸像、多视图、正背面、画幅比例等要求留在对应 assetPresentation 配置中。"); + process.exit(1); +} state.workflow = { appliedOps: [], ...(state.workflow ?? {}) }; const contentHash = hashValue(brief); @@ -100,8 +107,9 @@ state.config.styleProfile = { approvedAt: new Date().toISOString() }; state.config.promptProfile = { - style: [state.config?.visualPreset?.stylePromptSeed, values["公共视觉语言"]].filter(Boolean).join("。 ").replace(/\s+/g, " "), - render: values["默认渲染规格"].replace(/\s+/g, " ") + version: promptProfileVersion, + commonVisualLanguage: [state.config?.visualPreset?.stylePromptSeed, values["公共视觉语言"]].filter(Boolean).join("。 ").replace(/\s+/g, " "), + globalRenderLanguage: values["全局渲染语言"].replace(/\s+/g, " ") }; nextRevision(state); await writeJsonAtomic(statePath, state); diff --git a/scripts/run_project.mjs b/scripts/run_project.mjs index b5820cd..c5b6661 100644 --- a/scripts/run_project.mjs +++ b/scripts/run_project.mjs @@ -3,7 +3,7 @@ import { copyFile, mkdir, readFile } from "node:fs/promises"; import { spawnSync } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { hashValue, readJson, schemaVersion, writeJsonAtomic } from "./lib/project.mjs"; +import { assetPresentationVersion, defaultScopedPresentations, hashValue, promptProfileVersion, readJson, schemaVersion, writeJsonAtomic } from "./lib/project.mjs"; function option(name, fallback) { const position = process.argv.indexOf(name); @@ -54,8 +54,14 @@ try { mode: "standalone", styleProfile: null, promptLanguage: "zh-CN", - promptProfile: { style: "", render: "" }, - image: { ratio: null, quality: null }, + promptProfile: { version: promptProfileVersion, commonVisualLanguage: "", globalRenderLanguage: "" }, + assetPresentation: { + version: assetPresentationVersion, + characterState: { ...defaultScopedPresentations.characterState }, + location: { ...defaultScopedPresentations.location }, + prop: { ...defaultScopedPresentations.prop } + }, + image: { quality: null }, execution: { mode: "staged", reviewGate: "streaming", confirmation: "batch", concurrency: 4 } }, workflow: { appliedOps: [], setup: { path: "setup-decisions.json", status: "draft", contentHash: null, updatedAt: new Date().toISOString(), completedAt: null } }, diff --git a/scripts/validate_project_state.mjs b/scripts/validate_project_state.mjs index 052eb4f..2d15ac0 100644 --- a/scripts/validate_project_state.mjs +++ b/scripts/validate_project_state.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { artDirection, defaultState, executionStatus, hasCompletedArtDirection, hasCompletedSetup, hasCompletedStoryBible, hashValue, imageLocator, isPrimarilyChinese, listAssets, readJson, readiness, schemaVersion, setup, storyBible } from "./lib/project.mjs"; +import { artDirection, assetPresentation, assetPresentationScope, assetPresentationVersion, defaultState, executionStatus, hasCompletedArtDirection, hasCompletedSetup, hasCompletedStoryBible, hashValue, imageLocator, imageSettingsForRecord, isPrimarilyChinese, listAssets, promptProfileVersion, readJson, readiness, resolvedPromptProfile, schemaVersion, setup, storyBible } from "./lib/project.mjs"; import { access, readFile } from "node:fs/promises"; import path from "node:path"; @@ -51,6 +51,20 @@ if (initialSetup) { assert(typeof state.config?.visualPreset?.stylePromptSeed === "string", "完成初始化后必须有视觉范式种子字段"); assert(typeof state.config?.assetPresentation?.characterDefault?.ratio === "string" && state.config.assetPresentation.characterDefault.ratio, "完成初始化后必须有默认角色构图比例"); assert(typeof state.config?.assetPresentation?.characterDefault?.prompt === "string" && state.config.assetPresentation.characterDefault.prompt, "完成初始化后必须有默认角色构图描述"); + if (state.config?.assetPresentation?.version === assetPresentationVersion) { + for (const scope of ["characterDefault", "characterState", "location", "prop"]) { + const presentation = state.config.assetPresentation[scope]; + assert(Boolean(presentation), "assetPresentation." + scope + " 是分域呈现模型的必填配置"); + assert(typeof presentation?.id === "string" && presentation.id, "assetPresentation." + scope + ".id 必须为非空字符串"); + assert(typeof presentation?.ratio === "string" && /^\d+\s*:\s*\d+$/.test(presentation.ratio), "assetPresentation." + scope + ".ratio 必须为独立画幅比例"); + assert(typeof presentation?.prompt === "string" && presentation.prompt.trim(), "assetPresentation." + scope + ".prompt 必须为独立呈现描述"); + if (state.config?.promptLanguage === "zh-CN" && presentation?.prompt?.trim()) { + assert(isPrimarilyChinese(presentation.prompt), "assetPresentation." + scope + ".prompt 必须以中文为主"); + } + } + } else { + warnings.push("旧项目尚未持久化四类 assetPresentation;运行时将使用分域兼容默认值,可重新运行 apply_setup_decisions.mjs 补齐"); + } } if (forBatch && !hasCompletedSetup(state)) errors.push("正式建批前必须完成项目初始化配置"); } @@ -110,9 +124,17 @@ if (direction) { if (!state.config?.styleProfile) warnings.push("未设置 styleProfile:资产必须停留在 needs_style 或非执行状态"); if (!state.config?.promptProfile) warnings.push("未设置 promptProfile:将使用兼容字段 prompt"); if (state.config?.promptLanguage != null) assert(state.config.promptLanguage === "zh-CN", "config.promptLanguage 目前仅支持 zh-CN"); +const promptProfile = resolvedPromptProfile(state); +if (state.config?.promptProfile?.version === promptProfileVersion) { + assert(typeof state.config.promptProfile.commonVisualLanguage === "string", "promptProfile.commonVisualLanguage 必须为字符串"); + assert(typeof state.config.promptProfile.globalRenderLanguage === "string", "promptProfile.globalRenderLanguage 必须为字符串"); + assert(promptProfile.removedDedicatedTerms.length === 0, "promptProfile.globalRenderLanguage 不得包含资产专属交付版式:" + promptProfile.removedDedicatedTerms.join("、")); +} else if (promptProfile.removedDedicatedTerms.length) { + warnings.push("旧 promptProfile.render 含资产专属交付版式,编译时已过滤:" + promptProfile.removedDedicatedTerms.join("、") + ";建议在派生项目迁移为 globalRenderLanguage"); +} if (state.config?.promptLanguage === "zh-CN") { - if (state.config?.promptProfile?.style?.trim()) assert(isPrimarilyChinese(state.config.promptProfile.style), "promptProfile.style 必须以中文为主"); - if (state.config?.promptProfile?.render?.trim()) assert(isPrimarilyChinese(state.config.promptProfile.render), "promptProfile.render 必须以中文为主"); + if (promptProfile.commonVisualLanguage) assert(isPrimarilyChinese(promptProfile.commonVisualLanguage), "项目公共视觉语言必须以中文为主"); + if (promptProfile.globalRenderLanguage) assert(isPrimarilyChinese(promptProfile.globalRenderLanguage), "全局渲染语言必须以中文为主"); } for (const character of state.assets?.characters ?? []) { @@ -141,6 +163,13 @@ for (const record of listAssets(state)) { assert(allowedStatuses.has(asset.status), asset.id + ".status 无效"); if (state.config?.promptLanguage === "zh-CN") assert(isPrimarilyChinese(asset.deltaPrompt ?? asset.prompt), asset.id + " 的 deltaPrompt 必须以中文为主"); if (asset.execution?.status === "succeeded") assert(imageLocator(asset.image), asset.id + " 执行成功但缺少有效图片定位"); + if (state.config?.assetPresentation?.version === assetPresentationVersion) { + const scope = assetPresentationScope(record); + const presentation = assetPresentation(state, record); + const image = imageSettingsForRecord(state, record); + assert(Boolean(scope && presentation === state.config.assetPresentation[scope]), asset.id + " 未解析到自身资产类型的呈现配置"); + assert(image.ratio === presentation?.ratio, asset.id + " 的最终比例未来自 assetPresentation." + scope); + } if (asset.status === "ready" || forBatch) { const check = readiness(record, state); if (asset.status === "ready" && !check.ready) errors.push(asset.id + " 不满足 ready:" + check.reasons.join("、")); diff --git a/tests/fixtures/presentation-scopes/project-state.json b/tests/fixtures/presentation-scopes/project-state.json new file mode 100644 index 0000000..9d90f34 --- /dev/null +++ b/tests/fixtures/presentation-scopes/project-state.json @@ -0,0 +1,113 @@ +{ + "schemaVersion": 3, + "revision": 1, + "updatedAt": "2026-07-17T00:00:00.000Z", + "project": { + "id": "project-presentation-scope", + "title": "呈现作用域回归", + "sourceScript": "script.md", + "sourceHash": "fixture-source-hash" + }, + "config": { + "mode": "standalone", + "promptLanguage": "zh-CN", + "styleProfile": { + "id": "art-direction-fixture", + "contentHash": "fixture-direction-hash" + }, + "visualPreset": { + "id": "fixture-visual", + "name": "回归视觉", + "family": "CG", + "stylePromptSeed": "东方幻想三维动画视觉" + }, + "promptProfile": { + "version": 1, + "commonVisualLanguage": "东方幻想三维动画视觉,统一材质层次与色彩秩序", + "globalRenderLanguage": "电影级柔和光影,主体轮廓清晰,纹理细节稳定" + }, + "assetPresentation": { + "version": 1, + "characterDefault": { + "id": "character-board", + "name": "角色默认设定板", + "ratio": "4:3", + "prompt": "白色无缝背景的角色设定板,左侧胸像,右侧正面与背面全身图" + }, + "characterState": { + "id": "character-state-single", + "name": "单一角色状态图", + "ratio": "16:9", + "prompt": "单一角色状态资产图,完整呈现当前造型与状态变化" + }, + "location": { + "id": "location-wide", + "name": "宽幅场景图", + "ratio": "16:9", + "prompt": "单一场景环境设定图,宽幅呈现空间布局与环境细节" + }, + "prop": { + "id": "prop-single", + "name": "单一道具图", + "ratio": "4:3", + "prompt": "单一道具设定图,完整呈现结构、材质与尺度" + } + }, + "image": { + "ratio": "9:16", + "quality": "2k" + }, + "execution": { + "mode": "staged", + "reviewGate": "streaming", + "confirmation": "batch", + "concurrency": 4 + } + }, + "workflow": { + "appliedOps": [] + }, + "assets": { + "characters": [ + { + "id": "character-lin", + "name": "林青", + "identityPrompt": "年轻女性,黑色长发,清瘦体型,神情沉静", + "states": [ + { + "id": "character-lin-default", + "name": "默认态", + "sequence": 0, + "deltaPrompt": "青色常服,平静站立", + "status": "draft", + "referenceStateId": null + }, + { + "id": "character-lin-battle", + "name": "战斗态", + "sequence": 1, + "deltaPrompt": "银色轻甲,握剑戒备", + "status": "draft", + "referenceStateId": "character-lin-default" + } + ] + } + ], + "locations": [ + { + "id": "location-cloud-palace", + "name": "云上宫殿", + "deltaPrompt": "悬浮云海之上的东方宫殿群,层次开阔", + "status": "draft" + } + ], + "props": [ + { + "id": "prop-jade-token", + "name": "玉令", + "deltaPrompt": "青白玉质令牌,金色符文沿边缘流动", + "status": "draft" + } + ] + } +} diff --git a/tests/presentation-scope.test.mjs b/tests/presentation-scope.test.mjs new file mode 100644 index 0000000..2a7e14f --- /dev/null +++ b/tests/presentation-scope.test.mjs @@ -0,0 +1,255 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +import { + assetPresentationScope, + compilePrompt, + hashValue, + imageSettingsForRecord, + listAssets, + resolvedPromptProfile +} from "../scripts/lib/project.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const fixturePath = path.join(root, "tests/fixtures/presentation-scopes/project-state.json"); + +async function fixture() { + return JSON.parse(await readFile(fixturePath, "utf8")); +} + +function recordsByScope(state) { + return Object.fromEntries(listAssets(state).map((record) => [assetPresentationScope(record), record])); +} + +test("四类资产只编译自身呈现 prompt 与比例", async () => { + const state = await fixture(); + const records = recordsByScope(state); + const expected = { + characterDefault: { ratio: "4:3", marker: "白色无缝背景的角色设定板" }, + characterState: { ratio: "16:9", marker: "单一角色状态资产图" }, + location: { ratio: "16:9", marker: "单一场景环境设定图" }, + prop: { ratio: "4:3", marker: "单一道具设定图" } + }; + const markers = Object.values(expected).map((item) => item.marker); + + for (const [scope, contract] of Object.entries(expected)) { + const record = records[scope]; + const prompt = compilePrompt(state, record).text; + assert.match(prompt, /东方幻想三维动画视觉/); + assert.match(prompt, /电影级柔和光影/); + assert.ok(prompt.includes(contract.marker), `${scope} 缺少自身呈现 prompt`); + for (const marker of markers.filter((value) => value !== contract.marker)) { + assert.ok(!prompt.includes(marker), `${scope} 意外继承了 ${marker}`); + } + assert.equal(imageSettingsForRecord(state, record).ratio, contract.ratio); + assert.notEqual(imageSettingsForRecord(state, record).ratio, state.config.image.ratio, `${scope} 不应读取旧全局 ratio`); + } +}); + +test("旧项目污染 render 被过滤且不改写状态", async () => { + const state = await fixture(); + state.config.promptProfile = { + style: state.config.promptProfile.commonVisualLanguage, + render: "4:3 角色资产设定板,白色无缝背景;左侧胸像,右侧正面与背面全身图;柔和棚拍主光,主体轮廓清晰" + }; + state.config.assetPresentation = { characterDefault: state.config.assetPresentation.characterDefault }; + const before = JSON.stringify(state); + const profile = resolvedPromptProfile(state); + assert.deepEqual(profile.removedDedicatedTerms.sort(), ["画幅比例", "角色设定板", "白底或影棚背景", "胸像或半身像", "正背面交付", "版式位置"].sort()); + assert.equal(profile.globalRenderLanguage, "柔和棚拍主光,主体轮廓清晰"); + + const records = recordsByScope(state); + for (const scope of ["characterState", "location", "prop"]) { + const prompt = compilePrompt(state, records[scope]).text; + assert.ok(!/白色无缝背景|胸像|正面与背面|角色资产设定板/.test(prompt)); + } + assert.equal(imageSettingsForRecord(state, records.characterState).ratio, "16:9"); + assert.equal(imageSettingsForRecord(state, records.location).ratio, "16:9"); + assert.equal(imageSettingsForRecord(state, records.prop).ratio, "4:3"); + assert.equal(JSON.stringify(state), before); +}); + +test("validate_project_state 对新分域模型执行边界校验", async (t) => { + const directory = await mkdtemp(path.join(tmpdir(), "comic-assets-presentation-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const state = await fixture(); + const source = "回归测试剧本"; + const bible = "# 故事资产圣经\n\n回归测试内容"; + const direction = "# 项目创作方向\n\n回归测试内容"; + state.project.sourceHash = hashValue(source); + state.workflow.setup = { path: "setup-decisions.json", status: "complete", contentHash: "fixture-setup-hash" }; + state.workflow.storyBible = { path: "story-asset-bible.md", status: "complete", sourceHash: state.project.sourceHash, contentHash: hashValue(bible) }; + state.workflow.artDirection = { + path: "art-direction-brief.md", + status: "complete", + sourceHash: state.project.sourceHash, + storyBibleHash: state.workflow.storyBible.contentHash, + setupHash: state.workflow.setup.contentHash, + contentHash: hashValue(direction) + }; + await writeFile(path.join(directory, "script.md"), source); + await writeFile(path.join(directory, "story-asset-bible.md"), bible); + await writeFile(path.join(directory, "art-direction-brief.md"), direction); + const statePath = path.join(directory, "project-state.json"); + const validate = () => spawnSync(process.execPath, [path.join(root, "scripts/validate_project_state.mjs"), statePath], { encoding: "utf8" }); + + await writeFile(statePath, JSON.stringify(state, null, 2)); + assert.equal(validate().status, 0); + + state.config.promptProfile.globalRenderLanguage = "白底角色胸像正背面三视图,4:3 设定板"; + await writeFile(statePath, JSON.stringify(state, null, 2)); + const polluted = validate(); + assert.equal(polluted.status, 1); + assert.match(polluted.stderr, /不得包含资产专属交付版式/); + + state.config.promptProfile.globalRenderLanguage = "电影级柔和光影,主体轮廓清晰"; + delete state.config.assetPresentation.location.ratio; + await writeFile(statePath, JSON.stringify(state, null, 2)); + const missingRatio = validate(); + assert.equal(missingRatio.status, 1); + assert.match(missingRatio.stderr, /assetPresentation\.location\.ratio/); +}); + +test("build_image_batch 在批次中保留四类呈现来源", async (t) => { + const directory = await mkdtemp(path.join(tmpdir(), "comic-assets-batch-scope-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const state = await fixture(); + const source = "批次作用域回归剧本"; + const bible = "# 故事资产圣经\n\n批次作用域回归"; + const direction = "# 项目创作方向\n\n批次作用域回归"; + state.project.sourceHash = hashValue(source); + state.workflow.setup = { path: "setup-decisions.json", status: "complete", contentHash: "fixture-setup-hash" }; + state.workflow.storyBible = { path: "story-asset-bible.md", status: "complete", sourceHash: state.project.sourceHash, contentHash: hashValue(bible) }; + state.workflow.artDirection = { + path: "art-direction-brief.md", + status: "complete", + sourceHash: state.project.sourceHash, + storyBibleHash: state.workflow.storyBible.contentHash, + setupHash: state.workflow.setup.contentHash, + contentHash: hashValue(direction) + }; + await writeFile(path.join(directory, "script.md"), source); + await writeFile(path.join(directory, "story-asset-bible.md"), bible); + await writeFile(path.join(directory, "art-direction-brief.md"), direction); + const statePath = path.join(directory, "project-state.json"); + const batchPath = path.join(directory, "image-batch.json"); + const build = (scope = "all") => spawnSync(process.execPath, [path.join(root, "scripts/build_image_batch.mjs"), statePath, "--output", batchPath, "--scope", scope], { encoding: "utf8" }); + + const [character] = state.assets.characters; + const [defaultAsset, stateAsset] = character.states; + defaultAsset.status = "ready"; + defaultAsset.review = { promptStatus: "approved" }; + await writeFile(statePath, JSON.stringify(state, null, 2)); + assert.equal(build(`asset:${defaultAsset.id}`).status, 0); + let batch = JSON.parse(await readFile(batchPath, "utf8")); + assert.equal(Object.hasOwn(batch.image, "ratio"), false); + assert.deepEqual(batch.items.map((item) => [item.presentation.scope, item.image.ratio]), [["characterDefault", "4:3"]]); + + defaultAsset.status = "succeeded"; + defaultAsset.execution = { status: "succeeded" }; + defaultAsset.image = { artifactId: "artifact-character-default", localPath: "assets/images/default.png" }; + defaultAsset.referenceAnchor = { artifactId: "artifact-character-default", lockedAt: "2026-07-17T00:00:00.000Z", sourceStateRevision: 1 }; + for (const asset of [stateAsset, ...state.assets.locations, ...state.assets.props]) { + asset.status = "ready"; + asset.review = { promptStatus: "approved" }; + } + await writeFile(statePath, JSON.stringify(state, null, 2)); + assert.equal(build().status, 0); + batch = JSON.parse(await readFile(batchPath, "utf8")); + assert.deepEqual( + batch.items.map((item) => [item.presentation.scope, item.image.ratio]).sort(), + [["characterState", "16:9"], ["location", "16:9"], ["prop", "4:3"]] + ); +}); + +test("record_art_direction_brief 拒绝全局专属版式并写入新字段", async (t) => { + const directory = await mkdtemp(path.join(tmpdir(), "comic-assets-direction-scope-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const state = await fixture(); + state.workflow.setup = { path: "setup-decisions.json", status: "complete", contentHash: "fixture-setup-hash" }; + state.workflow.storyBible = { path: "story-asset-bible.md", status: "complete", sourceHash: state.project.sourceHash, contentHash: "fixture-bible-hash" }; + state.workflow.artDirection = { path: "art-direction-brief.md", status: "draft", sourceHash: state.project.sourceHash }; + const statePath = path.join(directory, "project-state.json"); + const briefPath = path.join(directory, "art-direction-brief.md"); + const record = () => spawnSync(process.execPath, [path.join(root, "scripts/record_art_direction_brief.mjs"), statePath], { encoding: "utf8" }); + const brief = (render) => `# 项目创作方向 + +## 创作命题 + +东方神话世界中的克制成长感。 + +## 用户确认的方向 + +采用清晰亲和的三维动画方向。 + +## 公共视觉语言 + +东方幻想三维动画视觉,统一材质层次与色彩秩序。 + +## 全局渲染语言 + +${render} + +## 正向设计重点 + +保持轮廓识别和色彩阵营稳定。 + +## 角色、场景与道具设计原则 + +共享统一世界材质,同时保持类型差异。 + +## 待确认项 + +无。 +`; + + await writeFile(statePath, JSON.stringify(state, null, 2)); + await writeFile(briefPath, brief("白色无缝背景,角色胸像与正背面三视图,4:3 排版")); + const rejected = record(); + assert.equal(rejected.status, 1); + assert.match(rejected.stderr, /包含资产专属交付版式/); + + await writeFile(briefPath, brief("电影级柔和光影,主体轮廓清晰,纹理细节稳定。")); + assert.equal(record().status, 0); + const recorded = JSON.parse(await readFile(statePath, "utf8")); + assert.equal(recorded.config.promptProfile.version, 1); + assert.match(recorded.config.promptProfile.commonVisualLanguage, /东方幻想三维动画视觉/); + assert.equal(recorded.config.promptProfile.globalRenderLanguage, "电影级柔和光影,主体轮廓清晰,纹理细节稳定。"); + assert.equal(Object.hasOwn(recorded.config.promptProfile, "render"), false); +}); + +test("新项目初始化直接持久化四类呈现配置", async (t) => { + const directory = await mkdtemp(path.join(tmpdir(), "comic-assets-new-project-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const sourcePath = path.join(directory, "source.md"); + const projectPath = path.join(directory, "project"); + await writeFile(sourcePath, "# 第一集\n\n回归测试剧本。\n"); + const run = spawnSync(process.execPath, [path.join(root, "scripts/run_project.mjs"), sourcePath, "--project-dir", projectPath, "--project-id", "project-new-scope"], { encoding: "utf8" }); + assert.equal(run.status, 0, run.stderr); + const statePath = path.join(projectPath, "project-state.json"); + let state = JSON.parse(await readFile(statePath, "utf8")); + assert.deepEqual(Object.keys(state.config.promptProfile).sort(), ["commonVisualLanguage", "globalRenderLanguage", "version"]); + assert.equal(Object.hasOwn(state.config.image, "ratio"), false); + assert.deepEqual(Object.keys(state.config.assetPresentation).sort(), ["characterState", "location", "prop", "version"]); + + await writeFile(path.join(projectPath, "setup-decisions.json"), JSON.stringify({ + schemaVersion: 3, + projectId: state.project.id, + status: "complete", + visualPresetId: "script", + characterLayoutId: "closeup-front-back" + }, null, 2)); + const apply = spawnSync(process.execPath, [path.join(root, "scripts/apply_setup_decisions.mjs"), statePath], { encoding: "utf8" }); + assert.equal(apply.status, 0, apply.stderr); + state = JSON.parse(await readFile(statePath, "utf8")); + assert.deepEqual(Object.keys(state.config.assetPresentation).sort(), ["characterDefault", "characterState", "location", "prop", "version"]); + assert.equal(state.config.assetPresentation.characterDefault.ratio, "4:3"); + assert.equal(state.config.assetPresentation.characterState.ratio, "16:9"); + assert.equal(state.config.assetPresentation.location.ratio, "16:9"); + assert.equal(state.config.assetPresentation.prop.ratio, "4:3"); +}); From f0559ac28dfb73870c875a7b40a2d1a4dc2750e4 Mon Sep 17 00:00:00 2001 From: Shanzoon <82895720+Shanzoon@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:48:41 +0800 Subject: [PATCH 06/11] =?UTF-8?q?=E5=9B=9B=E5=B2=97=E4=BD=8D=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=E3=80=81=E9=94=9A=E5=AE=9A=E5=BC=8F=E5=88=B0=E6=95=B4?= =?UTF-8?q?=E6=AE=B5=E6=AD=A3=E6=96=87=20v3=E3=80=81=E5=9B=9B=E8=BD=AE?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=E4=BF=AE=E5=A4=8D=E4=B8=8E=E4=BD=93=E9=AA=8C?= =?UTF-8?q?=E6=9D=BE=E7=BB=91=EF=BC=882026-07-18=20=E5=85=A8=E9=83=A8?= =?UTF-8?q?=E6=89=B9=E6=AC=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 涵盖 CHANGELOG 2026-07-18 全部条目:四岗位重构与名册对账、海外剧本适配、 第二轮九问题修复(前端轮询保护、分片派发、落盘总则、lj-awb 速查、文档内嵌、 promptProfile v3 无拼接、画面文字语言、人物群体呈现默认锚)、主美风格词体系 (种子重写与定位标签、媒介块分型、正文写作规范)、体验松绑与调度解耦 (持久授权、小额免确认、批量放行、打包派发、状态构图跟随、呈现排除项)。 测试 15 → 32 全绿。作为精简重构前的干净基线。 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 96 ++++++ README.md | 2 +- SKILL.md | 47 ++- assets/setup-app/style-presets.json | 305 ++++++++++++++++-- assets/viewer-app/doc.html | 189 +++++++++++ assets/viewer-app/index.html | 9 + assets/viewer-app/roster.html | 108 +++++++ assets/viewer-app/viewer.css | 20 ++ assets/viewer-app/viewer.js | 167 ++++++++-- references/creative-charter.md | 7 +- references/lj-awb-quickref.md | 76 +++++ references/project-schema.md | 26 ++ references/prompt-authoring.md | 89 ++++-- references/provider-policy.md | 2 +- references/subagent-contracts.md | 45 ++- scripts/apply_review_decisions.mjs | 8 +- scripts/apply_setup_decisions.mjs | 27 +- scripts/approve_prompt_batch.mjs | 114 +++++++ scripts/bootstrap_art_direction_brief.mjs | 4 +- scripts/bootstrap_story_bible.mjs | 2 +- scripts/lib/project.mjs | 184 ++++++++++- scripts/merge_agent_patches.mjs | 93 +++++- scripts/plan_prompt_shards.mjs | 110 +++++++ scripts/plan_work_dag.mjs | 11 +- scripts/record_art_direction_brief.mjs | 104 +++++- scripts/record_execution_approval.mjs | 23 +- scripts/record_standing_authorizations.mjs | 62 ++++ scripts/record_story_bible.mjs | 42 ++- scripts/validate_project_state.mjs | 47 ++- tests/anchored-prompt-v2.test.mjs | 352 +++++++++++++++++++++ tests/authorizations.test.mjs | 114 +++++++ tests/presentation-scope.test.mjs | 5 +- tests/prompt-body-v3.test.mjs | 350 ++++++++++++++++++++ tests/prompt-shards.test.mjs | 74 +++++ tests/style-presets.test.mjs | 23 ++ 35 files changed, 2772 insertions(+), 165 deletions(-) create mode 100644 assets/viewer-app/doc.html create mode 100644 assets/viewer-app/roster.html create mode 100644 references/lj-awb-quickref.md create mode 100644 scripts/approve_prompt_batch.mjs create mode 100644 scripts/plan_prompt_shards.mjs create mode 100644 scripts/record_standing_authorizations.mjs create mode 100644 tests/anchored-prompt-v2.test.mjs create mode 100644 tests/authorizations.test.mjs create mode 100644 tests/prompt-body-v3.test.mjs create mode 100644 tests/prompt-shards.test.mjs create mode 100644 tests/style-presets.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index f235bf0..40aac49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,102 @@ 后续接手此 skill 的窗口在修改前应先阅读本文件、`SKILL.md` 与相关数据契约。记录应说明行为变化、兼容性和验证方式,而不是逐行重复代码差异。 +## 2026-07-18 — 呈现排除项:局部构图的"不能有什么"清单(用户提问暴露的缺口) + +- 背景:需求规格(requirements)只是"必须有什么"的存在性检查——选"上半身肖像"后,正文写整套服装(皮靴/裙摆)照样通过校验,而画外内容会诱导生图模型拉远构图破坏版式;B4 状态图继承构图后该风险被放大。 +- 呈现配置新增可选 `exclusions`(与 requirements 同构的 label/match 清单);`lib/project.mjs` 新增 `foundPresentationExclusions`,readiness 在 v2/v3 正文命中排除项时拒绝(理由回流岗位四裁剪重写)。`apply_setup_decisions.mjs` 把 layout 的 exclusions 传递给 characterDefault 与派生的 characterState。首个应用:`upper-body` 上半身肖像排除 `鞋|靴|足部|脚踝|裤脚|裙摆`(保守词表,宁漏勿误伤——"长裙/连衣裙"等上下连体衣物不拦)。 +- prompt-authoring 新增"可见范围裁剪"规则与自检项:只写所选呈现范围内可见的部分,被裁剪的服装事实留给全身构图资产或镜头级提示词,圣经记录不丢失。 +- 验证:32/32 测试(新增排除项用例:写鞋靴拒绝、裁剪后放行)。兼容性:exclusions 为可选字段,存量项目呈现配置无此字段即无行为变化。 + +## 2026-07-18 — 体验松绑与调度解耦:持久授权、打包派发、状态构图跟随(第四轮实测 B1-B6+B8) + +依据交接稿第九节拍板(除 B7 API 直调外全做)。背景:长短双剧本实测——A1 门禁被执行得过死(用户明确说了选择仍被要求去页面点选+反复确认);159 资产 76 分片一片一派跑 26 波耗时 51 分钟;角色状态图不跟随所选构图;CG 角色 Q 版漂移(缺头身比钉子);主代理即兴写 approve-self-checked-prompts.mjs 绕过审查闸门。 + +- **B2 初始化授权规则改写**:setup 在总纲锁定前可随时重选,不是不可逆动作——用户在对话明确说出选择即为授权,主代理直接代写并在同一条回复复述预设名/定位/种子原文/构图,不再前置确认、不要求去页面点选;未明确表达时仍禁止猜测匹配与参考历史偏好;描述与预设不完全对应时列候选让用户挑。 +- **B3 构图库补主美标准版式**:新增 `closeup-turnaround`"特写 + 正侧背三视图"(16:9,左大特写+右正侧背三视图——主美文档标准版式,此前库中只能"特写+正背面"或"纯三视图"二选一)。 +- **B4 角色状态图跟随所选构图**:`apply_setup_decisions.mjs` 为全新 setup 派生 characterState(继承所选 layout 的 prompt/ratio/requirements + "当前外观版本"要素),不再固定 16:9 单图;顺带消除状态图单/多视图混杂(岗位四按需求规格写作后视图数一致)。存量补齐(presentationOnlyUpgrade)与豁免(grandfathered)路径保留旧 characterState,不影响在途项目。 +- **B5 两类钉子**:prompt-authoring 新增硬规则"角色资产默认空手"(禁手持包/电脑/话筒等,职业用服装气质表达,佩挂按圣经装备体系事实写佩挂不写手持);"头身比例"入总纲角色块钉子清单与正文主体句要素(写实七至八头身/按定位声明夸张头身比——防 Q 版/幼态漂移,实测两三个 CG 角色因此漂移)。 +- **B6 调度解耦(分片=审计单位,包=派发单位)**:`plan_prompt_shards.mjs` 新增 bundles 输出——普通分片按资产数聚合(角色 ≤6、场景/道具 ≤10),≥3 外观角色独占一包;SKILL/契约改为"每包一个子代理、完成一包立即领取下一包(常驻消费)",任务卡必须自包含(名册条目+圣经事实摘录+媒介块+构图规格+文字策略,不回读剧本/圣经/项目状态全文)。实测长剧本项目:76 片 → 29 包,3 worker 理论 26 波 → 约 10 波。 +- **B1 小额扣费免确认(项目级持久授权)**:新增 `record_standing_authorizations.mjs`(`--auto-approve-below <积分>|off`,用户明确授权后运行,写入 `config.authorizations`,可撤销);`record_execution_approval.mjs` 新增 `--auto --state `——估价 ≤ 阈值时自动确认(approvedBy 记 standing-authorization),快照/估价/哈希照常播报留档;超阈值或未授权硬拒回人工。默认关闭,skill 不设默认阈值。 +- **B8 批量放行官方化 + 禁野生脚本**:新增 `approve_prompt_batch.mjs`——需 `--batch-prompt-approval on` 持久授权;对 v2/v3 正文做质量自检(呈现要素/漂移或文字语言/比例/中文),通过者批量写入 review-decisions approved 决定(仍走 apply 统一应用,保留全部审查链路语义);**带可读文字的资产默认留人审**(`--include-readable-text` 显式放开),changes_requested 永不覆盖。SKILL 上下文纪律新增:禁止主代理即兴编写脚本写入项目目录或绕过审查/扣费闸门,官方脚本未覆盖的需求报告用户而不是自建通道。 +- 验证:31/31 测试(新增打包规则、批量放行集成、免确认阈值集成 3 个用例;presentation-scope 的 characterState 断言按 B4 新契约更新并注明日期)。长剧本项目只读冒烟:29 包。兼容性:B4 仅全新 setup 生效;B1/B8 授权默认关闭;manifest 保持版本 3(构图为纯增项)。 +- 暂缓:B7 API 直调 worker(B6 落地后实测仍慢再议;将作为引擎化第一块砖)。 + +## 2026-07-18 — 主美风格词体系落地:种子重写与定位标签、媒介块分资产类型、正文写作规范(第三轮实测 A1-A5) + +依据《comic-assets-第二轮实测问题交接-2026-07-19.md》第八节拍板(A1-A5 做,A6 参考链暂缓,A7 不做)。背景:《你也配修仙》v3 首跑暴露——主代理未经确认代选预设导致"二次元"种子污染总纲;媒介句无防漂移钉子致 5 张成图 2 张跨次元漂移(游戏立绘/写实照片);正文混入叙事概念、人物关系、逐视图重复与比例错写。主美《美术中台流程 20260608》文档(飞书)证实其风格词的稳定性来自"防漂移钉子"(无毛孔/无油光/非真人感/哑光分束发丝/夸张五官比例…)与短语铺陈格式。 + +- **A1 初始化代选防线**:SKILL.md 硬规则——主代理不得代写/代提交 setup-decisions,不得把用户文字描述自行匹配预设或参考其他项目历史偏好代选;用户明确要求代选时须先复述"预设名+定位+种子原文"获同意。`apply_setup_decisions.mjs` 应用后输出预设名(含定位)与种子原文,要求主代理转述用户核对。 +- **A2 预设种子按主美文档重写(manifest version 2→3)**:11 个种子以主美验证过的风格词为底稿重写——保留媒介词+防漂移钉子,剥离主体词(贵公子/美女等)与版式块;**有意保留人物钉子(面部/五官/发丝/服装/毛孔),推翻批次 A"种子不含角色措辞"的决定**——v3 架构下钉子经总纲"××·角色"块只进入角色正文,不再走公共段拼接,污染路径已消除。工作室/画家品牌词维持中性化(皮克斯/新海诚不进种子),工具词保留(UE5/Blender)。cg-xianxia 去掉"二次元"字样改以"国风游戏 CG 宣传图质感"承载同一定位。每个预设新增 `positioning` 定位标签(游戏立绘向/超写实真人向/国漫 CG 向…),贯通 Dialog 详情、`config.visualPreset`、总纲"已选视觉范式"注入,防止"名字最像就代选"的媒介预期错配。 +- **A3 媒介与画质分资产类型**:总纲 v3 模板"媒介与画质"改为按 维度×资产类型 给块("主维度·角色"可含人物钉子;"主维度·场景与道具"不含人物专属词),并禁止叙事概念与剧情结构词("反套路/师徒故事"——生图模型无法执行)。`record_art_direction_brief` v3 校验改为**按行分块作用**:角色块豁免"角色局部细节"版式项与人物措辞检查;场景道具块及未标注行维持拦截(角色/人物/肤质/发丝/毛孔)。 +- **A4 正文写作规范(prompt-authoring v3 重写)**:写作格式固化为主美式四步短语铺陈流——①原样抄本维度本类型媒介块;②主体身份一短句+可画外观短语;③服装识别物一次性铺陈;④版式收尾模板(语义覆盖需求规格+文字策略默认"不带文字"、可读文字逐个点名、禁全称句)。硬规则"只写画得出来的东西":头衔/人物关系/剧情来历/设计意图字面全部禁止(师尊/宿主/之妹/"视觉层级服从功能角色"),只允许转译为视觉;构图只说一遍,禁逐视图重复服装结构。新增校验 `findMismatchedRatioTerms`:v3 正文中的比例数字与呈现配置 ratio 不一致即拒绝(readiness + validate,一致或省略均可)。 +- **A5 宪法负向词边界澄清**:creative-charter 与 prompt-authoring 明确——禁止的是负向提示词参数通道、排除词清单与"避免…"补丁;"无毛孔/无油光/非真人感/不带文字/不要露沟"等内联否定描述词是防漂移钉子,合法且常常必要,agent 不得自我审查删除。 +- 验证:28/28 测试通过(新增 3:比例一致性校验、record 媒介块分块校验、预设种子卫生检查 `tests/style-presets.test.mjs`);冒烟——apply 输出种子复述、模板含双块指引与定位行。兼容性:种子与模板仅新项目生效;v1/v2 存量项目路径不变(v2 防漂移词表、v1 五段拼接原样);positioning 为可选字段,旧 manifest 项目不受影响。 +- 暂缓/不做(用户拍板):A6 跨角色风格参考链(先实测钉子效果,漂移仍超预期再做);A7 分片对账提示(Codex 并发设置调整后观察)。 + +## 2026-07-18 — promptProfile v3(整段正文,推翻锚定式拼接)、画面内文字语言与人物群体呈现默认锚(第二轮实测 P5/P8/P9) + +依据《comic-assets-第二轮实测问题交接-2026-07-19.md》第五、六节三轮拍板结论。**这是锚定式设计的第三轮修订:用户确认 Q 版形象等多媒介剧本是业务常态,"全局唯一逐字拼接段"假设不成立**——维度是资产事实(与性别/族裔同机制),由岗位四写进正文,不做系统机制轴。 + +- **promptProfile v3(新项目默认)**:无 styleAnchor,最终提示词 = 岗位四整段正文(`promptBody`),逐字物化、零拼接、零隐藏追加。总纲模板改版:`公共风格锚点`章节替换为`媒介与画质`(每个媒介维度一句,岗位四把本维度句子原样落进正文开头;允许感性的题材气质/调性,**禁止具体色名、材质枚举**——阵营色彩体系下放到分类型美术语言,修复真实项目"锚点写死色域、全资产挤在窄色域、主配角拉不开")。record 按"媒介与画质"标题识别 v3,校验中文/版式词/角色措辞(与 v2 锚点同词表),写入 `promptProfile: { version: 3 }`。**v3 下防漂移词表撤销**(正文必须自带媒介词);v2(锚定式)与 v1 存量项目全链路兼容不变,真实 v2 项目 validate 回归通过。 +- **辨识度层级(从轻)**:总纲模板"分类型美术语言·角色"新增指引——辨识度是显式决策、手段服从剧本人设:英雄型主角用独占轮廓/专属色/贯穿符号;**普通人代入型主角(废柴逆袭等)保持大众形象,用一两个克制的贯穿特征承载辨识并在总纲写明**(同日修订:初版"必须拥有独占特征"措辞会误导美术总监给代入型主角强加英雄式设计,用户指出后改为按人设分流);功能角色明确让位。prompt-authoring 自检同步"按声明的辨识手段核对,不得自行加戏"。不加圣经字段、不加必填章节、不加校验(用户拍板,待批量测试后再评估是否需要重的版本)。 +- **画面内文字语言(P8,v3 硬校验)**:总纲 v3 新增必填章节"画面内文字语言"(未明确时按世界观决策;默认策略:证据类道具与场景标识写明语言+内容原文,装饰类纹样化不可读);圣经模板世界观章节新增"世界文字语言"事实行;prompt-authoring 新增执行规则;readiness/validate 在 v3 下硬拒"要求可读文字(铭文/招牌/标签/字样/刻字/题字/信笺/菜单/字迹)但未指定语言或声明不可读"的正文——**拒绝理由回流岗位四自动重写,不卡用户**。词表故意保守(宁漏勿误伤),漏网由一致性检查岗与对话审查兜底。v2 存量项目不受此校验影响。 +- **人物群体呈现默认锚改写(P9)**:创作宪法与总纲模板——"未明确"角色默认按**剧本文化语境的主流面孔**呈现(西式命名+西式世界观→欧美面貌为主);除非剧本明确写了多元设定,不得以"国际化都市/开放港城"语境推演为由做多族裔均衡分布(真实项目实锤:美术总监的多元化默认与用户北美市场商业预期相反)。原有红线保留:不推断个别角色、不把族裔编码为善恶/权力等级。SKILL.md 新增**高后果章节确认协议**:总纲确认前必须把"人物群体呈现""画面内文字语言"单独摘出、各用一句后果化语言复述,等用户逐条回应。"目标发行市场"初始化输入按用户决定后置,待多轮测试再评估。 +- **外观版本定义扩展**:圣经模板与 subagent-contracts 闸门二——贯穿复现的媒介/画风形态变化(Q 版形象、异维度固定形态)构成独立外观版本(注明所属维度),走候选编号与对账;单场一闪而过的 Q 版演出仍降级为镜头级。 +- 验证:新增 `tests/prompt-body-v3.test.mjs`(7 用例:v3 编译逐字、媒介词通过、需求规格拒绝、画面文字三态、词表不误伤、缺正文、record v3 识别与缺章节拒绝)。全套 25/25 通过(`node --test tests/presentation-scope.test.mjs tests/anchored-prompt-v2.test.mjs tests/prompt-shards.test.mjs tests/prompt-body-v3.test.mjs`);v3 模板生成冒烟(三个新章节齐全);真实 v2 项目 validate 只读回归通过。 +- 兼容性:仅新项目走 v3(record 按模板标题分流);v1/v2 行为不变;viewer 编译展示同步 v3 分支。 + +## 2026-07-18 — 岗位四分片清单、大产物落盘总则、lj-awb 接口速查与等待页文档内嵌(第二轮实测 P4/P6/P7/P2) + +- **岗位四并发派发脚本化**(P4 根因:契约建议式措辞对主代理无强制力,Codex 实跑按资产类型只开 3 个子代理串行扛 80 资产)。新增 `scripts/plan_prompt_shards.mjs`:从项目状态确定性生成分片清单(每个角色一个分片含其全部外观版本;场景、道具按 ≤4 聚类;`--missing-only` 只列缺正文资产),输出 `workflow/prompt-shards.json`。SKILL.md 核心 DAG 与 subagent-contracts 岗位四条目改为规定式:按分片清单派发、并发 = min(分片数, 槽位)、按 shardId 对账、显式禁止按类型三分。新增 `tests/prompt-shards.test.mjs`(3 用例:分片规则、确定性、missing-only)。真实项目冒烟:80 资产 → 38 分片。 +- **大产物落盘总则**(P6 根因:大 JSON 走终端 stdout 被 Codex 摘要截断成半截文件)。SKILL.md 主代理上下文纪律与 subagent-contracts 新增:结构化产物必须用文件写入工具整体写入项目内路径,对话/终端只传路径+条目数+摘要,禁止 echo/heredoc 拼接大文件。`merge_agent_patches.mjs` 与 `apply_review_decisions.mjs` 在 JSON 解析失败时输出定向修复指引(提示截断成因与整体重写)。 +- **lj-awb 接口速查内置**(P7 根因:执行前置全串行且反复整篇阅读 lj-awb 文档,9-10 分钟黑箱)。新增 `references/lj-awb-quickref.md`(浓缩自 lj-awb 0.5.5:命令链、JSONL 契约、硬规则;**模型/价格等易变数据不内置**,保持动态查询);速查页记录适配版本号,preflight 版本不一致时才回读完整文档并更新。SKILL.md 执行段改写:接口知识以速查页为准;preflight 与渠道查询在岗位四写正文期间**前移并行**;查询结果缓存 `workflow/awb-capabilities.json`(24h + 版本双条件);估价与总积分永远全量重算展示;阶段进度逐步向用户播报。 +- **等待页文档内嵌**(P2)。等待页"查看圣经/创作方向"由 `target="_blank"` 链接改为页签按钮 + 内嵌 iframe(复用 `doc.html` 渲染器与自动更新,新增 `?embed=1` 隐藏顶栏模式),保留"在新窗口打开"次级链接;链接内容未变时不重建 DOM,轮询不打断阅读;离开等待态自动收起并清空 iframe。进度弹层链接行为不变。 +- 兼容性:无 schema 与编译行为变化;分片脚本为新增入口,旧流程命令不受影响。验证:18/18 测试(新增 3);真实项目只读冒烟——工作台/viewer.js/doc.html?embed=1 均 200,新逻辑已服务。 + +## 2026-07-18 — 工作台初始化选择轮询保护与提示词进度展示(第二轮实测 P1/P3) + +- 修复 `viewer.js` 初始化选择被轮询重置:`loadSetup()` 此前每次轮询(前台 2 秒)无条件以 `decisions ?? 默认值` 覆盖本地选择,用户未提交时页面选中状态被重置回默认,最终提交的是默认值(真实项目实锤,用户被迫重建项目)。现在只有已提交的 decisions 才回写本地状态,未提交时保留用户当前选择;setup 面板仅在首次加载或值变化时重绘,轮询期间不再打断交互。 +- 详情面板:锚定式项目(promptProfile v2+)`promptBody` 尚未编写时,不再把公共风格锚点以"最终生图提示词"标题展示(用户曾误认锚点为最终提示词)。改为"提示词进度"区块:明确标注锚点身份 + "正文尚未编写"状态,并隐藏复制按钮。正文存在时行为不变;v1 项目不受影响。 +- 兼容性:纯前端改动,无 schema、脚本或编译行为变化。验证:`node --check` + 既有 15 测试通过;建议手工冒烟——初始化页更改选择后等待 ≥3 个轮询周期确认不被重置,提交后 `setup-decisions.json` 与所选一致。 + +## 2026-07-18 — 海外剧本适配:基础身份事实、人物群体呈现与专有名词豁免 + +背景:用户审查发现四个欠考虑项——海外剧本(东南亚/欧美/拉美)的族裔呈现无执行抓手(宪法"不推断族裔"在图像层失效:中文提示词+不写人种=生图模型默认东亚面孔的沉默偏置)、性别/年龄无任何结构化约束、`isPrimarilyChinese` 会误杀拉丁人名密集的合法正文、"集-场"编号假设未覆盖 Scene/INT./EXT. 格式。 + +- **采集层**:圣经模板角色档案新增"基础身份事实"必填行(性别/年龄段/族裔或地域出身;只抄剧本明确写的,未写显式填"未明确",不得从名字或剧本语言推断)。`record_story_bible.mjs` 新增软校验:具名角色缺"性别"字样时逐个警告。模板头部新增非集-场剧本的编号映射规则与任意语言剧本声明。 +- **决策层**:美术总纲 v2 模板新增必填章节"人物群体呈现":剧本明确的照实沿用;"未明确"角色由 AI 美术总监按剧本地域与文化语境**自行决策**并写一行理由,随总纲整体确认(按用户要求弱化单独确认卡点,以美术总监指导为主,面向自动化流程)。`record_art_direction_brief.mjs` requiredHeadingsV2 同步;creative-charter 族裔条款改写:"项目级决策不允许留空——留空等于交给生图模型的默认值"。 +- **执行层**:prompt-authoring 岗位四契约新增:正文必须体现已确认的性别、年龄段、族裔面貌;圣经"未明确"且总纲无决策时不得自行发明,回报主代理。subagent-contracts 岗位一职责同步。 +- **`isPrimarilyChinese` 重写**:拉丁文从按字母计数改为按词计数,首字母大写的词(含 À-Þ 变音)按专有名词豁免。效果:含 María Fernández、Johnson 的中文正文通过;纯英文与英文为主的混杂描述仍被拒绝。影响所有中文占比校验点(readiness/validate/record),对存量中文项目无行为变化。 +- 验证:15/15 测试通过(新增 2 用例:专有名词豁免 + 缺"人物群体呈现"章节拒绝锁定)。兼容性:v2 新必填章节只影响新项目(v2 于同日发布,无存量 v2 草稿);v1 路径不变。 + +## 2026-07-18 — 四岗位架构重构:外观版本、并行流程、锚定式提示词与名册对账 + +依据《comic-assets-架构问题与解决方案-交叉验证交接.md》第十一节已确认结论,按批次 A→C→B 实施。 + +**批次 A(文案与规则层)** +- 重写 7 句带角色措辞的视觉范式种子(style-presets.json,manifest version→2):去掉角色/肤质/发丝/服饰等主体专属词,只留媒介与画质。修复根因之一:种子进公共段后污染场景与道具提示词。 +- 标点修复:`joinPromptSegments()`(去段尾句号再以"。"连接、末尾补一个)应用于 `compilePrompt` 与前端 `compiledPrompt`;`record_art_direction_brief` 拼接同步修复。消灭"。。"。兼容性注意:会改变既有项目重新编译时的 promptHash——对已锁定项目按"prompt 变化旧确认失效"原则处理,测试项目不受影响。 +- 故事资产圣经模板重写(bootstrap_story_bible):岗位一改称"剧本统筹与资产研究员";角色"状态"改为"外观版本"(定义:需独立参考图才能让多镜头稳定复现的固定视觉形态;服装/装备体系变化与永久身体变化构成新版本;表情/姿势/临时受伤/血迹/光效不记录——**推翻此前"状态全量记录"设计,用户拍板,排除项不留痕**);新增入册条件(有名字/台词/功能/关系任一命中即入册,与场次数无关,宁多勿漏)、类别代表与固定组合拆分规则、候选编号(C/L/P-XX);"别名、重复项和待确认项"章节改为"确定性处理结果"(五选一处置+一行理由,不再悬置待人工)。record_story_bible 校验同步(新标题+旧标题兼容别名,软校验措辞改外观版本)。 +- subagent-contracts 重写:四岗位边界(岗位一任务卡隔离:只给剧本+提取规则;岗位三不读美术总纲不写提示词;岗位四为主要并发阶段);"资产化准入"改为两层闸门——闸门一(入册,定性条件)永不由场次数决定角色存在性;闸门二(额外造型出图:≥2 场,或单场但贯穿主要段落按对话轮次/动作段计,或用户点名)。creative-charter 的不确定项条款改为确定性处置+留痕。prompt-authoring 差量白名单:只允许服装、装备体系、永久身体变化。 +- 验证:13/13 测试通过;临时项目端到端冒烟——新模板生成、新标题锁定、场景提示词开头为媒介级描述(无角色泄漏)、全链无"。。"。 + +**批次 C(流程层)** +- 圣经锁定后岗位二(美术总监)与岗位三(名册编制)并行:SKILL.md 正文与核心 DAG 改写;已核实 merge_agent_patches 本无美术总纲闸门,实测美术总纲未锁定时名册可合并。岗位四仍须等创作方向锁定。 + +**批次 B(结构层,锚定式合成方案,仅新项目生效)** +- `promptProfile` v2:唯一字段 `styleAnchor`(≤240 字);美术总纲模板 v2 以"公共风格锚点"+"分类型美术语言"取代"公共视觉语言"+"全局渲染语言";record 识别新章节走 v2(校验中文/无版式词/无角色措辞/长度),**不再拼接种子原文**(美术总监消化进锚点);旧模板走 v1 兼容路径不变。 +- 资产新增 `promptBody`:最终提示词 = 锚点 ⊕ 岗位四整段正文,逐字物化、无隐藏追加。呈现模板降级为需求规格(layouts 与分域默认配置新增 `requirements`,"站立"移出必备要素——修复轮椅角色与构图模板的结构性冲突);readiness 逐要素语义校验 + 防风格漂移词表(锚点级风格词出现在正文即拒绝)。merge update 白名单、审查 patch、plan_work_dag 提示词完整性、validate(v2 下 identityPrompt/deltaPrompt 退为可空输入材料)、viewer 编译展示全部同步 v2 分支。 +- 名册对账:patch 顶层可选 `roster`(candidateId/disposition/reason),merge 与圣经候选编号做 100% 对账(缺一多一均拒绝),通过后写入 `workflow.roster`;工作台新增 `/roster.html` 名册对照视图(统计+表格,自动更新),进度弹层在名册存在时给入口。 +- 新增 `tests/anchored-prompt-v2.test.mjs`(7 用例):锚点⊕正文逐字编译、轮椅坐姿正文通过需求规格(回归用例)、缺要素/风格漂移/缺正文拒绝 ready、名册对账失败与成功、record v2 写入与角色措辞拒绝。全部通过;旧套件 6/6 不受影响。 +- 已知边界:v1 存量项目行为不变(除标点规范化);圣经无候选编号时名册对账自动跳过;本轮未实现"呈现规格变化自动标记正文 stale"的增量工具,靠 sourceHash 失效兜底。 + +## 2026-07-17 — 工作台文档渲染与最终提示词展示(《你也配修仙》实测反馈) + +- 新增 `assets/viewer-app/doc.html`:项目内 markdown 文档的只读渲染视图(自带轻量渲染器,全文本先转义再插入受控标签,不执行文档内 HTML;前台 4 秒轮询自动更新)。故事资产圣经与项目创作方向在**草稿阶段**即可从工作台等待页预览("预览圣经草稿 / 预览创作方向草稿"链接),锁定后从进度弹层与等待页打开渲染版;确认与锁定仍全部在 Agent 对话中完成,服务端未新增任何写入口,`/project/` 的 `.md` 仍按 text/plain 提供原文。背景:用户实测反馈圣经简报只能看 md 源码,审阅体验断裂。 +- 详情检查器"最终生图提示词"改为只展示最终拼接文本(原五段分层展示删除;`compiledPrompt` 前端拼接逻辑未动,仍与后端 `compilePrompt()` 顺序、去重、"。"连接完全一致),保留一键复制。按来源分段的解释移到 Agent 对话按需进行,SKILL.md 审查章节措辞同步更新。背景:用户反馈右侧分层过多,只需要看发给模型的最终文本。 +- 兼容性:无 schema、状态、批次或脚本行为变化,纯前端与文档改动;旧项目直接生效。验证:`node --test tests/presentation-scope.test.mjs` 全部通过;在真实项目上启动 workspace_server 冒烟验证 doc.html 渲染、草稿/锁定两态链接与最终提示词展示。 + ## 2026-07-17 — 资产呈现作用域修复(第一阶段) - schemaVersion 继续保持 3,在配置内新增可判别的分域版本:`promptProfile.version: 1` 使用 `commonVisualLanguage` / `globalRenderLanguage`;`assetPresentation.version: 1` 明确拆分 `characterDefault`、`characterState`、`location`、`prop`。四类呈现各自保存 prompt 与 ratio,`config.image` 对新项目只承担质量等跨类型参数。 diff --git a/README.md b/README.md index a2a494e..01373b8 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ - 已安装并登录 Codex。 - 已安装 Node.js(用于初始化 Dialog 和确定性脚本)。 -- 如需正式生图,另行安装并登录 `lj-awb`;生图、积分与账号授权均由该环境处理。 +- 如需正式生图,安装并登录 [lj-awb CLI(官方安装说明)](https://animeworkbench.lingjingai.cn/lj-awb-cli);生图、积分与账号授权均由该环境处理。 ## 安装 diff --git a/SKILL.md b/SKILL.md index 8241283..156a1c7 100644 --- a/SKILL.md +++ b/SKILL.md @@ -15,7 +15,7 @@ description: 将用户上传的剧本直接转成可审查、可恢复、逐角 要求 Node.js >=20。真实生图还要求安装 lj-awb skill、联网并完成 AWB 登录;依赖不可用时仍交付完整项目、审查数据和供应商无关批次。 -收到新剧本后直接初始化,不要求用户命名目录: +剧本可以是任何语言与地域(国内、东南亚、欧美、拉美等):专有名词保留原文;角色的性别、年龄段、族裔只采剧本明确写出的事实,未明确的由 AI 美术总监在创作方向"人物群体呈现"章节按剧本地域语境统一决策,不交给生图模型的默认值。收到新剧本后直接初始化,不要求用户命名目录: ~~~bash node "/scripts/run_project.mjs" "<剧本文件>" --project-dir "<项目目录>" @@ -29,7 +29,9 @@ node "/scripts/workspace_server.mjs" "<项目目录>" node "/scripts/apply_setup_decisions.mjs" "<项目目录>/project-state.json" ~~~ -工作台只把选择写入 `setup-decisions.json`,不会直接修改 canonical 状态;`apply_setup_decisions.mjs` 校验后才把风格的中文提示词种子、默认角色构图描述和比例写入项目状态。提交后页面显示“设置已保存,等待 Agent 继续”,并自动轮询项目状态;不得让用户在阶段之间切换地址。随后**必须派一个可读取全文的子代理**用自然语言完成 `story-asset-bible.md` 的世界观、剧情时间线、角色别名与身份演变、场景、道具、证据和不确定项;主代理不得自行读取剧本原文(见"主代理上下文纪律")。不要要求子代理输出 JSON 或供应商提示词。证据一律用"集-场"编号指针而非台词全文;状态全量记录(含仅出现一次的),资产化取舍留给编译阶段按复用度决定。完成后固化该全局理解: +**初始化选择的授权规则**:setup 在总纲锁定前可以随时重选,不是不可逆动作,因此**不做前置确认**——用户在对话里明确说出了视觉范式和构图选择(说了即授权),主代理直接代写 `setup-decisions.json` 并运行 apply,然后在**同一条回复里**复述所写的预设名、定位、种子原文与构图(`apply_setup_decisions.mjs` 会输出这些内容),用户有异议当场重选即可,不要求用户去页面点选、不做多轮确认。两条边界不变:① 用户**没有**明确表达时不得自行猜测匹配预设、不得参考其他项目的历史偏好——引导用户在 Dialog 中点选;② 用户的描述与现有预设/构图不完全对应时,列出最接近的候选(含定位标签)让用户挑,不代做取舍。 + +工作台只把选择写入 `setup-decisions.json`,不会直接修改 canonical 状态;`apply_setup_decisions.mjs` 校验后才把风格的中文提示词种子、默认角色构图描述和比例写入项目状态。提交后页面显示“设置已保存,等待 Agent 继续”,并自动轮询项目状态;不得让用户在阶段之间切换地址。随后**必须派岗位一子代理(剧本统筹与资产研究员)**用自然语言完成 `story-asset-bible.md` 的世界观、剧情时间线、角色档案(含外观版本)、场景、道具、证据和确定性处理结果;派发任务卡只含剧本路径与事实提取规则,不携带画风、构图、模型参数或无关对话历史;主代理不得自行读取剧本原文(见"主代理上下文纪律")。不要要求子代理输出 JSON 或供应商提示词。证据一律用"集-场"编号指针而非台词全文;角色只记录真正的外观版本(整套服装/装备体系变化、永久身体变化),不记录表情、姿势、临时受伤等瞬时变化——定义与入册条件见圣经模板与 [references/subagent-contracts.md](references/subagent-contracts.md)。完成后固化该全局理解: ~~~bash node "/scripts/record_story_bible.mjs" "<项目目录>/project-state.json" @@ -37,15 +39,20 @@ node "/scripts/record_story_bible.mjs" "<项目目录>/project-stat 确认会锁定故事资产圣经。后续日常修改应落在美术风格、提示词差量、审查意见或图片锚点;如果发现全局剧情理解错误,保留当前项目和产物,另建或派生项目重新开始,不在原项目内改写圣经。 -故事资产圣经锁定后,由 AI 美术总监填写 `art-direction-brief.md`:提出创作命题、用户确认方向、公共视觉语言、全局渲染语言、正向设计重点与跨资产设计原则。全局渲染语言只写所有资产共享的材质、光影、清晰度和一致性,不得承载白底、胸像、三视图、正背面、排版或比例。用户确认后固化: +故事资产圣经锁定后,岗位二与岗位三**并行**推进: + +- **岗位二(AI 美术总监)**填写 `art-direction-brief.md`:提出创作命题、用户确认方向、媒介与画质(每个媒介维度一句,含感性的题材气质与调性,禁止具体色名与材质枚举)、人物群体呈现、画面内文字语言、分类型美术语言(含阵营色彩体系与主角辨识度层级)、正向设计重点与跨资产设计原则。任何章节不得承载白底、胸像、三视图、正背面、排版或比例。 +- **岗位三(名册编制)**同时依据圣经和资产准入规则生成 canonical 资产 sidecar patch(见下),它不读取美术总纲,不写美术提示词,二者互不等待。 + +**高后果章节确认协议**:请求用户确认总纲前,主代理必须把"人物群体呈现"与"画面内文字语言"两节**单独摘出**,各用一句后果化语言复述其成图后果(例:"这意味着成图主角将呈现为欧美面孔——确认吗?"、"这意味着道具上的可读文字将渲染为英文——确认吗?"),等用户逐条回应后才可固化;不得让这两节淹没在整体确认里。美术总纲经用户确认后固化: ~~~bash node "/scripts/record_art_direction_brief.mjs" "<项目目录>/project-state.json" ~~~ -它会把已确认的公共视觉语言和全局渲染语言写入 `styleProfile` / `promptProfile`。运行时只遵循 [references/creative-charter.md](references/creative-charter.md) 与 [references/prompt-authoring.md](references/prompt-authoring.md) 的短原则;不加载外部美术知识库原文作为逐条命令,也不使用负向提示词。 +新模板(整段正文 v3)下它校验"媒介与画质""人物群体呈现""画面内文字语言"等必填章节并把 `promptProfile` 标记为版本 3——**没有任何逐字拼接段**:媒介、画质、风格全部作为总纲输入材料,由岗位四消化后写进每条正文(正文第一句原样使用本维度的媒介与画质句)。历史模板继续兼容录入:含"公共风格锚点"章节的按锚定式 v2(锚点 ⊕ 正文),含"公共视觉语言/全局渲染语言"的按 v1。运行时只遵循 [references/creative-charter.md](references/creative-charter.md) 与 [references/prompt-authoring.md](references/prompt-authoring.md) 的短原则;不加载外部美术知识库原文作为逐条命令,也不使用负向提示词。 -随后由编译 Agent 依据故事资产圣经和项目创作方向生成 canonical 资产 sidecar patch,再运行 `merge_agent_patches.mjs`。仅在需要核验覆盖率或补漏时,才按剧本切片: +岗位三的编译 Agent 只依据故事资产圣经和资产准入规则(两层闸门,见 subagent-contracts)生成 canonical 资产 sidecar patch,主代理运行 `merge_agent_patches.mjs` 合并;这一步可以在美术总纲确认之前完成。岗位四写提示词必须等美术总纲锁定。仅在需要核验覆盖率或补漏时,才按剧本切片: ~~~bash node "/scripts/build_script_shards.mjs" "<项目目录>/<原始剧本>" --output "<项目目录>/workflow/chunks" @@ -62,11 +69,12 @@ node "/scripts/preflight.mjs" --bootstrap ## 核心 DAG 1. 项目启动即打开统一工作台,初始化 Dialog 覆盖在同一应用外壳上。用户单选视觉范式和默认角色资产构图;提交后页面保持在线并引导返回 Agent,`apply_setup_decisions.mjs` 将选择固化为项目事实。预览图仅供人判断,不进入任何模型输入。 -2. 全文理解 Agent 先写 `story-asset-bible.md`,建立全局实体、别名、身份演变、证据与不确定项;运行 `record_story_bible.mjs` 后锁定,才可继续。 -3. AI 美术总监依据故事资产圣经、用户意图和已选视觉范式提出 `art-direction-brief.md`;用户确认后运行 `record_art_direction_brief.mjs`,锁定项目创作方向。 -4. 编译 Agent 依据这两个已锁定产物生成 canonical 资产 sidecar patch,按复用度准入规则取舍状态并在 patch 的 `downgraded` 数组回报降级清单(见 subagent-contracts)。圣经中的每条状态要么成为 state 要么进 downgraded;只出默认态视为编译不完整,主代理退回重编。主代理运行 `merge_agent_patches.mjs`,统一稳定 ID、revision 和冲突。 +2. 岗位一(剧本统筹与资产研究员)先写 `story-asset-bible.md`,建立全局实体、别名、外观版本、证据与确定性处理结果;任务卡只含剧本与提取规则。运行 `record_story_bible.mjs` 后锁定,才可继续。 +3. 圣经锁定后**并行**:岗位二(AI 美术总监)依据圣经、用户意图和已选视觉范式提出 `art-direction-brief.md`,用户确认后运行 `record_art_direction_brief.mjs` 锁定创作方向;岗位三(名册编制)同时依据圣经和两层闸门准入规则生成 canonical 资产 sidecar patch,并在 patch 的 `downgraded` 数组回报降级清单(见 subagent-contracts)。岗位三不读取美术总纲。 +4. 名册对账:圣经中的每个外观版本要么成为 state 要么进 downgraded;只出默认外观视为编译不完整,主代理退回重编。主代理运行 `merge_agent_patches.mjs`,统一稳定 ID、revision 和冲突。 5. 剧本分片只用于核验与补漏。worker 不能在核验阶段自行创建最终 canonical ID;疑似新实体或别名冲突必须回报主代理。 -6. Prompt Agent 在公共方向已确认后自主用中文设计并写入分层提示词;完整 prompt 按“公共视觉语言 + 全局渲染语言 + 当前资产呈现 + 角色身份锚点 + 状态差量”在出批时编译,不追加负向提示词。角色默认态、角色状态、场景和道具分别继承自己的呈现 prompt 与比例,禁止跨域读取。专有名词可保留必要原文。 +6. 岗位四(Prompt Agent)在创作方向锁定且名册合并后,为每个资产用中文编写**整段提示词正文**(`promptBody`)并自行确认;新项目(promptProfile v3)最终提示词 = 正文本身,逐字物化,无任何拼接或隐藏追加——正文第一句原样使用总纲"媒介与画质"中本维度的句子,画面内可读文字必须指定语言或声明不可读(校验拒绝违规)。呈现模板降级为需求规格:正文必须语义覆盖该资产类型的必备呈现要素(白底、胸像、正/背面全身等),但措辞服从角色事实(轮椅角色写坐姿而非站立)。角色默认态、角色状态、场景和道具各自继承自己的需求规格与比例。存量项目兼容:锚定式 v2 = 锚点 ⊕ 正文(正文禁锚点级风格词);v1 按五段拼接编译。专有名词可保留必要原文。 + **并发派发按打包清单执行(硬要求)**:先运行 `node "/scripts/plan_prompt_shards.mjs" "<项目目录>/project-state.json" --missing-only`。分片(每角色一片含全部外观版本;场景、道具 ≤4 聚类)是**审计与对账单位**;脚本同时输出的包(bundle,普通分片按资产数聚合,多外观角色独占一包)才是**派发单位**。每包派一个子代理,完成一包立即领取下一包(常驻消费,不等整波齐),并发子代理数 = min(包数, 可用槽位);完成后按 shardId 对账,缺片补派。**任务卡必须自包含**:该包各资产的名册条目、圣经事实摘录、本类型媒介与画质词块、构图需求规格与文字策略——子代理不回读完整剧本、圣经或项目状态。**禁止按资产类型只开三个子代理,也禁止一片一派**(一片一派曾把 159 资产拖成 26 波、51 分钟)。 7. 运行 apply_review_decisions.mjs 消费稀疏审查 patch,再运行 plan_work_dag.mjs。 8. 默认态按角色独立释放。某角色默认图成功且用户锁定具体 artifactId 后,只解锁该角色后续状态;不等待其他角色。 9. 场景和道具按各自 ready 状态独立入批。单个阻塞项不得阻止其他可执行项。 @@ -82,6 +90,8 @@ node "/scripts/preflight.mjs" --bootstrap - 圣经与创作方向锁定后按需引用条目,不整篇读入主上下文。 - 对话式审查按角色或分组分批展示,不一次性倾倒全部资产的提示词。 - 不为项目即兴编写前端页面或查看器;给用户看进度和资产一律使用官方统一工作台(`workspace_server.mjs`)或 `download_artifacts.mjs` 下载到项目内的本地文件路径。 +- **不得即兴编写脚本写入项目目录或绕过审查/扣费闸门**(实跑中出现过主代理自建 approve-self-checked-prompts.mjs 之类的野生脚本)。批量放行、免确认等需求一律走官方脚本(`approve_prompt_batch.mjs`、`record_standing_authorizations.mjs`);官方脚本没覆盖的需求,报告用户而不是自己发明通道。 +- 任何结构化产物(canonical patch、审查 decisions、名册、超过约 50 行的 JSON/Markdown)必须用文件写入工具**整体写入**项目内路径;对话与终端只传路径、条目数和关键摘要。**禁止用 echo/heredoc 在终端拼接大文件**——终端摘要截断会产生半截 JSON,主代理和子代理同样适用。 ## 审查(对话式)+ 统一工作台 @@ -91,13 +101,13 @@ node "/scripts/preflight.mjs" --bootstrap node "/scripts/workspace_server.mjs" "<项目目录>" ~~~ -把终端输出的工作台 URL 发给用户。工作台在初始化阶段只允许写入 `setup-decisions.json`;设置固化后保持只读,提供五阶段状态、三栏浏览(资产列表 / 图片预览 / 详情检查器)和按来源分段的提示词展示,**无任何编辑、审批、锁定、生图或付费操作**。`bootstrap_review_package.mjs` 初始化 `review-decisions.json`(run_project 已自动执行)。 +把终端输出的工作台 URL 发给用户。工作台在初始化阶段只允许写入 `setup-decisions.json`;设置固化后保持只读,提供五阶段状态、三栏浏览(资产列表 / 图片预览 / 详情检查器)、与正式建批编译逐字一致的最终提示词展示,以及故事资产圣经与项目创作方向的只读 markdown 渲染视图(`/doc.html`,草稿阶段即可预览),**无任何编辑、审批、锁定、生图或付费操作**。`bootstrap_review_package.mjs` 初始化 `review-decisions.json`(run_project 已自动执行)。 主代理向用户展示待审内容并收集决定: -- 提示词:查看台的分段展示,或在对话中按来源分段(公共视觉语言 / 全局渲染语言 / 当前资产呈现 / 身份锚点 / 状态差量)加最终拼接文本;分段仅为展示,不写回状态。 +- 提示词:查看台只展示最终文本(与建批提交内容字节一致)。需要解释来源时在对话中说明:整段正文项目(v3)即单段正文;锚定式项目(v2)为"公共风格锚点 / 资产正文"两段;旧项目为"公共视觉语言 / 全局渲染语言 / 当前资产呈现 / 身份锚点 / 状态差量"五段。分段仅为展示,不写回状态。 - 图片:查看台预览优先使用项目内 `localPath`(结果回收后由 `download_artifacts.mjs` 下载写回);对话中给本地文件路径,不要给带时效签名的远程 URL。 -- 用户的确认、修改意见或直接给出的新文本,由主代理转写为稀疏审查 patch 写入 `review-decisions.json`:`status`(approved / changes_requested)、`note`、`identityPrompt` / `deltaPrompt` 局部修改、默认态锁定(`lockedReference` + `artifactId`)。patch 的 `baseRevision` 必须等于该文件当前 `revision`。不提供、也不接受直接覆盖完整编译结果的整段替换。 +- 用户的确认、修改意见或直接给出的新文本,由主代理转写为稀疏审查 patch 写入 `review-decisions.json`:`status`(approved / changes_requested)、`note`、`promptBody`(锚定式项目的正文修改)或 `identityPrompt` / `deltaPrompt`(旧项目局部修改)、默认态锁定(`lockedReference` + `artifactId`)。patch 的 `baseRevision` 必须等于该文件当前 `revision`。不提供、也不接受直接覆盖完整编译结果的整段替换。 写入后依次运行: @@ -109,6 +119,8 @@ node "/scripts/plan_work_dag.mjs" "<项目目录>/project-state.jso apply 之后立即向用户复述哪些资产被打回及其意见,作为下一轮提示词重写的输入;不要让 changes_requested 静默滞留。 +**提示词批量放行(需用户持久授权)**:用户明确表示"提示词不用逐条确认"后,运行 `node "/scripts/record_standing_authorizations.mjs" "<项目目录>/project-state.json" --batch-prompt-approval on` 记录授权;此后每轮审查可运行 `node "/scripts/approve_prompt_batch.mjs" "<项目目录>/project-state.json"`——质量自检通过的正文批量写入 approved 决定,**带可读文字的资产默认留人审**(文字内容错一字即报废),人工打回的 changes_requested 永不覆盖;随后照常 apply → validate → plan。向用户展示放行摘要并建议抽查主角资产。未授权时禁止任何形式的批量代批。 + ## 增量生图 只从 canonical ready 构建批次;approved、submitted、succeeded 不得直接或重复入批: @@ -117,13 +129,20 @@ apply 之后立即向用户复述哪些资产被打回及其意见,作为下 node "/scripts/build_image_batch.mjs" "<项目目录>/project-state.json" --scope "character:<角色ID>" --output "<项目目录>/image-batch.json" ~~~ -真实执行前必须读取当前 lj-awb skill 和 schema。动态查询模型、参数、价格与项目组,生成执行确认快照: +真实执行的接口知识以 [references/lj-awb-quickref.md](references/lj-awb-quickref.md) 为准(含适配版本号)——不要整篇阅读 lj-awb skill 文档;只有 `preflight.mjs` 报告的 lj-awb 版本与速查页不一致、或命令与速查页不符时,才回读 lj-awb 完整文档并更新速查页。执行前置的时序纪律: + +- **前移并行**:preflight、渠道/模型/项目组查询不依赖提示词内容,应在岗位四并发写正文期间由主代理并行完成,不要等提示词全部写完再串行开始。 +- **缓存**:渠道能力查询结果写入 `<项目目录>/workflow/awb-capabilities.json`(记录 fetchedAt 与 lj-awb 版本);同一项目 24 小时内且版本未变直接复用。估价与总积分永远全量重算并向用户展示。 +- **透明度**:该阶段每步向用户播报进行到哪一步(正在查询渠道能力 / 正在估价 / 正在校验第 x/N 项),不要让用户面对沉默的长等待。 + +模型、参数、价格与项目组动态查询后,生成执行确认快照: ~~~bash node "/scripts/prepare_awb_execution.mjs" "<项目目录>/image-batch.json" --model-group-code "" --project-group-no "" --estimated-cost "<积分>" ~~~ -向用户展示模型、尺寸、数量、预计积分、失败项重试策略和 executionPlanHash。用户明确确认后才运行 record_execution_approval.mjs,再按 lj-awb 当前 dry-run / --yes 规则提交。详细边界见 [references/execution-modes.md](references/execution-modes.md) 与 [references/provider-policy.md](references/provider-policy.md)。 +向用户展示模型、尺寸、数量、预计积分、失败项重试策略和 executionPlanHash。用户明确确认后才运行 record_execution_approval.mjs,再按 lj-awb 当前 dry-run / --yes 规则提交。 +**小额免确认(需用户持久授权)**:用户明确说出阈值(如"100 积分以下不用问我")后,运行 `record_standing_authorizations.mjs --auto-approve-below <积分>` 记录;此后预计消耗 ≤ 阈值的批次可直接 `record_execution_approval.mjs --auto --state --confirm-plan-hash ` 自动确认并提交——快照、估价与哈希仍必须完整播报留档。超过阈值、或项目未记录授权时,一律等待用户明确确认。详细边界见 [references/execution-modes.md](references/execution-modes.md) 与 [references/provider-policy.md](references/provider-policy.md)。 紧邻供应商调用前后使用 update_execution_checkpoint.mjs 记录 prepared → submitting → submitted;调用中断且没有 taskId 时转 submission_unknown。 diff --git a/assets/setup-app/style-presets.json b/assets/setup-app/style-presets.json index 1e75886..44fb9af 100644 --- a/assets/setup-app/style-presets.json +++ b/assets/setup-app/style-presets.json @@ -1,24 +1,293 @@ { - "version": 1, + "version": 3, "styles": [ - { "id": "script", "name": "按剧本生成", "family": "自动判断", "stylePromptSeed": "", "thumbnail": null, "examples": [] }, - { "id": "2d-urban-romance", "name": "都市言情小说", "family": "二维人设", "stylePromptSeed": "成熟向日漫剧情动画质感,半写实二维角色,干净黑色线稿,柔和赛璐璐上色,低饱和电影感色调,细腻渐变阴影与高清动画剧照质感。", "thumbnail": "style-thumbnails/2d-urban-romance.jpg", "examples": ["style-examples/2d-urban-romance/1.jpg", "style-examples/2d-urban-romance/2.jpg"] }, - { "id": "2d-american-retro", "name": "美式复古插画", "family": "二维人设", "stylePromptSeed": "美式复古动漫插画,九十年代欧美卡通与日系动画融合,平涂赛璐璐上色,流畅粗线稿,亮面阴影,复古商业广告感与潮流街头视觉。", "thumbnail": "style-thumbnails/2d-american-retro.jpg", "examples": ["style-examples/2d-american-retro/1.jpg", "style-examples/2d-american-retro/2.jpg"] }, - { "id": "2d-showa", "name": "复古昭和日漫", "family": "二维人设", "stylePromptSeed": "八十年代复古日系动漫与昭和漫画美术,经典 OVA 动画质感,手绘赛璐璐上色,高对比冷暖配色,浓烈黑色阴影块面,复古胶片颗粒与戏剧化打光。", "thumbnail": "style-thumbnails/2d-showa.jpg", "examples": ["style-examples/2d-showa/1.jpg", "style-examples/2d-showa/2.jpg"] }, - { "id": "2d-chinese-flat", "name": "古风平涂插画", "family": "二维人设", "stylePromptSeed": "传统东方二维手绘赛璐璐平涂,水墨与工笔融合,剪纸感轮廓,皮影式平面造型,低饱和敦煌配色,简洁勾线与传统国画留白,诗意神秘的东方幻想氛围。", "thumbnail": "style-thumbnails/2d-chinese-flat.jpg", "examples": ["style-examples/2d-chinese-flat/1.jpg", "style-examples/2d-chinese-flat/2.jpg"] }, - { "id": "2d-anime", "name": "通用二次元日漫", "family": "二维人设", "stylePromptSeed": "高完成度二维动画电影质感,柔和赛璐璐上色,干净线稿,细腻渐变阴影,通透光影与空气感色彩过渡,清爽干净的动画画面。", "thumbnail": "style-thumbnails/2d-anime.jpg", "examples": ["style-examples/2d-anime/1.jpg", "style-examples/2d-anime/2.jpg"] }, - { "id": "cg-stylized-3d", "name": "柔和三维动画", "family": "CG 人设", "stylePromptSeed": "非写实三维动画角色,软陶与黏土感渲染,哑光材质,柔和环境光,略微夸张的头身比例,简化服装褶皱与清晰色块。", "thumbnail": "style-thumbnails/cg-stylized-3d.jpg", "examples": ["style-examples/cg-stylized-3d/1.jpg"] }, - { "id": "cg-urban-romance", "name": "都市言情 CG", "family": "CG 人设", "stylePromptSeed": "乙女向游戏三维角色肖像,精致建模感,三维动画渲染,PBR 材质,哑光均匀肤质,分束清晰的发丝,柔和漫射光与高级都市时装质感。", "thumbnail": "style-thumbnails/cg-urban-romance.jpg", "examples": ["style-examples/cg-urban-romance/1.jpg"] }, - { "id": "cg-xianxia", "name": "古风仙侠 CG", "family": "CG 人设", "stylePromptSeed": "古风仙侠三维游戏角色,国风二次元三维渲染,飘逸衣料与精致刺绣,梦幻仙气,唯美古风,柔和漫射光与高清服饰细节。", "thumbnail": "style-thumbnails/cg-xianxia.jpg", "examples": ["style-examples/cg-xianxia/1.jpg"] }, - { "id": "cg-american-comic", "name": "美漫 3D", "family": "CG 人设", "stylePromptSeed": "美式漫画风三维动画电影质感,卡通化三维角色,夸张五官与清晰轮廓,漫画描边,半色调网点与印刷颗粒,强对比色块和鲜明色彩。", "thumbnail": "style-thumbnails/cg-american-comic.jpg", "examples": ["style-examples/cg-american-comic/1.jpg"] }, - { "id": "cg-general", "name": "通用漫剧 CG", "family": "CG 人设", "stylePromptSeed": "高精度三维漫剧角色,三维动画渲染与 PBR 材质,清晰色块,均匀哑光肤质,发丝分束清晰,服饰纹理可读,轮廓干净的商业角色设定质感。", "thumbnail": "style-thumbnails/cg-general.jpg", "examples": ["style-examples/cg-general/1.jpg", "style-examples/cg-general/2.jpg", "style-examples/cg-general/3.jpg", "style-examples/cg-general/4.jpg"] }, - { "id": "live-action", "name": "真人通用", "family": "真人", "stylePromptSeed": "商业摄影级真人角色质感,电影感光影,真实自然肤质与发丝,清晰服饰纹理,高级定妆照氛围,真实相机拍摄语言与细腻画面噪点。", "thumbnail": "style-thumbnails/live-action.jpg", "examples": ["style-examples/live-action/1.jpg"] } + { + "id": "script", + "name": "按剧本生成", + "family": "自动判断", + "positioning": "由美术总监按剧本决定", + "stylePromptSeed": "", + "thumbnail": null, + "examples": [] + }, + { + "id": "2d-urban-romance", + "name": "都市言情小说", + "family": "二维人设", + "positioning": "成熟日漫剧照向", + "stylePromptSeed": "成熟向日漫动画截图质感,都市恋爱剧情动画感,半写实动漫角色,干净黑色线稿,柔和赛璐璐上色,低饱和电影感色调,细腻渐变阴影,高清动画剧照质感。", + "thumbnail": "style-thumbnails/2d-urban-romance.jpg", + "examples": [ + "style-examples/2d-urban-romance/1.jpg", + "style-examples/2d-urban-romance/2.jpg" + ] + }, + { + "id": "2d-american-retro", + "name": "美式复古插画", + "family": "二维人设", + "positioning": "复古潮流插画向", + "stylePromptSeed": "美式复古动漫插画风,九十年代欧美卡通与日系动画融合,平涂赛璐璐上色,流畅粗线稿,亮面阴影,空气感色彩过渡,夸张漫画表现力,Y2K 潮流感,复古商业广告感,潮流街头视觉,高精度商业二次元插画。", + "thumbnail": "style-thumbnails/2d-american-retro.jpg", + "examples": [ + "style-examples/2d-american-retro/1.jpg", + "style-examples/2d-american-retro/2.jpg" + ] + }, + { + "id": "2d-showa", + "name": "复古昭和日漫", + "family": "二维人设", + "positioning": "昭和 OVA 向", + "stylePromptSeed": "八十年代复古日系动漫风,昭和漫画美术风格,经典 OVA 动画质感,手绘赛璐璐上色,低帧率复古动画感,高对比冷暖配色,浓烈黑色阴影块面,锐利高光,复古胶片颗粒,细腻手绘线稿,成熟系美型画风,强烈戏剧化打光,老动画印刷质感。", + "thumbnail": "style-thumbnails/2d-showa.jpg", + "examples": [ + "style-examples/2d-showa/1.jpg", + "style-examples/2d-showa/2.jpg" + ] + }, + { + "id": "2d-chinese-flat", + "name": "古风平涂插画", + "family": "二维人设", + "positioning": "传统国风动画向", + "stylePromptSeed": "老国产国风动画风格,传统二维手绘赛璐璐平涂,水墨与工笔融合,剪纸感轮廓,皮影式平面造型,低饱和敦煌配色,简洁黑色勾线,古典东方美术,柔和暖光,传统国画留白,复古胶片颗粒,低帧率动画截图质感,诗意神秘的东方幻想氛围。", + "thumbnail": "style-thumbnails/2d-chinese-flat.jpg", + "examples": [ + "style-examples/2d-chinese-flat/1.jpg", + "style-examples/2d-chinese-flat/2.jpg" + ] + }, + { + "id": "2d-anime", + "name": "通用二次元日漫", + "family": "二维人设", + "positioning": "治愈系动画电影向", + "stylePromptSeed": "高完成度二维动画电影质感,柔和赛璐璐上色,干净线稿,柔和边缘,低噪点,细腻渐变阴影,通透光影,空气感色彩过渡,干净色块,色彩清爽治愈。", + "thumbnail": "style-thumbnails/2d-anime.jpg", + "examples": [ + "style-examples/2d-anime/1.jpg", + "style-examples/2d-anime/2.jpg" + ] + }, + { + "id": "cg-stylized-3d", + "name": "柔和三维动画", + "family": "CG 人设", + "positioning": "黏土质感合家欢动画向", + "stylePromptSeed": "非写实三维动画渲染,软陶与黏土质感,非真人感,哑光材质,柔和环境光,低细节皮肤,无毛孔,头身比例略夸张,服装褶皱简化,色块清晰。", + "thumbnail": "style-thumbnails/cg-stylized-3d.jpg", + "examples": [ + "style-examples/cg-stylized-3d/1.jpg" + ] + }, + { + "id": "cg-urban-romance", + "name": "都市言情 CG", + "family": "CG 人设", + "positioning": "乙女游戏 CG 向", + "stylePromptSeed": "乙女向游戏三维角色质感,三维国漫角色肖像,偏游戏 CG 角色,精致建模感,UE5/Blender 三维动画渲染,PBR 材质,非写实人物,无真人感,低细节皮肤,无毛孔,哑光皮肤,低油光,皮肤颜色均匀,哑光分束发丝,发丝层次清晰,柔和漫射光,时尚大片氛围,细腻高清。", + "thumbnail": "style-thumbnails/cg-urban-romance.jpg", + "examples": [ + "style-examples/cg-urban-romance/1.jpg" + ] + }, + { + "id": "cg-xianxia", + "name": "古风仙侠 CG", + "family": "CG 人设", + "positioning": "国风游戏立绘向", + "stylePromptSeed": "古风仙侠三维游戏角色美术,梦幻仙气,唯美古风,国风游戏 CG 宣传图质感,非真人感,低细节皮肤,无毛孔,哑光分束发丝,柔和漫射光,高清细节。", + "thumbnail": "style-thumbnails/cg-xianxia.jpg", + "examples": [ + "style-examples/cg-xianxia/1.jpg" + ] + }, + { + "id": "cg-american-comic", + "name": "美漫 3D", + "family": "CG 人设", + "positioning": "美漫超英动画向", + "stylePromptSeed": "美式漫画风三维动画电影质感,超级英雄动画风,卡通化三维角色,非真人感,夸张五官,简化皮肤细节,哑光材质,PBR 三维渲染,清晰角色轮廓,漫画描边,半色调网点,印刷颗粒,强对比色块,色彩鲜明,高清动画 CG。", + "thumbnail": "style-thumbnails/cg-american-comic.jpg", + "examples": [ + "style-examples/cg-american-comic/1.jpg" + ] + }, + { + "id": "cg-general", + "name": "通用漫剧 CG", + "family": "CG 人设", + "positioning": "国漫 CG 向", + "stylePromptSeed": "三维国漫角色,高精度三维渲染质感,UE5/Blender 三维动画渲染,PBR 材质,色块清晰,无油光,无毛孔,低细节皮肤,面部清晰,皮肤均匀,夸张五官比例,非真人感,柔和漫射光,发丝分束清晰,服饰纹理清晰,轮廓干净,高清细腻,高完成度。", + "thumbnail": "style-thumbnails/cg-general.jpg", + "examples": [ + "style-examples/cg-general/1.jpg", + "style-examples/cg-general/2.jpg", + "style-examples/cg-general/3.jpg", + "style-examples/cg-general/4.jpg" + ] + }, + { + "id": "live-action", + "name": "真人通用", + "family": "真人", + "positioning": "超写实真人向", + "stylePromptSeed": "商业摄影级真人实拍质感,电影感光影,高级定妆照风格,超写实真人写真,无油光,真实的皮肤纹理和细节,超高细节,真实相机拍摄质感,细微摄影噪点画质,服饰纹理清晰,画面无杂物。", + "thumbnail": "style-thumbnails/live-action.jpg", + "examples": [ + "style-examples/live-action/1.jpg" + ] + } ], "layouts": [ - { "id": "closeup-front-back", "name": "特写 + 正背面设定", "ratio": "4:3", "prompt": "白色无缝背景的角色资产设定板,画面左侧为占主要视觉面积的正面胸像特写,表情平静、平视端庄;画面右侧为同一角色的正面全身站立图与回首背面全身图,排版紧密,人物比例与服装细节一致。", "image": "layout-examples/closeup-front-back.jpg" }, - { "id": "expression-front-back", "name": "情绪表情 + 正背面设定", "ratio": "16:9", "prompt": "白色无缝背景的多视角角色资产设定板,画面中部为占比最大的正面胸像特写,表情平静中性、平视端庄;左侧自上而下展示三张不同角度与表情的极近面部特写;右侧为同一角色的正面全身站立图与回首背面全身图,排版紧密,人物比例与服装细节一致。", "image": "layout-examples/expression-front-back.jpg" }, - { "id": "turnaround", "name": "标准正侧背三视图", "ratio": "16:9", "prompt": "白色无缝背景的角色三视图设定板,正面、侧面与背面完整全身站立图并列,人物比例、发型、服装和足部细节一致,清晰呈现角色结构。", "image": null }, - { "id": "single-full-body", "name": "单张全身立绘", "ratio": "4:3", "prompt": "白色无缝背景的单一角色全身立绘,清晰展示角色整体轮廓、服装、姿态和识别细节。", "image": null }, - { "id": "upper-body", "name": "上半身肖像", "ratio": "3:4", "prompt": "白色无缝背景的角色上半身肖像,优先清晰呈现面孔、发型、气质和上身服装细节。", "image": null } + { + "id": "closeup-front-back", + "name": "特写 + 正背面设定", + "ratio": "4:3", + "prompt": "白色无缝背景的角色资产设定板,画面左侧为占主要视觉面积的正面胸像特写,表情平静、平视端庄;画面右侧为同一角色的正面全身站立图与回首背面全身图,排版紧密,人物比例与服装细节一致。", + "image": "layout-examples/closeup-front-back.jpg", + "requirements": [ + { + "label": "白色无缝背景", + "match": "白色.{0,8}背景|白底" + }, + { + "label": "正面胸像特写", + "match": "胸像" + }, + { + "label": "正面全身图", + "match": "正面全身" + }, + { + "label": "背面全身图", + "match": "背面全身|回首背面" + } + ] + }, + { + "id": "closeup-turnaround", + "name": "特写 + 正侧背三视图", + "ratio": "16:9", + "prompt": "白色无缝背景的角色资产设定板,画面左侧为占主要视觉面积的正面胸像特写,表情平静、平视端庄;画面右侧并列同一角色的正面、侧面与背面完整全身图,人物比例、面貌、发型和服装结构严格一致。", + "image": null, + "requirements": [ + { + "label": "白色无缝背景", + "match": "白色.{0,8}背景|白底" + }, + { + "label": "正面胸像特写", + "match": "胸像" + }, + { + "label": "正面全身图", + "match": "正面" + }, + { + "label": "侧面全身图", + "match": "侧面" + }, + { + "label": "背面全身图", + "match": "背面" + } + ] + }, + { + "id": "expression-front-back", + "name": "情绪表情 + 正背面设定", + "ratio": "16:9", + "prompt": "白色无缝背景的多视角角色资产设定板,画面中部为占比最大的正面胸像特写,表情平静中性、平视端庄;左侧自上而下展示三张不同角度与表情的极近面部特写;右侧为同一角色的正面全身站立图与回首背面全身图,排版紧密,人物比例与服装细节一致。", + "image": "layout-examples/expression-front-back.jpg", + "requirements": [ + { + "label": "白色无缝背景", + "match": "白色.{0,8}背景|白底" + }, + { + "label": "多角度表情特写", + "match": "表情.{0,6}特写|面部特写" + }, + { + "label": "正面胸像特写", + "match": "胸像" + }, + { + "label": "正面全身图", + "match": "正面全身" + }, + { + "label": "背面全身图", + "match": "背面全身|回首背面" + } + ] + }, + { + "id": "turnaround", + "name": "标准正侧背三视图", + "ratio": "16:9", + "prompt": "白色无缝背景的角色三视图设定板,正面、侧面与背面完整全身站立图并列,人物比例、发型、服装和足部细节一致,清晰呈现角色结构。", + "image": null, + "requirements": [ + { + "label": "白色无缝背景", + "match": "白色.{0,8}背景|白底" + }, + { + "label": "正面全身图", + "match": "正面" + }, + { + "label": "侧面全身图", + "match": "侧面" + }, + { + "label": "背面全身图", + "match": "背面" + } + ] + }, + { + "id": "single-full-body", + "name": "单张全身立绘", + "ratio": "4:3", + "prompt": "白色无缝背景的单一角色全身立绘,清晰展示角色整体轮廓、服装、姿态和识别细节。", + "image": null, + "requirements": [ + { + "label": "白色无缝背景", + "match": "白色.{0,8}背景|白底" + }, + { + "label": "单一角色全身", + "match": "全身" + } + ] + }, + { + "id": "upper-body", + "name": "上半身肖像", + "ratio": "3:4", + "prompt": "白色无缝背景的角色上半身肖像,优先清晰呈现面孔、发型、气质和上身服装细节。", + "image": null, + "requirements": [ + { + "label": "白色无缝背景", + "match": "白色.{0,8}背景|白底" + }, + { + "label": "上半身肖像", + "match": "上半身|半身" + } + ], + "exclusions": [ + { + "label": "下半身内容(超出上半身肖像可见范围,会诱导模型拉远构图)", + "match": "鞋|靴|足部|脚踝|裤脚|裙摆" + } + ] + } ] } diff --git a/assets/viewer-app/doc.html b/assets/viewer-app/doc.html new file mode 100644 index 0000000..75a462d --- /dev/null +++ b/assets/viewer-app/doc.html @@ -0,0 +1,189 @@ + + + + + + 项目文档 · 漫剧资产工作台 + + + + +
+

项目文档

+ + 正在读取 +
+
+

正在读取文档…

+
+ + + diff --git a/assets/viewer-app/index.html b/assets/viewer-app/index.html index e0766da..48ab180 100644 --- a/assets/viewer-app/index.html +++ b/assets/viewer-app/index.html @@ -101,11 +101,20 @@

选择一个资产

准备工作台

页面会根据项目文件自动更新。

+ + diff --git a/assets/viewer-app/roster.html b/assets/viewer-app/roster.html new file mode 100644 index 0000000..836abb1 --- /dev/null +++ b/assets/viewer-app/roster.html @@ -0,0 +1,108 @@ + + + + + + 资产名册对照 · 漫剧资产工作台 + + + + +
+

资产名册对照

+ + 正在读取 +
+
+
+
正在读取名册…
+
+ + + diff --git a/assets/viewer-app/viewer.css b/assets/viewer-app/viewer.css index 1554afb..2fc010f 100644 --- a/assets/viewer-app/viewer.css +++ b/assets/viewer-app/viewer.css @@ -331,3 +331,23 @@ button:disabled { cursor: not-allowed; opacity: .58; } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } } + +/* 项目文档只读渲染入口与最终提示词展示 */ +.stage-doc-links { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; } +.wait-links { display: flex; justify-content: center; flex-wrap: wrap; gap: 8px 18px; margin-top: 14px; } +.wait-links:empty { display: none; } +.wait-doc-link { color: var(--primary); font-size: .8125rem; font-weight: 600; text-decoration: none; border-bottom: 1px solid currentColor; padding-bottom: 1px; } +.wait-doc-link:hover { color: var(--primary-hover); } +.wait-doc-tab { appearance: none; border: 0; background: none; padding: 0 0 1px; color: var(--primary); font-size: .8125rem; font-weight: 600; border-bottom: 1px solid currentColor; cursor: pointer; } +.wait-doc-tab:hover { color: var(--primary-hover); } +.wait-doc-tab.active { color: var(--ink); border-bottom-color: var(--ink); } +.wait-doc { width: min(880px, 92vw); margin-top: 22px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); overflow: hidden; box-shadow: 0 10px 30px rgb(0 0 0 / 6%); } +.wait-doc[hidden] { display: none; } +.wait-doc-bar { display: flex; align-items: center; gap: 12px; padding: 8px 14px; border-bottom: 1px solid var(--line); background: var(--surface-muted); } +.wait-doc-bar b { color: var(--ink-soft); font-size: .75rem; } +.wait-doc-bar a { margin-left: auto; color: var(--primary); font-size: .6875rem; font-weight: 600; text-decoration: none; } +.wait-doc-bar a:hover { color: var(--primary-hover); } +.wait-doc-close { appearance: none; border: 1px solid var(--line); background: var(--surface); border-radius: 6px; padding: 2px 8px; color: var(--muted); font-size: .6875rem; cursor: pointer; } +.wait-doc-close:hover { color: var(--ink-soft); } +.wait-doc iframe { display: block; width: 100%; height: min(52vh, 560px); border: 0; background: var(--surface); } +.final-prompt { margin: 10px 0 0; padding: 10px 12px; background: var(--surface-muted); border: 1px solid var(--line); border-radius: 8px; color: var(--ink); font-size: .8125rem; line-height: 1.8; white-space: pre-wrap; word-break: break-word; } diff --git a/assets/viewer-app/viewer.js b/assets/viewer-app/viewer.js index 72a1c68..c509a4a 100644 --- a/assets/viewer-app/viewer.js +++ b/assets/viewer-app/viewer.js @@ -9,6 +9,8 @@ const app = { status: "all", search: "", expandedCharacters: new Set(), + waitLinksKey: "", + waitDocHref: null, etag: null, refreshing: false, retryDelay: 2000, @@ -65,6 +67,18 @@ function projectFileUrl(value) { return "/project/" + String(value).split(/[\\/]+/).filter(Boolean).map(encodeURIComponent).join("/"); } +function docUrl(path, title) { + if (!path || remoteUrl(path)) return ""; + return "/doc.html?path=" + encodeURIComponent(path) + (title ? "&title=" + encodeURIComponent(title) : ""); +} + +function docLinksHtml(links, className) { + return (links ?? []) + .filter((link) => link?.href) + .map((link) => `${escapeHtml(link.label)}`) + .join(""); +} + function imageValue(image) { if (!image || typeof image !== "object") return ""; return image.localPath || image.url || image.backendPath || ""; @@ -160,10 +174,24 @@ function sanitizeGlobalRenderLanguage(value) { function resolvedPromptProfile() { const profile = app.state.config?.promptProfile ?? {}; + if (profile.version === 3) { + // v3 整段正文:无拼接段,最终提示词 = promptBody。 + return { version: 3, styleAnchor: "", commonVisualLanguage: "", globalRenderLanguage: "" }; + } + if (profile.version === 2) { + return { + version: 2, + styleAnchor: String(profile.styleAnchor ?? "").trim().replace(/\s+/g, " "), + commonVisualLanguage: "", + globalRenderLanguage: "" + }; + } const scoped = profile.version === 1 || Object.prototype.hasOwnProperty.call(profile, "commonVisualLanguage") || Object.prototype.hasOwnProperty.call(profile, "globalRenderLanguage"); return { + version: scoped ? 1 : null, + styleAnchor: "", commonVisualLanguage: scoped ? profile.commonVisualLanguage ?? "" : profile.style ?? "", globalRenderLanguage: sanitizeGlobalRenderLanguage(scoped ? profile.globalRenderLanguage : profile.render) }; @@ -253,7 +281,16 @@ function promptSegments(row) { } function compiledPrompt(row) { - return uniqueSegments(promptSegments(row).map((segment) => segment.value)).join("。"); + const profile = resolvedPromptProfile(); + const segments = profile.version === 3 + ? [row.asset.promptBody ?? ""] + : profile.version === 2 + ? [profile.styleAnchor, row.asset.promptBody ?? ""] + : promptSegments(row).map((segment) => segment.value); + const parts = uniqueSegments(segments) + .map((segment) => segment.replace(/[。\s]+$/u, "")) + .filter(Boolean); + return parts.length ? parts.join("。") + "。" : ""; } function workflowModel() { @@ -279,14 +316,23 @@ function workflowModel() { label: labels.script, status: storyDone && directionDone ? "已锁定" : current === "script" ? "进行中" : "等待设置", tone: storyDone && directionDone ? "complete" : current === "script" ? "current" : "", - href: storyDone && state.workflow.storyBible?.path ? projectFileUrl(state.workflow.storyBible.path) : null, - action: storyDone ? "查看故事资产圣经" : null + links: [ + storyDone && state.workflow.storyBible?.path + ? { href: docUrl(state.workflow.storyBible.path, "故事资产圣经"), label: "查看故事资产圣经" } + : null, + directionDone && state.workflow.artDirection?.path + ? { href: docUrl(state.workflow.artDirection.path, "项目创作方向"), label: "查看创作方向" } + : null + ].filter(Boolean) }, { id: "assets", label: labels.assets, status: current === "assets" ? "进行中" : assetsReady && !reviewPending ? "已完成" : hasGenerationActivity ? "仍有待审资产" : "尚未开始", - tone: current === "assets" || (hasGenerationActivity && reviewPending) ? "current" : assetsReady && !reviewPending ? "complete" : "" + tone: current === "assets" || (hasGenerationActivity && reviewPending) ? "current" : assetsReady && !reviewPending ? "complete" : "", + links: state.workflow?.roster?.entries?.length + ? [{ href: "/roster.html", label: "查看名册对照" }] + : [] }, { id: "generation", @@ -306,6 +352,8 @@ function milestone(label, tone = "") { function waitingModel() { const workflow = workflowModel(); const decisionSaved = app.setup?.decisions?.status === "complete"; + const biblePath = app.state.workflow?.storyBible?.path; + const briefPath = app.state.workflow?.artDirection?.path; if (!workflow.setupDone && decisionSaved) { return { stage: "项目设置 · 已保存", @@ -328,8 +376,9 @@ function waitingModel() { return { stage: "剧本解析 · 进行中", title: "正在建立故事资产圣经", - copy: "全文理解 Agent 会整理世界观、角色身份演变、场景、道具和证据。请在 Agent 对话中继续流程。", + copy: "全文理解 Agent 会整理世界观、角色身份演变、场景、道具和证据。圣经草稿写盘后可在下方实时预览;确认与锁定在 Agent 对话中完成。", milestones: [milestone("项目设置", "complete"), milestone("全文理解", "current"), milestone("创作方向")], + links: [biblePath ? { href: docUrl(biblePath, "故事资产圣经(草稿)"), label: "预览圣经草稿" } : null].filter(Boolean), handoff: true }; } @@ -339,18 +388,57 @@ function waitingModel() { title: "故事资产圣经已经锁定", copy: "下一步由 AI 美术总监提出项目创作方向,并在 Agent 对话中等待确认。", milestones: [milestone("全文理解", "complete"), milestone("创作方向", "current"), milestone("资产编译")], + links: [ + biblePath ? { href: docUrl(biblePath, "故事资产圣经"), label: "查看故事资产圣经" } : null, + briefPath ? { href: docUrl(briefPath, "项目创作方向(草稿)"), label: "预览创作方向草稿" } : null + ].filter(Boolean), handoff: true }; } return { stage: "资产整理 · 进行中", title: "正在整理角色、场景和道具", - copy: "资产和中文分层提示词完成后会自动出现在工作台中。", + copy: "资产和中文提示词完成后会自动出现在工作台中。", milestones: [milestone("剧本解析", "complete"), milestone("资产编译", "current"), milestone("提示词整理")], + links: [ + biblePath ? { href: docUrl(biblePath, "故事资产圣经"), label: "查看故事资产圣经" } : null, + briefPath ? { href: docUrl(briefPath, "项目创作方向"), label: "查看创作方向" } : null + ].filter(Boolean), handoff: true }; } +function closeWaitDoc() { + app.waitDocHref = null; + $("#wait-doc").hidden = true; + $("#wait-doc-frame").src = "about:blank"; + document.querySelectorAll(".wait-doc-tab").forEach((tab) => tab.classList.remove("active")); +} + +function openWaitDoc(href, label) { + app.waitDocHref = href; + $("#wait-doc-title").textContent = label; + $("#wait-doc-open").href = href; + $("#wait-doc-frame").src = href + "&embed=1"; + $("#wait-doc").hidden = false; + document.querySelectorAll(".wait-doc-tab").forEach((tab) => tab.classList.toggle("active", tab.dataset.docHref === href)); +} + +// 等待页文档改为内嵌展示;链接内容未变化时不重建 DOM,避免轮询打断阅读中的 iframe。 +function renderWaitLinks(links) { + const items = (links ?? []).filter((link) => link?.href); + const key = JSON.stringify(items); + if (key === app.waitLinksKey) return; + app.waitLinksKey = key; + $("#wait-links").innerHTML = items + .map((link) => ``) + .join(""); + if (app.waitDocHref && !items.some((link) => link.href === app.waitDocHref)) closeWaitDoc(); + else if (app.waitDocHref) { + document.querySelectorAll(".wait-doc-tab").forEach((tab) => tab.classList.toggle("active", tab.dataset.docHref === app.waitDocHref)); + } +} + function renderWorkspaceMode() { const rows = createRows(); const workflow = workflowModel(); @@ -359,11 +447,15 @@ function renderWorkspaceMode() { $("#app-shell").classList.toggle("waiting", waiting); $("#workflow-wait").hidden = !waiting; $("#setup-overlay").hidden = workflow.setupDone || app.setup?.decisions?.status === "complete"; - if (!waiting) return; + if (!waiting) { + if (app.waitDocHref) closeWaitDoc(); + return; + } $("#wait-stage").textContent = wait.stage; $("#wait-title").textContent = wait.title; $("#wait-copy").textContent = wait.copy; $("#wait-milestones").innerHTML = wait.milestones.join(""); + renderWaitLinks(wait.links); $("#agent-handoff").hidden = !wait.handoff; } @@ -374,8 +466,8 @@ function renderHeader() { $("#stage-button").querySelector(".status-dot").style.background = workflow.current === "results" ? "var(--success)" : "var(--warning)"; $("#project-meta").textContent = `revision ${app.state.revision ?? 0} · ${app.state.config?.styleProfile?.name ?? "尚未锁定创作方向"}`; $("#stage-popover").innerHTML = `

项目进度

${workflow.rows.map((item) => { - const action = item.href - ? `${escapeHtml(item.action)}` + const action = item.links?.length + ? `${docLinksHtml(item.links, "stage-doc-link")}` : `${escapeHtml(item.status)}`; return `
${escapeHtml(item.label)} ${escapeHtml(item.status)}
${action}
`; }).join("")}`; @@ -478,15 +570,24 @@ function renderSelection(row) { renderInspector(row); } -function segmentHtml(segment) { - return `
-
${escapeHtml(segment.label)}${escapeHtml(segment.meta)}
-

${escapeHtml(segment.value)}

-
`; +function promptSectionHtml(row) { + const profile = resolvedPromptProfile(); + const body = String(row.asset.promptBody ?? "").trim(); + // 锚定式项目正文尚未编写时,锚点不是最终提示词,不能以"最终生图提示词"标题展示。 + if (profile.version >= 2 && !body) { + return `
+

提示词进度

正文尚未编写
+ ${profile.styleAnchor ? `
公共风格锚点(全项目共享的逐字拼接段):${escapeHtml(profile.styleAnchor)}
` : ""} +

最终生图提示词由资产正文构成,岗位四完成正文后在此显示完整文本。

+
`; + } + return `
+

最终生图提示词

+

${escapeHtml(compiledPrompt(row))}

+
`; } function renderInspector(row) { - const segments = promptSegments(row); const descriptionTitle = row.kind === "character" ? "角色描述" : row.kind === "location" ? "场景说明" : "道具说明"; const description = row.description || (row.kind === "character" ? "角色剧情身份待补充。" : "资产说明待补充。"); const note = row.asset.review?.note ?? ""; @@ -496,14 +597,7 @@ function renderInspector(row) {

${escapeHtml(description)}

${row.visualBrief ? `
本资产视觉摘要:${escapeHtml(row.visualBrief)}
` : ""} -
-

最终生图提示词

-
${segments.map(segmentHtml).join("")}
-
- 查看模型实际接收文本 -

${escapeHtml(compiledPrompt(row))}

-
-
+ ${promptSectionHtml(row)} ${note ? `

最近审查意见

在对话中更新

${escapeHtml(note)}

@@ -536,7 +630,7 @@ function renderSetupStyleDetail() { const examples = item.examples?.length ? `
方向样例,仅用于选择${item.examples.length} 张
${item.examples.map((source, index) => `
${escapedAttribute(item.name)}样例${index + 1}
参考样例 ${index + 1}
`).join("")}
` : `
不固定二维、CG 或真人。AI 美术总监将根据剧本、世界观和用户意图决定视觉表现。
`; - $("#setup-style-detail").innerHTML = `

${escapeHtml(item.name)}

${escapeHtml(item.stylePromptSeed || "不使用预设风格种子。")}

${examples}`; + $("#setup-style-detail").innerHTML = `

${escapeHtml(item.name)}

${item.positioning ? `

定位:${escapeHtml(item.positioning)}

` : ""}

${escapeHtml(item.stylePromptSeed || "不使用预设风格种子。")}

${examples}`; } function renderSetupStyles() { @@ -608,13 +702,21 @@ async function loadState(conditional = false) { async function loadSetup() { const { payload } = await requestJson("/api/setup"); + const firstLoad = !app.manifest; + // 轮询期间不得覆盖用户尚未提交的页面选择:只有已提交的 decisions 才回写本地状态。 + const decisionSaved = payload.decisions?.status === "complete"; + const nextPreset = decisionSaved ? payload.decisions.visualPresetId : (firstLoad ? "script" : app.visualPresetId); + const nextLayout = decisionSaved ? payload.decisions.characterLayoutId : (firstLoad ? "closeup-front-back" : app.characterLayoutId); + const changed = firstLoad || nextPreset !== app.visualPresetId || nextLayout !== app.characterLayoutId; app.setup = payload; app.manifest = payload.manifest; - app.visualPresetId = payload.decisions?.visualPresetId ?? "script"; - app.characterLayoutId = payload.decisions?.characterLayoutId ?? "closeup-front-back"; + app.visualPresetId = nextPreset; + app.characterLayoutId = nextLayout; $("#setup-project-title").textContent = payload.project?.title ?? ""; - renderSetupStyles(); - renderSetupLayouts(); + if (changed) { + renderSetupStyles(); + renderSetupLayouts(); + } } function setConnectionState(disconnected) { @@ -732,6 +834,15 @@ $("#setup-layout-catalog").addEventListener("click", (event) => { renderSetupLayouts(); }); +$("#wait-links").addEventListener("click", (event) => { + const tab = event.target.closest("[data-doc-href]"); + if (!tab) return; + if (app.waitDocHref === tab.dataset.docHref) closeWaitDoc(); + else openWaitDoc(tab.dataset.docHref, tab.dataset.docLabel); +}); + +$("#wait-doc-close").addEventListener("click", closeWaitDoc); + $("#setup-submit").addEventListener("click", submitSetup); $("#reload-button").addEventListener("click", () => refresh({ force: true, notify: true })); diff --git a/references/creative-charter.md b/references/creative-charter.md index 2544e51..d987514 100644 --- a/references/creative-charter.md +++ b/references/creative-charter.md @@ -12,11 +12,12 @@ ## 创作原则 -- 不把未证实的视觉设计伪装成剧情事实;不确定项必须留给用户确认。 +- 不把未证实的视觉设计伪装成剧情事实。事实层的别名、重复项按确定性处置规则落地(归并/拆分/类别/排除/矛盾告警)并附一行理由留痕;只有重大创作取向的分歧才升级给用户确认。 - 用轮廓、比例、色彩、材质、空间和重复视觉锚点建立可见差异,不依赖细碎堆砌。 - 让角色、场景和道具共同服务同一叙事世界;每个核心设计选择都应能解释其与人物或剧情的关系。 - 默认资产优先稳定、清晰和可复用;状态资产才承载剧情阶段、情绪、动作或损耗。 -- 不根据名字、语言、目标市场或模糊描述推断人物族裔、文化背景或其他敏感身份。剧本未明确时,提出可选方向或标记待确认。 +- 不根据名字、语言或模糊描述推断**个别角色**的族裔、文化背景或其他敏感身份。剧本未明确时,由 AI 美术总监在创作方向"人物群体呈现"章节给出项目级呈现决策并写明理由(随总纲整体确认):默认按**剧本文化语境的主流面孔**统一呈现(西式命名与西式世界观 → 欧美面貌为主;东南亚背景 → 东南亚面貌为主);**除非剧本明确写了多元设定,不得以"国际化都市/开放港城"等语境推演为由做多族裔均衡分布**——本 skill 服务的短剧面向明确目标市场,均衡分布不是商业默认。不把族裔编码为善恶或权力等级;用户明确指定时以用户为准。不允许把这个决定留空——留空等于交给生图模型的默认值。 +- 画面内出现的可读文字(招牌、标签、铭文、文件、菜单等)遵循创作方向"画面内文字语言"的项目级决策;要求可读文字的资产正文须写明语言与内容原文,装饰性文字默认纹样化处理、不构成可读文字。这个决定同样不允许留空——留空等于让生图模型把提示词语言画上画面。 - 已确认的视觉锚点必须在后续状态中继承,除非剧情或用户明确要求改变。 ## 不可替代的生产边界 @@ -24,4 +25,4 @@ - 不得违反已锁定的故事资产圣经。 - 不得绕过用户对项目创作方向、提示词或付费批次的确认。 - 不得用复杂背景、额外人物或无关可手持道具破坏被要求的隔离资产。 -- 不把负向提示词、排除词清单或模型规避补丁写入最终 prompt。 +- 不使用负向提示词参数通道、排除词清单或"避免……"式模型规避补丁。**内联否定描述词不在此列**:"无毛孔""无油光""非真人感""不带文字""不要露沟"等是正常且常常必要的风格描述(防风格漂移的钉子),允许并应当按需写入提示词。 diff --git a/references/lj-awb-quickref.md b/references/lj-awb-quickref.md new file mode 100644 index 0000000..e29234b --- /dev/null +++ b/references/lj-awb-quickref.md @@ -0,0 +1,76 @@ +# lj-awb 接口速查(comic-assets 专用浓缩版) + +> **适配 lj-awb 版本:0.5.5**(浓缩自该版本 SKILL.md 与 modules/ 文档)。 +> `preflight.mjs` 报告的 lj-awb 版本与此不一致时,本页可能过期:回读 lj-awb 完整文档(`~/.codex/skills/lj-awb/SKILL.md` 及其 modules/),核对本页命令后更新页首版本号。 +> 本页只承载**接口形态**(命令、参数、流程、规则)。模型清单与价格是易变数据,**永远动态查询,不写进本页**。 + +## 命令解析(每个新环境一次) + +```bash +for dir in "$PWD/skills/lj-awb" "$HOME/.codex/skills/lj-awb" "$HOME/.claude/skills/lj-awb" "$HOME/.cc-switch/skills/lj-awb"; do + if [ -x "$dir/scripts/resolve-lj-awb-cmd.sh" ]; then + LINGJING_AWB_CMD="$(bash "$dir/scripts/resolve-lj-awb-cmd.sh")" + break + fi +done +``` + +comic-assets 的 `preflight.mjs --bootstrap` 已封装此逻辑并输出版本号。 + +## 本 skill 用到的最短命令链(批量文生图) + +```bash +# 1. 环境与上下文(缺认证/项目组上下文时才跑;结果可缓存本会话复用) +lj-awb doctor --verify +lj-awb project current # 拿默认 projectGroupNo;doctorStatus=error 时停止 + +# 2. 渠道能力(动态数据;结果写入 workflow/awb-capabilities.json 缓存) +lj-awb model image-models --model "" # 候选模型组 +lj-awb model options --model-group-code -f text # ratio/quality/generateNum 合法值与默认值 +lj-awb model create-spec --model-group-code -f text + +# 3. 估价(fee 是最终估价,不是参数探索工具,不要为多组参数反复跑) +lj-awb create image-fee --model-group-code --prompt "<样例prompt>" \ + --ratio --quality --project-group-no + +# 4. 批量提交(JSONL 每行只写差异字段;公共参数放命令行) +lj-awb create image-batch \ + --input-file ./image.jsonl \ + --model-group-code \ + --project-group-no \ + --concurrency 4 \ + --yes + +# 5. 轮询与收割 +lj-awb task wait --task-id --wait-seconds 600 -f text +lj-awb task status --task-id -f text +``` + +JSONL 行格式(仅差异字段): + +```json +{"prompt":"...","ratio":"16:9","quality":"2k"} +``` + +允许字段:`prompt`、`ratio`、`quality`、`generate_num`、`resources`、`resource`、`model_params`。参考图生图时资源写 `{"type":"image","usage":"reference","source":{"kind":"url","value":"material/..."}}`。 + +## 硬规则(0.5.5,违反会扣错钱或被后端拒) + +- **`--project-group-no` 默认必传**:不传时后端跳过项目组预算隔离,扣账号通用积分(记到"杂项"账上)。先 `project current` 拿默认项目组。 +- **扣费动作前必须确认**:`safety.requiresConfirmation=true` 的命令先向用户确认再加 `--yes`;`supportsDryRun=true` 的写入/扣费命令先 `--dry-run`。 +- **批量是 best-effort**:单条失败不回滚整批,已成功项照样扣分。提交后必读响应 `successCount` / `failureCount` 与每项 `status` / `taskId` / `statusCommand` / `error`;失败项单独修复重跑,**不要全批重跑**。 +- 复用上游结果作下游输入时,`source.value` 用 `backendPath`(`material/...`),**不要**复制带签名的完整 https `url`。 +- 同一参考素材 ≥3 个任务复用时,先 `lj-awb upload files --files ...` 拿 `backendPath`,避免逐任务重复上传。 +- 生图批量并发建议 `--concurrency 4`;≥30% 任务失败或上游 429 时下次降为 1-2。 +- 积分口径:只把 `billingPointBalance` 当可扣余额;`projectBudgetBalance` 是项目组预算,不要混说。 +- Agent 读输出默认 `-f text`(稳定 key=value 分区),不要解析 pretty 表格;仅脚本严格解析时用 `-f json`。 +- 命令返回 `nextCommand` 时优先复用返回值,不手拼。 +- 等待超时只代表本轮轮询结束,不等于任务失败。 +- prompt 原文直出:提交本 skill 编译的最终提示词,不改写、不润色。 +- 命令或参数与本页不符时以 `lj-awb schema --domain create --command image-batch -f json` 的精确契约为准。 + +## 渠道能力缓存约定(comic-assets 侧) + +- 第 2 步查询结果由主代理写入 `<项目目录>/workflow/awb-capabilities.json`:`{ fetchedAt, ljAwbVersion, modelGroupCode, options摘要, projectGroupNo }`。 +- 同一项目 24 小时内、且 preflight 报告的 lj-awb 版本未变时直接复用,不重查;`prepare_awb_execution.mjs` 前只做一次 `image-fee` 轻量复核。 +- 估价与总积分**永远全量重算并向用户展示**,确认环节不因缓存省略。 diff --git a/references/project-schema.md b/references/project-schema.md index 1b36c00..87903eb 100644 --- a/references/project-schema.md +++ b/references/project-schema.md @@ -280,3 +280,29 @@ AWB 归一化结果: ] } ~~~ + +## 锚定式提示词(promptProfile v2,2026-07-18) + +新项目由 `record_art_direction_brief.mjs` 从含"公共风格锚点"章节的美术总纲写入: + +~~~json +{ + "config": { + "promptProfile": { "version": 2, "styleAnchor": "……(≤240 字,唯一逐字拼接段)" }, + "assetPresentation": { + "characterDefault": { + "id": "closeup-front-back", "ratio": "4:3", + "prompt": "……(v1 编译用;v2 下仅作参考文案)", + "requirements": [ { "label": "正面全身图", "match": "正面全身" } ] + } + } + } +} +~~~ + +- 资产新增 `promptBody`:岗位四编写的整段正文。v2 编译输出 = `styleAnchor` ⊕ `promptBody`(`joinPromptSegments` 去尾句号后以"。"连接),呈现/身份/差量字段不再拼接。 +- `requirements` 是呈现需求规格:readiness 逐项以正则做语义存在校验;缺项或正文含锚点级风格词(`findAnchorStyleTerms`)都会阻止 ready。 +- `identityPrompt` / `deltaPrompt` 在 v2 中退为岗位四的输入材料与审计字段(差量白名单:服装、装备体系、永久身体变化);validate 允许为空。 +- 审查 patch 新增 `promptBody` 字段;merge 的 update 白名单同步放行。 +- `compilePrompt` 的 sourceHash 含 `{styleAnchor, presentation(id/ratio/requirements), promptBody}`——锚点或规格变化会改变 promptHash,经既有机制显式失效旧确认。 +- patch 顶层可选 `roster` 数组:`{candidateId(C/L/P-编号), name, disposition(included|merged|excluded|category|split), reason, assetIds}`。merge 校验编号唯一、理由必填,并与圣经中出现的候选编号做 100% 对账(缺一或多一都拒绝合并),通过后写入 `workflow.roster`,工作台 `/roster.html` 展示对照表。 diff --git a/references/prompt-authoring.md b/references/prompt-authoring.md index 98d1761..d04eb6a 100644 --- a/references/prompt-authoring.md +++ b/references/prompt-authoring.md @@ -1,38 +1,73 @@ -# Prompt Agent:从创作方向到资产提示词 +# Prompt Agent(岗位四):从创作方向到资产提示词 -Prompt Agent 是 AI 美术总监的执行阶段,而不是规则填空器。运行时仅阅读:故事资产圣经、已锁定的项目创作方向、当前 canonical 资产、相关原文证据和本文件;不要加载外部美术知识库原文作为逐条命令。默认以中文写提示词,专有名词可保留必要原文。 +岗位四是 AI 美术总监的执行阶段,而不是规则填空器。运行时仅阅读:故事资产圣经、已锁定的项目创作方向(含媒介与画质、人物群体呈现、画面内文字语言、分类型美术语言)、正式名册条目、相关原文证据和本文件;不要加载外部美术知识库原文作为逐条命令。默认以中文写提示词,专有名词可保留必要原文。 -## 工作顺序 +## 整段正文(promptProfile v3,新项目默认) -1. 从故事资产圣经读取事实、人物关系、状态变化、场景用途与不确定项。 -2. 从项目创作方向读取用户确认的视觉命题、公共视觉语言、全局渲染语言与正向设计重点;其中公共视觉语言已包含用户单选的视觉范式中文种子。 -3. 使用 `creative-charter.md` 自主推导视觉设计;先形成简短设计理由,再写 prompt。 -4. 用中文仅把稳定可见身份写入 `identityPrompt`;用中文仅把本状态相对默认态的变化写入 `deltaPrompt`。 -5. 场景和道具直接写其当前可见设计,但区分剧情事实与自主设计;不确定时保守并标记待确认。 -6. 交由用户在对话中确认。批准前的文本永远只是候选。 +最终提示词 = **你写的整段正文(`promptBody`),一字不多一字不少**。系统不做任何逐字拼接或隐藏追加;媒介、画质、风格、身份、呈现方式全部由你写进正文。你对正文的完整质量负责。 -## 字段边界 +### 写作格式(经真实生图验证的稳定格式,必须遵守) -- `promptProfile.commonVisualLanguage`:项目公共视觉语言,所有资产共享。 -- `promptProfile.globalRenderLanguage`:所有资产共享的材质、光影、清晰度和一致性语言;不得包含白底、胸像、半身像、多视图、正背面、角色站姿、左右排版或画幅比例。 -- `config.visualPreset.stylePromptSeed`:初始化 Dialog 固化的视觉范式种子;由系统合并进 `promptProfile.commonVisualLanguage`,不需要也不得重写为参考图输入。 -- `config.assetPresentation.characterDefault`:仅 sequence: 0 的角色资产继承。 -- `config.assetPresentation.characterState`:仅 sequence > 0 的角色状态继承。 -- `config.assetPresentation.location`:仅场景资产继承。 -- `config.assetPresentation.prop`:仅道具资产继承。四类配置各自提供 prompt 与 ratio,不读取角色默认态或全局比例。 -- `identityPrompt`:角色跨状态不变的面孔、体态、发型、固定穿戴和识别锚点。 -- `deltaPrompt`:当前状态的服装、姿态、状态变化、环境适配与必要的剧情差量。 -- `description`、`visualBrief`、`sourceRefs`:解释与证据,不直接当作供应商 prompt。 +正文是**给生图模型的短语铺陈流**,不是给人读的文章。按以下固定顺序组织,全文用逗号分隔的短语堆叠,不写散文长句: -最终 prompt 以中文为主,不使用负向提示词、排除词清单或 `negativeConstraints`。用正向设计语言描述希望出现的画面;需要讨论的风险留在审查意见中,而不拼入模型输入。 +1. **媒介与画质词块**:原样使用总纲"媒介与画质"中本资产所属维度、**本资产类型**的词块(角色资产抄"××·角色"块,场景与道具抄"××·场景与道具"块)。不要改写、不要换措辞、不要混抄另一类型的块——这一块是跨资产风格一致的锚,里面的"无毛孔/非真人感"等否定式钉子是防漂移关键,不得删减。 +2. **主体一句**:身份用一个短句带过("外表约二十岁的中国古代修仙男性,东亚面貌"),随后铺陈可画的外观短语:脸型五官、发型发色、**头身比例与身高体型**(成年角色默认声明"写实七至八头身"或按定位声明夸张头身比——这是防止角色滑向 Q 版/幼态的关键钉子)、气质神态。 +3. **服装与识别物**:一次性完整铺陈本外观版本的服装结构、材质、佩饰与识别锚点,短语堆叠。**可见范围裁剪**:只写所选呈现范围内画面可见的部分——上半身肖像不写下装、鞋靴、裙摆、腿部(描述画外内容会诱导模型拉远构图破坏版式,命中呈现配置的排除项会被校验拒绝);全身构图才写完整服装体系。圣经记录的整套服装事实不因此丢失,被裁剪的部分留给全身构图的资产或镜头级提示词。 +4. **版式收尾模板**:一次性说清构图(例:"纯白背景,干净商业角色资产设定板排版,左侧大尺寸正面胸像特写,右侧正面全身图与回首背面全身图并列,人物比例、面貌、发型与服装结构一致"),语义覆盖本资产呈现需求规格(`assetPresentation..requirements`)的每一项,措辞服从角色事实。结尾给文字策略:默认"不带文字";需要画面出现可读文字时**逐个点名**(旗面为中文"招徒")。 -初始化 Dialog 中的示例图只提供给用户识别视觉方向和构图类型,绝不随 prompt、批次或 AWB 请求发送。 +**角色资产默认空手(硬规则)**:双手自然下垂或轻握,不携带包、笔记本电脑、话筒、文件、武器等任何手持道具——职业与身份用服装、材质与气质表达,剧情识别物走独立道具资产。仅当圣经把佩挂记为该外观版本的装备体系事实(如腰侧佩剑、肩背行囊)时按事实写**佩挂**,仍不写手持。 -向用户展示最终 prompt 时可按来源分段说明(公共视觉语言 / 全局渲染语言 / 当前资产呈现 / 身份锚点 / 状态差量),但分段只属于展示层,不写回项目状态。公共语言和四类呈现配置只读;默认角色的 `identityPrompt` 与当前资产的 `deltaPrompt` 依据用户在对话中的意见,通过审查 patch(review-decisions.json → apply_review_decisions.mjs)局部修改。不提供、也不接受直接覆盖完整编译结果的整段替换。 +**只写画得出来的东西(硬规则)**:身份头衔、人物关系、剧情来历、设计意图一律不写字面——"师尊""系统宿主""律宏之妹""仙根被夺后获救""视觉层级服从功能角色""承载反套路故事"这类表述全部禁止;它们只在决定可见外观时转译为视觉短语(金丹修士 → 服饰规制与气质,不写"金丹修士"四个字)。 + +**其他格式禁令**: + +- 构图只说一遍(第 4 步),禁止在各视图下重复服装结构;某视图确有独有信息才补一短语("背面呈现半束长发与后领结构")。 +- 画幅比例数字(16:9 等)可省略;写了就必须与本资产呈现配置的 ratio 完全一致,不一致会被校验拒绝。 +- 画面文字禁止全称句("所有信息性文字均使用……")——生图模型对范围限定不敏感;只逐个点名可读文字,其余默认落入"不带文字"。 + +### 工作顺序 + +1. 从故事资产圣经读取事实:人物身份、外观版本、场景用途、道具识别点。**体态事实(轮椅、义肢、非人形体等)优先级高于任何构图惯例**——需求规格里的"全身图"由你按事实措辞(坐姿、盘踞、悬浮均可)。**基础身份事实(性别、年龄段、族裔或地域面貌)必须写进正文**:圣经明确的照实写;圣经标"未明确"的,按创作方向"人物群体呈现"章节的决策写;两处都没有时**不得自行发明**,回报主代理补决策后再写。 +2. 从创作方向"分类型美术语言"读取该资产类型与所属阵营的设计语言(色彩体系、材质世界、轮廓语言)——它是设计词汇的来源,不是照抄的文本。多方势力的剧本中,按资产所属阵营取用对应色彩与材质,不要把所有阵营的色域混写进一个资产。 +3. **画面内文字**:需要画面出现可读文字(招牌、标签、铭文、文件、菜单等)时,按创作方向"画面内文字语言"的决策**写明语言并给出内容原文**(例:标签为英文 "Daffodil / Hawthorn");不需要可读性时明确写"纹样化处理、不构成可读文字"。违反会被校验拒绝。 +4. 按"写作格式"四步组装正文,自检(见下)后确认,正文经 patch 写入 `promptBody`。批准前的文本永远只是候选。 + +### 正文边界(校验会拒绝违规) + +- 必备呈现要素缺一不可(语义匹配,非逐字);姿态词服从角色事实。 +- 画面内可读文字必须指定语言或声明不可读。 +- 比例数字与呈现配置不一致即拒绝。 +- 后续外观版本的正文仍配合"默认外观参考图 + 差量"生成:`deltaPrompt` 作为你的中间材料记录差量,**只允许**服装、装备体系、永久身体变化与媒介形态变化——表情、姿势、受伤、血迹、环境属于镜头级提示词,不进资产。 +- 不使用负向提示词参数通道、排除词清单或"避免……"补丁;但"无毛孔""无油光""非真人感""不带文字"等内联否定描述词是合法且常常必要的风格钉子,不要自我审查删除。 + +### 物化与失效 + +你确认的正文落盘后,审查台展示的文本与建批提交的文本字节一致。呈现规格变化时,系统按来源指纹定位受影响资产、显式失效旧审查——不静默替换。 + +## 锚定式(promptProfile v2,存量项目兼容) + +最终提示词 = 公共风格锚点 ⊕ 你写的整段正文。锚点由系统逐字拼在开头,你不重复它;**不写锚点级风格词**(PBR、赛璐璐、实拍质感、渲染品质、电影感光影等媒介与画质词汇全部由锚点承载,正文出现即校验失败)。其余规则与 v3 相同(呈现需求规格、身份事实、差量白名单)。 + +## 分层式(promptProfile v1,存量项目兼容) + +旧项目继续按五段拼接编译:公共视觉语言 + 全局渲染语言 + 当前资产呈现 + `identityPrompt` + `deltaPrompt`。字段边界: + +- `promptProfile.commonVisualLanguage` / `globalRenderLanguage`:只读,所有资产共享。 +- `config.assetPresentation.`:四类各自提供 prompt 与 ratio,禁止跨域读取。 +- `identityPrompt`:角色跨外观版本不变的识别锚点(含体态事实);`deltaPrompt`:同差量白名单。 +- 局部修改通过审查 patch(review-decisions.json → apply_review_decisions.mjs),不接受整段覆盖编译结果。 + +## 通用 + +- `description`、`visualBrief`、`sourceRefs` 是解释与证据,不直接当作供应商 prompt。 +- 初始化 Dialog 中的示例图只供用户识别方向,绝不随 prompt、批次或 AWB 请求发送。 +- 向用户展示最终提示词时给完整文本;分段说明仅为展示,不写回状态。 ## 自检 -- 该设计是否服从事实和已确认方向,同时保留模型自主设计空间? -- 角色之间能否在整体轮廓、色彩或状态上区分? -- 是否把剧情解释、未经证实设定或多余道具塞进了 prompt? -- 默认资产是否稳定、清晰、适合成为后续状态的参考锚点? +- 该设计是否服从事实(尤其体态事实)和已确认方向,同时保留自主设计空间? +- 正文第一块是否原样使用了本维度、本资产类型的媒介与画质词块(含全部否定式钉子)?必备呈现要素是否全部覆盖?服装描述是否只写了所选呈现范围内可见的部分? +- 全文是否为短语铺陈流?有没有混进头衔、人物关系、剧情来历、设计意图、叙事概念这类画不出来的字面?构图是否只说了一遍? +- 基础身份事实是否已按圣经或"人物群体呈现"决策写入?画面内可读文字是否已逐个点名语言与原文,或默认"不带文字"? +- **辨识度**:把本角色和同阵营其他角色的正文并排读,能否按总纲"分类型美术语言·角色"**声明的辨识手段**认出主角?英雄型主角看独占轮廓/专属色/贯穿符号是否落进正文;普通人设主角看声明的贯穿特征(发型、常穿单品、体态)是否稳定复现——**不得给普通人设主角自行添加英雄式专属设计**。 +- 默认外观是否稳定、清晰、适合成为后续版本的参考锚点? diff --git a/references/provider-policy.md b/references/provider-policy.md index a871edc..0e7deff 100644 --- a/references/provider-policy.md +++ b/references/provider-policy.md @@ -2,7 +2,7 @@ ## 环境 -第一次在新电脑运行时执行 preflight.mjs --bootstrap。它优先使用 PATH 中的兼容 lj-awb;否则查找 lj-awb skill 自带 resolver。缺少 Node/npm、登录、项目组、网络或余额时,将 provider job 标记 blocked,保留已有项目与批次。 +第一次在新电脑运行时执行 preflight.mjs --bootstrap。它优先使用 PATH 中的兼容 lj-awb;否则查找 lj-awb skill 自带 resolver。若仍无法解析 CLI,引导用户参照 [lj-awb CLI 官方安装说明](https://animeworkbench.lingjingai.cn/lj-awb-cli) 完成安装后重试,不猜测包名或下载路径。缺少 Node/npm、登录、项目组、网络或余额时,将 provider job 标记 blocked,保留已有项目与批次。 ## 动态契约 diff --git a/references/subagent-contracts.md b/references/subagent-contracts.md index e50c494..75e91d9 100644 --- a/references/subagent-contracts.md +++ b/references/subagent-contracts.md @@ -1,33 +1,46 @@ # 子代理与 checkpoint 契约 -## 分工 +## 分工(四岗位) -主代理保留一个槽位并作为唯一 canonical 合并者。默认先使用一个能阅读全文的 Agent 产出 `story-asset-bible.md`,再由 AI 美术总监产出并由用户确认 `art-direction-brief.md`,最后分工: +主代理保留一个槽位并作为唯一 canonical 合并者。四个岗位的边界: -- Compiler:依据故事资产圣经和已锁定项目创作方向生成角色、场景、道具的 canonical sidecar patch,并为每个资产保留证据;按下方"资产化准入"规则取舍状态。 -- Prompt Agent:读取短创作宪法、项目创作方向、已选视觉范式和资产证据,自主用中文写分层提示词;不加载外部美术知识库原文作为逐条命令,也不把初始化 Dialog 的示例图当成模型参考图。 -- Worker A / B:只在需要时核验各自负责的剧本分片,报告遗漏、别名、身份变化和冲突。 -- Worker C:复核跨分片别名、重复项与低置信度实体。 +- **岗位一 · 剧本统筹与资产研究员**:完整提取剧情事实、角色身份、外观版本、场景、道具与证据,产出 `story-asset-bible.md`。每个具名角色必须记录基础身份事实(性别 / 年龄段 / 族裔或地域出身;只抄剧本明确写的,未写填"未明确",不得从名字或剧本语言推断)。角色只记录真正的外观版本,不记录情绪和临时剧情状态(定义见圣经模板)。剧本可以是任何语言与地域;专有名词保留原文。**隔离要求**:派发任务卡只包含原始剧本路径与事实提取规则,不携带画风选择、构图选择、模型参数或无关对话历史。 +- **岗位二 · AI 美术总监**:依据圣经与用户意图决定整个项目怎么设计、怎么渲染,产出 `art-direction-brief.md`,由用户确认。 +- **岗位三 · 名册编制(Compiler)**:依据圣经和资产准入规则整理正式资产名册,生成角色、场景、道具的 canonical sidecar patch,并为每个资产保留证据。**不写美术提示词、不做美术设计、不读取美术总纲**;输入只有:圣经(候选清单、别名、身份变化、证据位置)、用户要求的生成范围、资产准入规则。 +- **岗位四 · 提示词编写(Prompt Agent)**:读取短创作宪法、已锁定项目创作方向、正式名册条目和资产证据,自主用中文编写提示词;不加载外部美术知识库原文作为逐条命令,也不把初始化 Dialog 的示例图当成模型参考图。这是主要的高并发阶段,**派发必须以 `plan_prompt_shards.mjs` 生成的打包清单为准**:分片(每个角色一片含其全部外观版本、场景道具 ≤4 聚类)是审计与对账单位;包(bundle)才是派发单位——普通分片按资产数聚合(角色 ≤6、场景/道具 ≤10),多外观角色独占一包。每包派一个子代理,完成一包立即领取下一包,并发子代理数 = min(包数, 可用槽位);完成后按 shardId 对账,缺片补派。任务卡自包含(名册条目、圣经事实摘录、本类型媒介与画质词块、构图需求规格、文字策略),子代理不回读完整剧本/圣经/项目状态。**禁止按资产类型只开三个子代理,也禁止一片一派**。最后由统一一致性检查收尾。 +- Worker A / B / C:只在需要核验覆盖率时读取各自剧本分片,报告遗漏、别名、身份变化和冲突;复核跨分片重复项与低置信度实体。 -提示词阶段让同一角色的全部状态归同一 worker;场景/道具独立。生图并发由 lj-awb 批次控制,不要一张图一个子代理。 +生图并发由 lj-awb 批次控制,不要一张图一个子代理。 -## 资产化准入 +## 资产准入(两层闸门) -资产图片的价值在于复用;只有会被复用的画面才值得成为独立资产。Compiler 编译时按故事资产圣经中各状态列出的集-场编号取舍: +**闸门一 · 是否入册**(决定角色/场景/道具存在与否,与出场次数无关): -- 角色状态、场景形态或道具状态出现于 ≥2 个场次或跨集,或作为默认态被后续状态引用 → 建独立 state / canonical 条目。 -- 仅单场出现的瞬时状态(临时表情、瞬间损耗、一闪而过的过场物件)→ 不建资产;该差异留给镜头级提示词与视频生成模型处理。 -- 单场出现但剧情或用户明确要求高保真呈现的关键画面(如高潮造型)可例外资产化,须作为待确认项标注理由,由人工审查裁决。 -- 降级不是删除:Compiler 在 patch 顶层 `downgraded` 数组回报被降级候选(名称、所属资产、出现集-场、降级原因)。merge 不消费该字段;主代理在审查阶段呈现,人工可单条捞回。 -- **状态对账(硬要求)**:圣经"状态全量"中的每一条,要么成为独立 state,要么出现在 `downgraded` 里,二者必居其一;states 数 + downgraded 数应与圣经状态条目数对得上。只编译默认态、其余状态既不建也不降级,视为编译不完整,主代理必须退回重编。 -- 圣经全量记录、编译阶段取舍:过滤有误时重建 patch 即可,不得以准入为由改写已锁定的故事资产圣经。 +- 角色:有正式姓名或稳定称呼、有台词或连续对话、承担剧情功能、与主要角色存在固定关系——任一命中即入册,并原则上生成一个默认外观资产。名册宁多勿漏:多录一行的代价是审查多看一眼,漏一个的代价是视频制作中途返工。 +- 无名功能性群体抽象为类别代表角色(一个类别一个代表性单人资产);成员固定的组合必须拆成独立角色。区分标准:成员之间有互动、各自有台词或称呼 = 固定组合必拆;可互换的背景群体 = 类别代表。 +- 场景:多次使用或承担多个镜头的地点入册;一闪而过的背景地点可排除。 + +**闸门二 · 额外外观版本是否单独出图**(只管第二套及以后的造型,永远不决定角色存在性): + +- 出现于 ≥2 个场次或跨集,或作为默认外观被后续版本引用 → 建独立 state。 +- 单场出现但贯穿该场主要段落(以该角色场内对话轮次、动作段数为可数依据)→ 建独立 state。 +- 用户点名要求高保真呈现 → 例外资产化,标注理由。 +- 仅一闪而过的闪回、插入镜头中的完整造型 → 降级,留给镜头级提示词处理。 +- 媒介/画风形态变化(如 Q 版形象、异维度固定形态)按同一闸门计:贯穿复现(≥2 场或跨集)才建独立 state 并注明所属媒介维度;单场一闪而过的 Q 版演出降级为镜头级。 +- 降级不是删除:岗位三在 patch 顶层 `downgraded` 数组回报被降级候选(名称、所属资产、出现集-场、降级原因)。merge 不消费该字段;主代理在审查阶段呈现,人工可单条捞回。 + +**名册对账(硬要求)**:圣经角色档案中的每个外观版本,要么成为独立 state,要么出现在 `downgraded` 里,二者必居其一;states 数 + downgraded 数应与圣经外观版本条目数对得上。只编译默认外观、其余版本既不建也不降级,视为编译不完整,主代理必须退回重编。圣经带候选编号时,名册须对每个候选编号给出唯一处置(纳入 / 合并 / 排除 / 类别 / 拆分)并附一行理由。 + +准入取舍有误时重建 patch 即可,不得以准入为由改写已锁定的故事资产圣经。 ## 最小上下文 -全文理解 Agent 只输出故事资产圣经,不输出 JSON 或供应商 prompt。`record_story_bible.mjs` 确认后,任何 Agent 都不得改写该文件;发现全局理解错误时报告并要求新建或派生项目。AI 美术总监读取故事资产圣经、用户创作意图、`config.visualPreset` 和 `creative-charter.md`,先输出项目创作方向,由用户确认。Compiler 读取两个已锁定产物、当前项目状态和相关证据片段,输出 canonical 资产 patch。核验 worker 只读自己的 chunk、故事资产圣经、项目创作方向、当前 canonical 资产台账、styleProfile、promptProfile 和任务 scope;不读取完整剧本。 +岗位一只输出故事资产圣经,不输出 JSON 或供应商 prompt。`record_story_bible.mjs` 确认后,任何 Agent 都不得改写该文件;发现全局理解错误时报告并要求新建或派生项目。岗位二读取故事资产圣经、用户创作意图、`config.visualPreset` 和 `creative-charter.md`,先输出项目创作方向,由用户确认。岗位三读取已锁定圣经、当前项目状态和相关证据片段,输出 canonical 资产 patch;不读取美术总纲、画风描述、构图模板或生图参数。核验 worker 只读自己的 chunk、故事资产圣经、当前 canonical 资产台账和任务 scope;不读取完整剧本。 核验 worker 的结果写 sidecar JSON,只回报路径、数量、冲突、置信度和证据。它不得以局部称呼直接创建最终角色 ID;疑似新实体、别名或身份变化必须回报为冲突或待确认项。 +**产物交付方式(所有岗位通用)**:patch、名册、审查文件等结构化产物必须用文件写入工具整体写到指定路径,最终回复只报路径 + 条目数 + 关键摘要;禁止把大 JSON 经终端 stdout/heredoc 回传——终端摘要截断会产生半截文件。 + ## Patch ~~~json diff --git a/scripts/apply_review_decisions.mjs b/scripts/apply_review_decisions.mjs index 7bd4c29..f6de912 100644 --- a/scripts/apply_review_decisions.mjs +++ b/scripts/apply_review_decisions.mjs @@ -37,6 +37,11 @@ try { console.log("没有待应用的审查决定。"); process.exit(0); } + if (error instanceof SyntaxError) { + console.error("无法解析审查决定文件 " + decisionsFile + ":" + error.message); + console.error("常见原因:JSON 经终端 stdout/heredoc 传输被截断。请用文件写入工具把完整 JSON 整体重写到该路径后重试,不要在终端拼接大文件。"); + process.exit(1); + } throw error; } @@ -63,7 +68,7 @@ for (const [id, decision] of Object.entries(document.decisions ?? {})) { } if (decision.status != null && !allowedPromptStatuses.has(decision.status)) errors.push(id + " 的审查 status 无效:" + decision.status); if (decision.imageStatus != null && !allowedImageStatuses.has(decision.imageStatus)) errors.push(id + " 的 imageStatus 无效:" + decision.imageStatus); - for (const field of ["deltaPrompt", "prompt", "imageUrl", "note", "identityPrompt", "artifactId"]) { + for (const field of ["deltaPrompt", "prompt", "promptBody", "imageUrl", "note", "identityPrompt", "artifactId"]) { if (decision[field] != null && typeof decision[field] !== "string") errors.push(id + "." + field + " 必须为字符串"); } if (decision.lockedReference != null && typeof decision.lockedReference !== "boolean") errors.push(id + ".lockedReference 必须为布尔值"); @@ -90,6 +95,7 @@ for (const [id, decision] of Object.entries(document.decisions ?? {})) { const asset = record.asset; if (decision.identityPrompt != null && record.character && asset.sequence === 0) record.character.identityPrompt = decision.identityPrompt.trim(); if (decision.deltaPrompt != null) asset.deltaPrompt = decision.deltaPrompt.trim(); + if (decision.promptBody != null) asset.promptBody = decision.promptBody.trim(); if (decision.prompt != null) asset.prompt = decision.prompt.trim(); if (decision.imageUrl != null) asset.image = decision.imageUrl.trim() ? { ...(asset.image ?? {}), url: decision.imageUrl.trim() } : null; asset.review = { ...(asset.review ?? {}) }; diff --git a/scripts/apply_setup_decisions.mjs b/scripts/apply_setup_decisions.mjs index 571faec..b68a2f1 100644 --- a/scripts/apply_setup_decisions.mjs +++ b/scripts/apply_setup_decisions.mjs @@ -75,7 +75,7 @@ if (artDirectionPath && !lockedDirection) { if (!relativeBrief.startsWith("..") && !path.isAbsolute(relativeBrief)) { try { const brief = await readFile(absoluteBrief, "utf8"); - const selection = `## 已选视觉范式(锁定输入)\n\n- 名称:${preset.name}\n- 提示词风格种子:${preset.stylePromptSeed}\n- 说明:这是用户在初始化 Dialog 中的一次性选择。保留其视觉范式意图;不要把预览图、样例主体或外部知识库细节当作模型参考图。\n\n`; + const selection = `## 已选视觉范式(锁定输入)\n\n- 名称:${preset.name}${preset.positioning ? `\n- 定位:${preset.positioning}` : ""}\n- 提示词风格种子:${preset.stylePromptSeed}\n- 说明:这是用户在初始化 Dialog 中的一次性选择。保留其视觉范式意图与定位(游戏立绘向/影视写实向等不可混淆);不要把预览图、样例主体或外部知识库细节当作模型参考图。\n\n`; const updated = brief.match(/^##\s+已选视觉范式(锁定输入)\s*$/m) ? brief.replace(/^##\s+已选视觉范式(锁定输入)\s*$[\s\S]*?(?=^##\s+创作命题\s*$)/m, selection) : brief.replace(/^#\s+项目创作方向\s*\n+/m, (heading) => heading + "\n" + selection); @@ -99,10 +99,22 @@ state.config.visualPreset = { id: preset.id, name: preset.name, family: preset.family, + ...(preset.positioning ? { positioning: preset.positioning } : {}), stylePromptSeed: preset.stylePromptSeed, manifestVersion: manifest.version, selectedAt: new Date().toISOString() }; +// 角色外观版本跟随所选构图与画幅(资产库整齐、消除单/多视图混杂);仅全新 setup 生效,存量补齐/豁免路径保留原 characterState。 +const characterStateFromLayout = { + id: layout.id + "-state", + name: layout.name + "(外观版本)", + ratio: layout.ratio, + prompt: layout.prompt.replace(/。\s*$/u, "") + ",呈现该角色当前外观版本的完整造型。", + ...(Array.isArray(layout.requirements) + ? { requirements: [...layout.requirements, { label: "当前外观版本", match: "外观版本|当前造型|造型|服装|装束" }] } + : {}), + ...(Array.isArray(layout.exclusions) ? { exclusions: layout.exclusions } : {}) +}; state.config.assetPresentation = { version: assetPresentationVersion, characterState: { ...defaultScopedPresentations.characterState }, @@ -114,8 +126,11 @@ state.config.assetPresentation = { id: layout.id, name: layout.name, ratio: layout.ratio, - prompt: layout.prompt - } + prompt: layout.prompt, + ...(Array.isArray(layout.requirements) ? { requirements: layout.requirements } : {}), + ...(Array.isArray(layout.exclusions) ? { exclusions: layout.exclusions } : {}) + }, + ...(presentationOnlyUpgrade || grandfathered ? {} : { characterState: characterStateFromLayout }) }; if (presentationOnlyUpgrade) { console.log("存量项目:已补齐角色状态、场景和道具的独立呈现配置;创作方向与既有资产保持不变。"); @@ -127,3 +142,9 @@ if (grandfathered) { nextRevision(state); await writeJsonAtomic(statePath, state); console.log("项目初始化配置已应用:" + preset.name + " / " + layout.name + " revision=" + state.revision); +if (preset.stylePromptSeed) { + console.log("请向用户复述并核对所选视觉范式:" + preset.name + (preset.positioning ? "(" + preset.positioning + ")" : "")); + console.log("风格种子原文:" + preset.stylePromptSeed); +} else { + console.log("视觉范式:按剧本生成(无预设种子),媒介与画质由 AI 美术总监依据剧本提出,请用户在总纲确认时核对。"); +} diff --git a/scripts/approve_prompt_batch.mjs b/scripts/approve_prompt_batch.mjs new file mode 100644 index 0000000..8ce7256 --- /dev/null +++ b/scripts/approve_prompt_batch.mjs @@ -0,0 +1,114 @@ +#!/usr/bin/env node +// 提示词批量放行(官方通道,取代主代理即兴脚本): +// 需要用户先通过 record_standing_authorizations.mjs --batch-prompt-approval on 授权。 +// 质量自检通过的正文批量写入 review-decisions.json 置 approved;带可读文字的资产默认留人审; +// changes_requested(人工打回)永不覆盖。写入后仍走 apply_review_decisions.mjs 统一应用。 +import path from "node:path"; +import { + assetPresentation, + findAnchorStyleTerms, + findMismatchedRatioTerms, + findUnspecifiedImageTextTerms, + isPrimarilyChinese, + listAssets, + missingPresentationRequirements, + promptProfileAnchorVersion, + promptProfileBodyVersion, + readJson, + resolvedPromptProfile, + reviewStatus, + schemaVersion, + writeJsonAtomic +} from "./lib/project.mjs"; + +function option(name, fallback = null) { + const position = process.argv.indexOf(name); + return position >= 0 ? process.argv[position + 1] : fallback; +} + +const [stateFile] = process.argv.slice(2); +if (!stateFile || stateFile.startsWith("--")) { + console.error("用法: node approve_prompt_batch.mjs [--decisions review-decisions.json] [--include-readable-text]"); + process.exit(1); +} +const includeReadableText = process.argv.includes("--include-readable-text"); +const state = await readJson(stateFile); +if (state.schemaVersion !== schemaVersion) { + console.error("schemaVersion 不匹配:期望 " + schemaVersion + ",实际为 " + state.schemaVersion); + process.exit(1); +} +if (state.config?.authorizations?.batchPromptApproval !== true) { + console.error("未获得提示词批量放行授权。请在用户明确同意后运行:node record_standing_authorizations.mjs --batch-prompt-approval on"); + process.exit(1); +} +const profile = resolvedPromptProfile(state); +if (profile.version !== promptProfileAnchorVersion && profile.version !== promptProfileBodyVersion) { + console.error("批量放行仅支持 promptProfile v2/v3 项目(正文即最终提示词)。"); + process.exit(1); +} + +const decisionsFile = option("--decisions", path.join(path.dirname(path.resolve(stateFile)), "review-decisions.json")); +const document = await readJson(decisionsFile); +if (document.projectId !== state.project?.id) { + console.error("review-decisions.json projectId 不匹配。"); + process.exit(1); +} + +function promptQualityReasons(record) { + const reasons = []; + const body = String(record.asset.promptBody ?? "").trim(); + if (!body) { + reasons.push("缺少正文"); + return reasons; + } + const missing = missingPresentationRequirements(assetPresentation(state, record), body); + if (missing.length) reasons.push("缺呈现要素:" + missing.join("、")); + if (profile.version === promptProfileAnchorVersion) { + const drift = findAnchorStyleTerms(body); + if (drift.length) reasons.push("含锚点级风格词:" + drift.join("、")); + } else { + const unspecified = findUnspecifiedImageTextTerms(body); + if (unspecified.length) reasons.push("画面文字未指定语言:" + unspecified.join("、")); + const badRatios = findMismatchedRatioTerms(body, assetPresentation(state, record)?.ratio); + if (badRatios.length) reasons.push("比例数字与配置不一致:" + badRatios.join("、")); + } + if (state.config?.promptLanguage === "zh-CN" && !isPrimarilyChinese(body)) reasons.push("正文必须以中文为主"); + return reasons; +} + +// 画面上真的会渲染可读文字的资产(同时命中文字载体词与语言词)默认留人审——文字内容错一个字就报废。 +const inImageWords = /铭文|招牌|标签|字样|刻字|题字|信笺|菜单|字迹/; +const languageWords = /中文|汉字|英文|拉丁文|法文|德文|西班牙文|日文|韩文/; +const hasReadableText = (body) => inImageWords.test(String(body ?? "")) && languageWords.test(String(body ?? "")); + +const approved = []; +const heldReadableText = []; +const failed = []; +const skipped = []; +for (const record of listAssets(state)) { + const status = reviewStatus(record.asset); + if (status === "approved") { skipped.push([record.asset.id, "已通过"]); continue; } + if (status === "changes_requested") { skipped.push([record.asset.id, "人工打回,不覆盖"]); continue; } + const reasons = promptQualityReasons(record); + if (reasons.length) { failed.push([record.asset.id, reasons.join(";")]); continue; } + if (!includeReadableText && hasReadableText(record.asset.promptBody)) { + heldReadableText.push(record.asset.id); + continue; + } + approved.push(record.asset.id); +} + +document.decisions = { ...(document.decisions ?? {}) }; +for (const id of approved) { + document.decisions[id] = { ...(document.decisions[id] ?? {}), status: "approved" }; +} +await writeJsonAtomic(decisionsFile, document); + +console.log("批量放行:" + approved.length + " 个提示词写入 approved 决定;" + heldReadableText.length + " 个带可读文字资产留待人审;" + failed.length + " 个未达标;" + skipped.length + " 个跳过。"); +if (heldReadableText.length) console.log("留待人审(画面含可读文字,建议逐个核对语言与原文):" + heldReadableText.join("、")); +if (failed.length) { + console.log("未达标清单(回流岗位四重写):"); + for (const [id, reason] of failed.slice(0, 20)) console.log(" - " + id + ":" + reason); + if (failed.length > 20) console.log(" …其余 " + (failed.length - 20) + " 项见 review 流程"); +} +console.log("下一步:node apply_review_decisions.mjs && validate && plan_work_dag(建议用户抽查主角与带文字资产)。"); diff --git a/scripts/bootstrap_art_direction_brief.mjs b/scripts/bootstrap_art_direction_brief.mjs index e48bfe6..9481b08 100644 --- a/scripts/bootstrap_art_direction_brief.mjs +++ b/scripts/bootstrap_art_direction_brief.mjs @@ -41,9 +41,9 @@ try { } const selectedPreset = state.config?.visualPreset; const lockedSelection = selectedPreset - ? `## 已选视觉范式(锁定输入)\n\n- 名称:${selectedPreset.name}\n- 提示词风格种子:${selectedPreset.stylePromptSeed}\n- 说明:这是用户在初始化 Dialog 中的一次性选择。保留其视觉范式意图;不要把预览图、样例主体或外部知识库细节当作模型参考图。\n\n` + ? `## 已选视觉范式(锁定输入)\n\n- 名称:${selectedPreset.name}${selectedPreset.positioning ? `\n- 定位:${selectedPreset.positioning}` : ""}\n- 提示词风格种子:${selectedPreset.stylePromptSeed}\n- 说明:这是用户在初始化 Dialog 中的一次性选择。保留其视觉范式意图与定位(游戏立绘向/影视写实向等不可混淆);不要把预览图、样例主体或外部知识库细节当作模型参考图。\n\n` : "## 已选视觉范式(锁定输入)\n\n待完成初始化 Dialog 后自动写入。\n\n"; - await writeFile(outputPath, `# 项目创作方向\n\n> 由 AI 美术总监基于已锁定的故事资产圣经提出;用户确认后运行 record_art_direction_brief.mjs。\n> 不要复制外部美术知识库。请使用模型的自主设计能力,并遵守 references/creative-charter.md。\n> 最终 prompt 只使用正向描述;不要生成负向提示词、排除词清单或“避免……”补丁。\n> 本 skill 面向中文使用者:公共视觉语言、全局渲染语言和后续资产提示词均以中文为主;专有名词可保留必要原文。\n> 白底、胸像、三视图、正背面、排版和画幅比例属于具体资产呈现配置,禁止写入全局渲染语言。\n\n${lockedSelection}## 创作命题\n\n待补充:用一段话说明本项目希望观众首先感受到的世界、情绪和设计张力。\n\n## 用户确认的方向\n\n待补充:记录用户选择的方向、参考意图和创作自由度。\n\n## 公共视觉语言\n\n待补充:用中文将已确认的画风、视觉气质、色彩/材质倾向和统一设计语言写成可直接拼入 prompt 的短文本。本节会逐字拼进每一条生图提示词;只写描述画面本身的语言,不写流程说明、分配规则或系统行为。\n\n## 全局渲染语言\n\n待补充:用中文说明所有资产都适用的材质表现、光影质量、细节清晰度和一致性目标,写成可直接拼入 prompt 的短文本。不得写白底、胸像、半身像、三视图、多视图、正背面、左右排版、角色站姿或任何画幅比例;这些由角色默认态、角色状态、场景、道具各自的 assetPresentation 配置提供。\n\n## 正向设计重点\n\n待补充:列出本项目最应被看见和保持一致的设计重点,不写排除词或反向清单。\n\n## 角色、场景与道具设计原则\n\n待补充:说明它们如何共享世界感、如何形成差异,以及哪些设计应保持稳定。\n\n## 待确认项\n\n待补充:列出不能由模型自行决定的分歧;若没有,请明确写“无”。\n`, "utf8"); + await writeFile(outputPath, `# 项目创作方向\n\n> 由 AI 美术总监基于已锁定的故事资产圣经提出;用户确认后运行 record_art_direction_brief.mjs。\n> 不要复制外部美术知识库。请使用模型的自主设计能力,并遵守 references/creative-charter.md。\n> 最终 prompt 只使用正向描述;不要生成负向提示词、排除词清单或“避免……”补丁。\n> 本 skill 面向中文使用者:本总纲和后续资产提示词均以中文为主;专有名词可保留必要原文。\n> 本总纲全部内容是岗位四编写整段正文的**输入材料**,系统不做任何逐字拼接。\n> 白底、胸像、三视图、正背面、排版和画幅比例属于具体资产呈现配置,禁止写入本总纲各章节。\n\n${lockedSelection}## 创作命题\n\n待补充:用一段话说明本项目希望观众首先感受到的世界、情绪和设计张力。\n\n## 用户确认的方向\n\n待补充:记录用户选择的方向、参考意图和创作自由度。\n\n## 媒介与画质\n\n待补充:为项目的每个**媒介维度**分别给出"角色"与"场景与道具"两个风格词块(岗位四把本资产所属维度、所属类型的词块**原样**落进正文开头)。以已选视觉范式的风格种子为底稿,用短语铺陈流书写,格式如下:\n\n- **主维度·角色**:媒介 + 感性题材气质/调性(如"现代哥特""国风")+ 画质目标 + **人物防漂移钉子**——必须逐一钉死会漂移的自由度:皮肤细节度(低细节皮肤/超写实皮肤纹理)、毛孔与油光(无毛孔/无油光)、真人感(非真人感/超写实真人)、材质(哑光/实拍物性)、发丝(哑光分束发丝/发丝层次清晰)、五官夸张度(夸张五官比例/写实比例五官)、**头身比例**(写实七至八头身/头身比例略夸张——不声明会向 Q 版漂移)。\n- **主维度·场景与道具**:媒介 + 同一题材气质 + 材质光照与画质目标,**不含人物专属词**(肤质/毛孔/发丝/五官不出现在本块)。\n\n单一媒介剧本只写主维度两行;剧本存在贯穿复现的其他媒介形态(如反复使用的 Q 版形象、穿越进入的游戏/漫画世界)时,为该维度再加两行并注明适用范围。**禁止写进本节**:具体色名(绯红/灰绿/黑金等)、材质枚举清单(黑曜石、旧银、丝绒……)、确定性的细节纹样——这些属于"分类型美术语言",由岗位四按资产与阵营取用;**叙事概念与剧情结构词**("反套路""师徒故事""逆袭"等——生图模型无法执行剧情概念);版式与流程说明。\n\n## 人物群体呈现\n\n待补充:决定本项目人物的族裔与地域面貌呈现。剧本明确写出的族裔/地域出身照实沿用;圣经标注"未明确"的角色,默认按**剧本文化语境的主流面孔**统一呈现(西式命名与西式世界观 → 欧美面貌为主;东南亚背景 → 东南亚面貌为主),写一行理由,随本总纲整体确认。**除非剧本明确写了多元设定,不得以"国际化都市/开放港城"等语境推演为由做多族裔均衡分布**——本 skill 服务的短剧面向明确目标市场。不得从角色名字或剧本语言推断个别角色的族裔;不把族裔编码为善恶或权力等级;用户明确指定时以用户为准。\n\n## 画面内文字语言\n\n待补充:决定画面中出现的可读文字(招牌、标签、铭文、文件、菜单等)使用什么语言。剧本明确写了世界文字语言的照实沿用;未明确时按世界观决策(如西式世界观 → 画面文字为英文)并写一行理由,随本总纲整体确认。默认策略:证据类道具与场景标识(文件、标签、招牌、菜单、唱片等)写明语言并由岗位四给出内容原文;装饰类(徽章、戒指、纹章等)默认纹样化处理、不构成可读文字。\n\n## 分类型美术语言\n\n本节是岗位四编写提示词正文的输入材料,**不逐字拼接**;写设计语言与阵营/类型差异(色彩体系、材质世界、轮廓语言),不重复"媒介与画质"的句子。剧本存在多方势力时,在此为各阵营分别定义色彩与材质体系。\n\n### 角色\n\n待补充:角色造型语言、轮廓与比例倾向、服饰体系与识别锚点原则;人物面貌遵循"人物群体呈现"章节的决策。**辨识度层级**:辨识度是显式决策,手段服从剧本人设,目标是主角在成图资产库中一眼可定位——但可定位不等于华丽专属设计。英雄型/强人设主角用独占轮廓、专属色或贯穿符号;普通人代入型主角(废柴逆袭、日常职场等大众形象人设)保持平凡外形,用一两个克制的贯穿特征承载辨识(固定发型、一件常穿单品、稳定体态),并在本节写明"主角采用普通人设、辨识靠××"及理由,防止岗位四自行加英雄式设计。核心配角次之;功能角色与类别代表保持体系统一、明确让位,不塑造成抢注意力的独立造型。\n\n### 场景\n\n待补充:空间与建筑语言、材质层级、光线氛围原则。\n\n### 道具\n\n待补充:器物设计语言、材质与尺度原则。\n\n## 正向设计重点\n\n待补充:列出本项目最应被看见和保持一致的设计重点,不写排除词或反向清单。\n\n## 角色、场景与道具设计原则\n\n待补充:说明它们如何共享世界感、如何形成差异,以及哪些设计应保持稳定。\n\n## 待确认项\n\n待补充:列出不能由模型自行决定的分歧;若没有,请明确写“无”。\n`, "utf8"); } const next = { diff --git a/scripts/bootstrap_story_bible.mjs b/scripts/bootstrap_story_bible.mjs index 41af2e5..4d0fd9a 100644 --- a/scripts/bootstrap_story_bible.mjs +++ b/scripts/bootstrap_story_bible.mjs @@ -48,7 +48,7 @@ try { console.error("已锁定的故事资产圣经文件缺失;请从备份恢复,或新建派生项目。"); process.exit(1); } - await writeFile(outputPath, `# 故事资产圣经\n\n> 项目:${state.project.id}\n>\n> 用影视主美的视角阅读剧本并完成下列内容。先理解叙事,再编译资产。\n> 证据一律使用"集-场"编号指针(如 12-3),不引用台词全文。\n## 世界观与美术语境\n\n待补充:时代、地域、社会规则、技术/奇幻设定,以及影响资产设计的视觉语境。\n\n## 剧情梗概与时间线\n\n待补充:按章节或关键转折概括剧情,并标明时间、地点与身份变化。本章节供人工审查与全局理解,不进入下游编译输入。\n\n## 角色档案\n\n待补充:每个角色列出 canonical 名称、别名/称呼、稳定身份特征(跨全剧不变的外貌与识别锚点),以及状态列表(每条状态注明触发剧情、视觉变化点和全部出现集-场),再记录关系与不确定项。\n\n## 场景与地点\n\n待补充:每个地点列出 canonical 名称、别名、用途,以及各形态的时空变化(每种形态注明全部出现集-场)和不确定项。\n\n## 道具与状态\n\n待补充:只列出需要视觉资产化的道具;说明别名、持有者与状态变化,每个状态逐一列出全部出现集-场。单次出现的状态照记不删,是否资产化由下游编译按复用度决定。\n\n## 别名、重复项和待确认项\n\n待补充:集中记录同一实体的多种称呼、疑似重复项、指代不明项,以及不能臆造的内容。\n`, "utf8"); + await writeFile(outputPath, `# 故事资产圣经\n\n> 项目:${state.project.id}\n>\n> 用剧本统筹与资产研究员的视角阅读剧本。只整理剧情事实、角色身份、外观版本、场景、道具及其证据;不受画风、构图、模型和生成参数影响,不做美术设计。\n> 证据一律使用"集-场"编号指针(如 12-3),不引用台词全文。剧本使用 Scene/INT./EXT.、幕-场或其他非"集-场"结构时,先建立编号映射(如单部电影按 1-场号),并在"世界观与美术语境"章节记录换算规则。\n> 为每个角色、场景、道具条目分配稳定候选编号(角色 C-01、场景 L-01、道具 P-01 递增),供下游名册编制逐项对账。\n> 剧本可以是任何语言与地域(东南亚、欧美、拉美等均可);人名、地名等专有名词保留原文,其余描述以中文为主。\n## 世界观与美术语境\n\n待补充:时代、地域、社会规则、技术/奇幻设定,以及影响资产设计的视觉语境。若剧本明确写出世界内文字所用语言(招牌、文件、铭文等),记录一行"世界文字语言:……";剧本未写明时填"世界文字语言:未明确"(由创作方向层决策)。\n\n## 剧情梗概与时间线\n\n待补充:按章节或关键转折概括剧情,并标明时间、地点与身份变化。本章节供人工审查与全局理解,不进入下游编译输入。\n\n## 角色档案\n\n待补充。入册条件(任一命中即入册,与出场次数无关,宁多勿漏):有正式姓名或稳定称呼;有台词或连续对话;承担剧情功能;与主要角色存在固定关系。无名但需复用的功能性群体(普通弟子、士兵、宾客等)抽象为类别代表角色,不使用"甲乙丙丁"作为正式名称;剧情中成员固定的组合(三姐妹、四大护法等)必须拆成独立角色条目。\n\n每个角色记录:候选编号、canonical 名称、别名/称呼、**基础身份事实**(性别、年龄段、族裔或地域出身——只抄剧本明确写出的,剧本未写的一律填"未明确",不得从名字、语言或目标市场推断;未明确项由 AI 美术总监在创作方向中给出呈现决策)、稳定身份特征(跨全剧不变的外貌与识别锚点,含轮椅、义肢、非人形体等体态事实),以及外观版本列表。外观版本定义:需要一张独立角色参考图才能让后续多个镜头稳定复现的固定视觉形态——整套服装或装备体系变化、永久性重大身体变化(妖化、成年、进化后形态、固定新发型)、贯穿复现的媒介或画风形态变化(如反复使用的 Q 版形象、穿越异维度后的固定形态——记录时注明所属媒介维度)构成新版本;表情、姿势、动作、发怒哭泣、临时受伤、血迹污渍、战斗损耗、临时光效与一时性法术效果不属于外观版本,不记录。每个外观版本注明触发剧情、视觉变化点和全部出现集-场。\n\n## 场景与地点\n\n待补充:每个地点记录候选编号、canonical 名称、别名、用途与形态版本(固定空间结构变化或永久性改变构成新形态;光线、天气、临时破坏、人物活动不构成)。多次使用或承担多个镜头的次级地点必须入册;一闪而过的背景地点可不入册。每种形态注明全部出现集-场。\n\n## 道具与状态\n\n待补充:只列出需要视觉资产化的道具,记录候选编号、别名、持有者与状态变化,每个状态逐一列出全部出现集-场。角色拿起、放下或更换手持道具不构成角色外观版本。\n\n## 确定性处理结果\n\n待补充:集中处置同一实体的多种称呼、疑似重复项与指代不明项。每项必须落入以下之一并附一行理由:已归并为同一实体 / 已拆分为不同实体 / 按类别处理 / 不生成独立资产 / 剧本自身矛盾(保留告警)。不输出"等待人工确认"的悬置项。\n`, "utf8"); } const next = { diff --git a/scripts/lib/project.mjs b/scripts/lib/project.mjs index 10fa183..28e7c8d 100644 --- a/scripts/lib/project.mjs +++ b/scripts/lib/project.mjs @@ -4,26 +4,43 @@ import path from "node:path"; export const schemaVersion = 3; export const promptProfileVersion = 1; +// v2(锚定式,存量兼容):promptProfile 只承载公共风格锚点;最终提示词 = 锚点 ⊕ 岗位四整段正文(promptBody)。 +export const promptProfileAnchorVersion = 2; +// v3(整段正文,新项目默认):无任何逐字拼接段;最终提示词 = 岗位四整段正文。 +// 媒介/画质/维度差异全部由岗位四按总纲"媒介与画质"输入材料写入正文(多媒介剧本如 Q 版形象是常态,维度是资产事实而非系统机制轴)。 +export const promptProfileBodyVersion = 3; export const assetPresentationVersion = 1; +// 锚点长度约束(仅 v2):锚点是唯一逐字拼接段,必须短而干净。 +export const styleAnchorMaxLength = 240; export const defaultScopedPresentations = Object.freeze({ characterState: Object.freeze({ id: "single-character-state", name: "单一角色状态图", ratio: "16:9", - prompt: "单一角色状态资产图,完整呈现当前造型、服装、发型、妆容、姿态与随身物变化,主体清晰且便于复用" + prompt: "单一角色状态资产图,完整呈现当前造型、服装、发型、妆容、姿态与随身物变化,主体清晰且便于复用", + requirements: Object.freeze([ + Object.freeze({ label: "单一角色主体", match: "单一|单人|一名|一位|同一角色" }), + Object.freeze({ label: "当前造型呈现", match: "造型|服装|外观|装束" }) + ]) }), location: Object.freeze({ id: "environment-wide", name: "宽幅场景设定图", ratio: "16:9", - prompt: "单一场景环境设定图,宽幅构图,清晰呈现空间布局、建筑结构、材质、光线与可复用环境细节" + prompt: "单一场景环境设定图,宽幅构图,清晰呈现空间布局、建筑结构、材质、光线与可复用环境细节", + requirements: Object.freeze([ + Object.freeze({ label: "环境全景", match: "场景|环境|空间|全景" }) + ]) }), prop: Object.freeze({ id: "single-prop", name: "单一道具设定图", ratio: "4:3", - prompt: "单一道具设定图,完整清晰呈现主体轮廓、结构、材质、尺度与关键识别细节" + prompt: "单一道具设定图,完整清晰呈现主体轮廓、结构、材质、尺度与关键识别细节", + requirements: Object.freeze([ + Object.freeze({ label: "单一道具主体", match: "道具|器物|物件|主体" }) + ]) }) }); @@ -121,6 +138,29 @@ export function sanitizeGlobalRenderLanguage(value) { export function resolvedPromptProfile(state) { const profile = state.config?.promptProfile ?? {}; + if (profile.version === promptProfileBodyVersion) { + return { + version: promptProfileBodyVersion, + styleAnchor: "", + commonVisualLanguage: "", + globalRenderLanguage: "", + rawGlobalRenderLanguage: "", + removedDedicatedTerms: [], + legacy: false + }; + } + if (profile.version === promptProfileAnchorVersion) { + const styleAnchor = cleanSegment(profile.styleAnchor); + return { + version: promptProfileAnchorVersion, + styleAnchor, + commonVisualLanguage: "", + globalRenderLanguage: "", + rawGlobalRenderLanguage: "", + removedDedicatedTerms: findDedicatedDeliveryLayoutTerms(styleAnchor), + legacy: false + }; + } const usesScopedProfile = profile.version === promptProfileVersion || Object.hasOwn(profile, "commonVisualLanguage") || Object.hasOwn(profile, "globalRenderLanguage"); @@ -128,6 +168,7 @@ export function resolvedPromptProfile(state) { const rawGlobalRenderLanguage = cleanSegment(usesScopedProfile ? profile.globalRenderLanguage : profile.render); return { version: usesScopedProfile ? promptProfileVersion : null, + styleAnchor: "", commonVisualLanguage, globalRenderLanguage: sanitizeGlobalRenderLanguage(rawGlobalRenderLanguage), rawGlobalRenderLanguage, @@ -136,12 +177,77 @@ export function resolvedPromptProfile(state) { }; } +// 锚点级风格词(仅 v2 锚定式):只允许出现在公共风格锚点里;岗位四正文命中即校验失败,防止风格漂移。 +// v3(整段正文)下不适用——正文必须自带媒介与画质措辞,本词表不得用于 v3 校验。 +const anchorOnlyStylePatterns = [ + ["渲染媒介", /PBR|赛璐璐|三维动画渲染|二维动画|三渲二|实拍质感|摄影级/u], + ["画风流派", /漫画描边|半色调|网点|胶片颗粒|水墨|工笔|赛博朋克风|昭和/u], + ["画质承诺", /高精度|高清|渲染品质|动画品质|电影感光影|商业动画|成像质量/u] +]; + +export function findAnchorStyleTerms(value) { + const text = cleanSegment(value); + if (!text) return []; + return anchorOnlyStylePatterns.filter(([, pattern]) => pattern.test(text)).map(([label]) => label); +} + +// 画面内文字语言(v3):正文要求画面出现可读文字时,必须指定渲染语言或声明不可读。 +// 词表故意保守(宁漏勿误伤);漏网项由一致性检查岗与对话审查兜底。 +const inImageTextPattern = /铭文|招牌|标签|字样|刻字|题字|信笺|菜单|字迹/gu; +const inImageTextResolutionPattern = /英文|拉丁文|法文|德文|西班牙文|葡萄牙文|日文|韩文|泰文|越南文|阿拉伯文|西里尔|中文|汉字|无可读文字|不可读|纹样化/u; + +export function findUnspecifiedImageTextTerms(value) { + const text = cleanSegment(value); + if (!text) return []; + const hits = [...new Set(text.match(inImageTextPattern) ?? [])]; + if (!hits.length) return []; + return inImageTextResolutionPattern.test(text) ? [] : hits; +} + +// 画幅比例一致性(v3):比例由呈现配置承载,正文可以省略;写了就必须与配置一致(防止误导模型或抄错呈现规格)。 +export function findMismatchedRatioTerms(value, ratio) { + const text = cleanSegment(value); + if (!text) return []; + const matches = [...new Set((text.match(/\d+\s*[::]\s*\d+/g) ?? []).map((term) => term.replace(/\s+/g, "").replace(":", ":")))]; + if (!matches.length) return []; + const expected = cleanSegment(ratio); + return expected ? matches.filter((term) => term !== expected) : matches; +} + +export function missingPresentationRequirements(presentation, body) { + const text = cleanSegment(body); + const requirements = presentation?.requirements ?? []; + if (!text || !requirements.length) return []; + return requirements + .filter((requirement) => requirement?.match && !new RegExp(requirement.match, "u").test(text)) + .map((requirement) => requirement.label ?? requirement.match); +} + +// 呈现排除项:局部构图(上半身肖像等)的"不能有什么"清单——正文描述可见范围之外的内容 +// 会诱导生图模型拉远构图破坏版式。词表放在呈现配置里(exclusions),保守取词、宁漏勿误伤。 +export function foundPresentationExclusions(presentation, body) { + const text = cleanSegment(body); + const exclusions = presentation?.exclusions ?? []; + if (!text || !exclusions.length) return []; + return exclusions + .filter((exclusion) => exclusion?.match && new RegExp(exclusion.match, "u").test(text)) + .map((exclusion) => exclusion.label ?? exclusion.match); +} + export function isPrimarilyChinese(value) { const text = cleanSegment(value); if (!text) return false; const hanCount = (text.match(/[\u3400-\u9fff]/g) ?? []).length; - const latinCount = (text.match(/[A-Za-z]/g) ?? []).length; - return hanCount >= 2 && hanCount >= latinCount / 2; + // \u62c9\u4e01\u6587\u6309"\u8bcd"\u8ba1\u6570\u800c\u975e\u6309\u5b57\u6bcd\u8ba1\u6570\uff1b\u9996\u5b57\u6bcd\u5927\u5199\u7684\u8bcd\u6309\u4e13\u6709\u540d\u8bcd\u8c41\u514d\u2014\u2014 + // \u6d77\u5916\u5267\u672c\u7684\u4eba\u540d\u3001\u5730\u540d\uff08Mar\u00eda Fern\u00e1ndez\u3001Johnson\uff09\u53ef\u4fdd\u7559\u539f\u6587\u800c\u4e0d\u6324\u5360\u4e2d\u6587\u5360\u6bd4\u3002 + const latinWords = text.match(/[A-Za-z\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u00ff][A-Za-z\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u00ff'\u2019-]*/g) ?? []; + const commonLatinWords = latinWords.filter((word) => !/^[A-Z\u00c0-\u00d6\u00d8-\u00de]/.test(word)); + return hanCount >= 2 && hanCount >= commonLatinWords.length; +} + +export function joinPromptSegments(segments) { + const parts = segments.map((segment) => String(segment ?? "").replace(/[。\s]+$/u, "")).filter(Boolean); + return parts.length ? parts.join("。") + "。" : ""; } export function compilePrompt(state, record) { @@ -149,6 +255,38 @@ export function compilePrompt(state, record) { const asset = record.asset; const presentationScope = assetPresentationScope(record); const presentation = assetPresentation(state, record); + if (profile.version === promptProfileBodyVersion) { + // v3 整段正文:最终提示词 = 岗位四整段正文,无任何逐字拼接段或隐藏追加。 + // 媒介/画质/维度由正文自带;呈现配置只作为需求规格参与校验(见 readiness)。 + const text = joinPromptSegments(uniqueSegments([asset.promptBody])); + return { + text, + sourceHash: hashValue({ + promptProfile: { version: promptProfileBodyVersion }, + presentationScope, + presentation: presentation + ? { id: presentation.id ?? null, ratio: presentation.ratio ?? null, requirements: presentation.requirements ?? null } + : null, + promptBody: asset.promptBody ?? null + }) + }; + } + if (profile.version === promptProfileAnchorVersion) { + // v2 锚定式(存量兼容):最终提示词 = 锚点 ⊕ 岗位四整段正文,二者之外没有任何隐藏追加。 + // 呈现配置不再拼接,只作为正文的需求规格参与校验(见 readiness)。 + const text = joinPromptSegments(uniqueSegments([profile.styleAnchor, asset.promptBody])); + return { + text, + sourceHash: hashValue({ + promptProfile: { version: promptProfileAnchorVersion, styleAnchor: profile.styleAnchor || null }, + presentationScope, + presentation: presentation + ? { id: presentation.id ?? null, ratio: presentation.ratio ?? null, requirements: presentation.requirements ?? null } + : null, + promptBody: asset.promptBody ?? null + }) + }; + } const positive = uniqueSegments([ profile.commonVisualLanguage, profile.globalRenderLanguage, @@ -157,7 +295,7 @@ export function compilePrompt(state, record) { asset.deltaPrompt, asset.prompt ]); - const text = positive.join("。"); + const text = joinPromptSegments(positive); return { text, sourceHash: hashValue({ @@ -274,8 +412,38 @@ export function readiness(record, state) { if (!image.ratio) reasons.push("缺少图片比例"); if (!image.quality) reasons.push("缺少 config.image.quality"); if (!prompt.text) reasons.push("缺少可编译提示词"); - if (state.config?.promptLanguage === "zh-CN") { - const profile = resolvedPromptProfile(state); + const profile = resolvedPromptProfile(state); + if (profile.version === promptProfileAnchorVersion || profile.version === promptProfileBodyVersion) { + const body = cleanSegment(record.asset.promptBody); + if (profile.version === promptProfileAnchorVersion && !profile.styleAnchor) reasons.push("缺少公共风格锚点"); + if (!body) reasons.push("缺少岗位四提示词正文(promptBody)"); + if (body) { + const presentation = assetPresentation(state, record); + const missingRequirements = missingPresentationRequirements(presentation, body); + if (missingRequirements.length) reasons.push("正文缺少必备呈现要素:" + missingRequirements.join("、")); + const exclusionHits = foundPresentationExclusions(presentation, body); + if (exclusionHits.length) reasons.push("正文包含所选呈现范围之外的内容(" + exclusionHits.join("、") + ");局部构图只写可见部分"); + if (profile.version === promptProfileAnchorVersion) { + // 防漂移词表仅适用于 v2;v3 正文必须自带媒介与画质措辞。 + const driftTerms = findAnchorStyleTerms(body); + if (driftTerms.length) reasons.push("正文包含应由公共风格锚点承载的风格词(" + driftTerms.join("、") + "),请移除后重写"); + } else { + const unspecifiedText = findUnspecifiedImageTextTerms(body); + if (unspecifiedText.length) { + reasons.push("正文要求画面出现可读文字(" + unspecifiedText.join("、") + ")但未指定文字语言;请写明语言与内容原文(如:标签为英文 \"Hawthorn\"),或改为纹样化不可读"); + } + const mismatchedRatios = findMismatchedRatioTerms(body, assetPresentation(state, record)?.ratio); + if (mismatchedRatios.length) { + reasons.push("正文中的画幅比例(" + mismatchedRatios.join("、") + ")与本资产呈现配置不一致;比例由系统承载,正文可省略,写了必须一致"); + } + } + } + if (state.config?.promptLanguage === "zh-CN") { + for (const [label, value] of [["公共风格锚点", profile.styleAnchor], ["资产提示词正文", body]]) { + if (value?.trim() && !isPrimarilyChinese(value)) reasons.push(label + "必须以中文为主"); + } + } + } else if (state.config?.promptLanguage === "zh-CN") { const languageFields = [ ["项目公共视觉语言", profile.commonVisualLanguage], ["全局渲染语言", profile.globalRenderLanguage], diff --git a/scripts/merge_agent_patches.mjs b/scripts/merge_agent_patches.mjs index 58ffb73..1025b58 100644 --- a/scripts/merge_agent_patches.mjs +++ b/scripts/merge_agent_patches.mjs @@ -1,6 +1,28 @@ #!/usr/bin/env node +import { readFile } from "node:fs/promises"; +import path from "node:path"; import { listAssets, nextRevision, readJson, schemaVersion, writeJsonAtomic } from "./lib/project.mjs"; +const rosterDispositions = new Set(["included", "merged", "excluded", "category", "split"]); + +// 名册对账:圣经里出现的每个候选编号(C-01/L-02/P-03…)必须在 roster 中恰好处置一次。 +async function bibleCandidateIds(state, stateFile) { + const biblePath = state.workflow?.storyBible?.path; + if (!biblePath) return null; + const projectRoot = path.dirname(path.resolve(stateFile)); + const absolute = path.resolve(projectRoot, biblePath); + const relative = path.relative(projectRoot, absolute); + if (relative.startsWith("..") || path.isAbsolute(relative)) return null; + try { + const content = await readFile(absolute, "utf8"); + const matches = content.match(/\b[CLP]-\d{1,3}\b/g) ?? []; + return matches.length ? new Set(matches) : null; + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } +} + const [stateFile, ...patchFiles] = process.argv.slice(2).filter((value) => !value.startsWith("--")); if (!stateFile || !patchFiles.length) { console.error("用法: node merge_agent_patches.mjs [patch2.json ...]"); @@ -18,7 +40,14 @@ const conflicts = []; const patches = []; for (const file of patchFiles) { - const patch = await readJson(file); + let patch; + try { + patch = await readJson(file); + } catch (error) { + console.error("无法解析 patch 文件 " + file + ":" + error.message); + console.error("常见原因:JSON 经终端 stdout/heredoc 传输被截断。请让子代理用文件写入工具把完整 JSON 整体重写到该路径后重试,不要在终端拼接大文件。"); + process.exit(1); + } if (patch.schemaVersion !== schemaVersion) conflicts.push(file + " schemaVersion 不匹配"); if (patch.projectId !== state.project?.id) conflicts.push(file + " projectId 不匹配"); if (!patch.opId || typeof patch.opId !== "string") conflicts.push(file + " 缺少 opId"); @@ -58,8 +87,8 @@ for (const { file, patch } of patches) { continue; } const allowed = record.assetType === "character-state" - ? new Set(["name", "visualBrief", "deltaPrompt", "sourceRefs"]) - : new Set(["name", "description", "identityPrompt", "visualBrief", "deltaPrompt", "sourceRefs", "usages"]); + ? new Set(["name", "visualBrief", "deltaPrompt", "promptBody", "sourceRefs"]) + : new Set(["name", "description", "identityPrompt", "visualBrief", "deltaPrompt", "promptBody", "sourceRefs", "usages"]); for (const [field, value] of Object.entries(operation.changes)) { if (!allowed.has(field)) { conflicts.push(file + " 不允许更新 " + operation.id + "." + field); @@ -78,6 +107,59 @@ for (const { file, patch } of patches) { } } +// 名册处置:patch 顶层可选 roster 数组,每项 {candidateId, name?, disposition, reason, assetIds?}。 +const rosterEntries = new Map((state.workflow.roster?.entries ?? []).map((entry) => [entry.candidateId, entry])); +let rosterUpdated = false; +for (const { file, patch } of patches) { + if (patch.roster == null) continue; + if (!Array.isArray(patch.roster)) { + conflicts.push(file + " roster 必须为数组"); + continue; + } + const seenInPatch = new Set(); + for (const entry of patch.roster) { + if (!entry || typeof entry.candidateId !== "string" || !/^[CLP]-\d{1,3}$/.test(entry.candidateId)) { + conflicts.push(file + " roster 含无效 candidateId:" + (entry?.candidateId ?? "<缺失>")); + continue; + } + if (seenInPatch.has(entry.candidateId)) { + conflicts.push(file + " roster 中候选编号重复处置:" + entry.candidateId); + continue; + } + seenInPatch.add(entry.candidateId); + if (!rosterDispositions.has(entry.disposition)) { + conflicts.push(file + " roster " + entry.candidateId + " 的 disposition 无效:" + (entry.disposition ?? "<缺失>")); + continue; + } + if (typeof entry.reason !== "string" || !entry.reason.trim()) { + conflicts.push(file + " roster " + entry.candidateId + " 缺少处置理由(reason)"); + continue; + } + rosterEntries.set(entry.candidateId, { + candidateId: entry.candidateId, + name: entry.name ?? null, + disposition: entry.disposition, + reason: entry.reason.trim(), + assetIds: Array.isArray(entry.assetIds) ? entry.assetIds : [] + }); + rosterUpdated = true; + } +} + +if (rosterUpdated && !conflicts.length) { + const candidates = await bibleCandidateIds(state, stateFile); + if (candidates) { + const missing = [...candidates].filter((id) => !rosterEntries.has(id)); + const unknown = [...rosterEntries.keys()].filter((id) => !candidates.has(id)); + if (missing.length) conflicts.push("名册对账失败:圣经候选编号未处置:" + missing.join("、")); + if (unknown.length) conflicts.push("名册对账失败:处置了圣经中不存在的候选编号:" + unknown.join("、")); + } + if (!conflicts.length) { + state.workflow.roster = { entries: [...rosterEntries.values()], updatedAt: new Date().toISOString() }; + operationCount += 1; + } +} + if (conflicts.length) { for (const conflict of conflicts) console.error("合并冲突:" + conflict); process.exit(1); @@ -85,4 +167,9 @@ if (conflicts.length) { if (operationCount) nextRevision(state); await writeJsonAtomic(stateFile, state); +if (rosterUpdated) { + const counts = {}; + for (const entry of rosterEntries.values()) counts[entry.disposition] = (counts[entry.disposition] ?? 0) + 1; + console.log("名册对账通过:" + rosterEntries.size + " 个候选(" + Object.entries(counts).map(([key, value]) => key + "=" + value).join(",") + ")。"); +} console.log("已合并 " + operationCount + " 个操作,revision=" + state.revision + "。"); diff --git a/scripts/plan_prompt_shards.mjs b/scripts/plan_prompt_shards.mjs new file mode 100644 index 0000000..01c89df --- /dev/null +++ b/scripts/plan_prompt_shards.mjs @@ -0,0 +1,110 @@ +#!/usr/bin/env node +// 岗位四并发分片清单:确定性生成,主代理照单逐片派子代理,禁止按资产类型三分。 +// 规则:每个角色一个分片(含其全部外观版本);场景、道具按名册顺序聚类,每片不超过 maxPerShard。 +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { readJson, writeJsonAtomic } from "./lib/project.mjs"; + +export const defaultMaxPerShard = 4; + +function chunk(items, size) { + const output = []; + for (let index = 0; index < items.length; index += size) output.push(items.slice(index, index + size)); + return output; +} + +export function planPromptShards(state, options = {}) { + const maxPerShard = Number.isInteger(options.maxPerShard) && options.maxPerShard > 0 + ? options.maxPerShard + : defaultMaxPerShard; + const missingOnly = Boolean(options.missingOnly); + const hasBody = (asset) => Boolean(String(asset.promptBody ?? "").trim()); + const shards = []; + for (const character of state.assets?.characters ?? []) { + const states = (character.states ?? []).filter((asset) => !missingOnly || !hasBody(asset)); + if (!states.length) continue; + shards.push({ + shardId: "shard-character-" + character.id, + assetType: "character", + label: character.name ?? character.id, + characterId: character.id, + assetIds: states.map((asset) => asset.id) + }); + } + for (const [group, assetType] of [["locations", "location"], ["props", "prop"]]) { + const assets = (state.assets?.[group] ?? []).filter((asset) => !missingOnly || !hasBody(asset)); + chunk(assets, maxPerShard).forEach((members, index) => { + shards.push({ + shardId: `shard-${assetType}-${String(index + 1).padStart(2, "0")}`, + assetType, + label: members.map((asset) => asset.name ?? asset.id).join("、"), + characterId: null, + assetIds: members.map((asset) => asset.id) + }); + }); + } + const counts = { character: 0, location: 0, prop: 0 }; + for (const shard of shards) counts[shard.assetType] += 1; + // 打包:分片是审计/对账单位,包(bundle)才是派发单位——每包一个子代理,避免"一片一派"的调度浪费。 + // 多外观角色独占一包;其余分片按资产数聚合(角色 ≤6、场景/道具 ≤10),同类型相邻打包。 + const soloStatesThreshold = Number.isInteger(options.soloStatesThreshold) && options.soloStatesThreshold > 0 + ? options.soloStatesThreshold + : 3; + const bundleCaps = { character: 6, location: 10, prop: 10, ...(options.bundleCaps ?? {}) }; + const bundles = []; + let current = null; + for (const shard of shards) { + const solo = shard.assetType === "character" && shard.assetIds.length >= soloStatesThreshold; + const cap = bundleCaps[shard.assetType]; + if (solo || shard.assetIds.length >= cap) { + bundles.push({ assetType: shard.assetType, shardIds: [shard.shardId], assetCount: shard.assetIds.length, solo: true }); + current = null; + continue; + } + if (!current || current.assetType !== shard.assetType || current.assetCount + shard.assetIds.length > cap) { + current = { assetType: shard.assetType, shardIds: [], assetCount: 0, solo: false }; + bundles.push(current); + } + current.shardIds.push(shard.shardId); + current.assetCount += shard.assetIds.length; + } + bundles.forEach((bundle, index) => { bundle.bundleId = "bundle-" + String(index + 1).padStart(2, "0"); }); + return { + generatedAt: new Date().toISOString(), + projectId: state.project?.id ?? null, + sourceStateRevision: state.revision ?? 0, + maxPerShard, + missingOnly, + summary: { + ...counts, + total: shards.length, + assetCount: shards.reduce((sum, shard) => sum + shard.assetIds.length, 0), + bundles: bundles.length + }, + shards, + bundles + }; +} + +function option(name, fallback = null) { + const position = process.argv.indexOf(name); + return position >= 0 ? process.argv[position + 1] : fallback; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + const [stateFile] = process.argv.slice(2); + if (!stateFile || stateFile.startsWith("--")) { + console.error("用法: node plan_prompt_shards.mjs [--max-per-shard 4] [--missing-only] [--output workflow/prompt-shards.json]"); + process.exit(1); + } + const state = await readJson(stateFile); + const plan = planPromptShards(state, { + maxPerShard: Number(option("--max-per-shard", defaultMaxPerShard)), + missingOnly: process.argv.includes("--missing-only") + }); + const outputFile = path.resolve(option("--output", path.join(path.dirname(path.resolve(stateFile)), "workflow", "prompt-shards.json"))); + await writeJsonAtomic(outputFile, plan); + console.log("岗位四分片清单已生成:" + outputFile); + console.log(`分片 ${plan.summary.total} 个(角色 ${plan.summary.character} / 场景 ${plan.summary.location} / 道具 ${plan.summary.prop}),覆盖资产 ${plan.summary.assetCount} 个,打包为 ${plan.summary.bundles} 个派发包。`); + console.log("派发单位是包:每包一个子代理,完成一包立即领取下一包;分片仍按 shardId 对账。禁止按资产类型三分,也禁止一片一派。"); +} diff --git a/scripts/plan_work_dag.mjs b/scripts/plan_work_dag.mjs index 6eef524..6c29fb0 100644 --- a/scripts/plan_work_dag.mjs +++ b/scripts/plan_work_dag.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import path from "node:path"; import { randomUUID } from "node:crypto"; -import { executionStatus, hasCompletedArtDirection, hasCompletedSetup, hasCompletedStoryBible, hashValue, readJson, readiness, schemaVersion, setup, styleHash, writeJsonAtomic } from "./lib/project.mjs"; +import { executionStatus, hasCompletedArtDirection, hasCompletedSetup, hasCompletedStoryBible, hashValue, promptProfileAnchorVersion, readJson, readiness, resolvedPromptProfile, schemaVersion, setup, styleHash, writeJsonAtomic } from "./lib/project.mjs"; function option(name, fallback) { const position = process.argv.indexOf(name); @@ -115,7 +115,10 @@ for (const character of state.assets?.characters ?? []) { const anchor = (character.states ?? []).find((asset) => asset.sequence === 0); if (!anchor) continue; const promptJobId = "prompt-" + character.id; - const promptsComplete = Boolean(character.identityPrompt?.trim()) && (character.states ?? []).every((asset) => Boolean((asset.deltaPrompt ?? asset.prompt)?.trim())); + const anchoredProfile = resolvedPromptProfile(state).version >= promptProfileAnchorVersion; + const promptsComplete = anchoredProfile + ? (character.states ?? []).every((asset) => Boolean(asset.promptBody?.trim())) + : Boolean(character.identityPrompt?.trim()) && (character.states ?? []).every((asset) => Boolean((asset.deltaPrompt ?? asset.prompt)?.trim())); addJob({ id: promptJobId, type: "character-prompts", @@ -165,7 +168,9 @@ for (const [assetType, group] of [["location", "locations"], ["prop", "props"]]) const records = (state.assets?.[group] ?? []).map((asset) => ({ asset, character: null, assetType })); if (!records.length) continue; const promptJobId = "prompt-" + group; - const promptsComplete = records.every((record) => Boolean((record.asset.deltaPrompt ?? record.asset.prompt)?.trim())); + const promptsComplete = resolvedPromptProfile(state).version >= promptProfileAnchorVersion + ? records.every((record) => Boolean(record.asset.promptBody?.trim())) + : records.every((record) => Boolean((record.asset.deltaPrompt ?? record.asset.prompt)?.trim())); addJob({ id: promptJobId, type: "asset-prompts", diff --git a/scripts/record_art_direction_brief.mjs b/scripts/record_art_direction_brief.mjs index 042fbd1..5781169 100644 --- a/scripts/record_art_direction_brief.mjs +++ b/scripts/record_art_direction_brief.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { readFile } from "node:fs/promises"; import path from "node:path"; -import { findDedicatedDeliveryLayoutTerms, hasCompletedSetup, hasCompletedStoryBible, hashValue, isPrimarilyChinese, nextRevision, promptProfileVersion, readJson, schemaVersion, writeJsonAtomic } from "./lib/project.mjs"; +import { findDedicatedDeliveryLayoutTerms, hasCompletedSetup, hasCompletedStoryBible, hashValue, isPrimarilyChinese, nextRevision, promptProfileAnchorVersion, promptProfileBodyVersion, promptProfileVersion, readJson, schemaVersion, styleAnchorMaxLength, writeJsonAtomic } from "./lib/project.mjs"; function option(name, fallback) { const position = process.argv.indexOf(name); @@ -26,7 +26,12 @@ if (!stateFile || stateFile.startsWith("--")) { process.exit(1); } -const requiredHeadings = ["创作命题", "用户确认的方向", "公共视觉语言", "全局渲染语言", "正向设计重点", "角色、场景与道具设计原则", "待确认项"]; +// v3(整段正文)模板含"媒介与画质"章节(无逐字拼接段);v2(锚定式)模板含"公共风格锚点";旧模板(公共视觉语言/全局渲染语言)继续按 v1 处理。 +const bodyBased = (markdown) => /^##\s+媒介与画质\s*$/m.test(markdown); +const anchorBased = (markdown) => /^##\s+公共风格锚点\s*$/m.test(markdown); +const requiredHeadingsV3 = ["创作命题", "用户确认的方向", "媒介与画质", "人物群体呈现", "画面内文字语言", "分类型美术语言", "正向设计重点", "角色、场景与道具设计原则", "待确认项"]; +const requiredHeadingsV2 = ["创作命题", "用户确认的方向", "公共风格锚点", "人物群体呈现", "分类型美术语言", "正向设计重点", "角色、场景与道具设计原则", "待确认项"]; +const requiredHeadingsV1 = ["创作命题", "用户确认的方向", "公共视觉语言", "全局渲染语言", "正向设计重点", "角色、场景与道具设计原则", "待确认项"]; const statePath = path.resolve(stateFile); const projectRoot = path.dirname(statePath); const state = await readJson(statePath); @@ -51,25 +56,75 @@ if (briefRelativePath.startsWith("..") || path.isAbsolute(briefRelativePath)) { process.exit(1); } const brief = await readFile(briefPath, "utf8"); +const usesBody = bodyBased(brief); +const usesAnchor = !usesBody && anchorBased(brief); +const requiredHeadings = usesBody ? requiredHeadingsV3 : usesAnchor ? requiredHeadingsV2 : requiredHeadingsV1; const values = Object.fromEntries(requiredHeadings.map((heading) => [heading, clean(section(brief, heading))])); // 兼容已经生成但尚未锁定的旧模板;旧标题内容仍按新的全局边界校验。 -if (!values["全局渲染语言"]) values["全局渲染语言"] = clean(section(brief, "默认渲染规格")); +if (!usesBody && !usesAnchor && !values["全局渲染语言"]) values["全局渲染语言"] = clean(section(brief, "默认渲染规格")); const missing = requiredHeadings.filter((heading) => !values[heading] || /(^|\n)-?\s*待补充/.test(values[heading])); if (missing.length) { console.error("项目创作方向尚未完成以下章节:" + missing.join("、")); process.exit(1); } -if (state.config?.promptLanguage === "zh-CN") { - const nonChinese = ["公共视觉语言", "全局渲染语言"].filter((heading) => !isPrimarilyChinese(values[heading])); - if (nonChinese.length) { - console.error("以下章节必须以中文为主:" + nonChinese.join("、")); +if (usesBody) { + // v3:媒介与画质按 维度×资产类型 分块("××·角色"块可含人物防漂移钉子;其余块不得含人物专属词)。 + const mediaSection = values["媒介与画质"]; + if (state.config?.promptLanguage === "zh-CN" && !isPrimarilyChinese(mediaSection.replace(/\s+/g, " ").trim())) { + console.error("媒介与画质章节必须以中文为主。"); + process.exit(1); + } + for (const rawLine of mediaSection.split(/\n+/)) { + const line = rawLine.trim(); + if (!line) continue; + const labelMatch = line.match(/^[-*]?\s*\*\*([^*]+)\*\*\s*[::]/); + const label = labelMatch ? labelMatch[1] : ""; + const content = labelMatch ? line.slice(labelMatch[0].length) : line; + const characterBlock = /角色/.test(label); + // 版式词对所有块禁止;"角色局部细节"(面部/五官/发丝等)在角色块内是防漂移钉子,予以豁免。 + const dedicated = findDedicatedDeliveryLayoutTerms(content) + .filter((term) => !(characterBlock && term === "角色局部细节")); + if (dedicated.length) { + console.error("媒介与画质章节包含资产专属交付版式:" + dedicated.join("、") + "。版式要求属于 assetPresentation 需求规格,不进媒介块。"); + process.exit(1); + } + if (!characterBlock && /角色|人物|肤质|发丝|毛孔/.test(content)) { + console.error("媒介与画质的场景/道具适用块包含人物专属措辞(角色/人物/肤质/发丝/毛孔)。人物防漂移钉子只写在\"××·角色\"块中。"); + process.exit(1); + } + } +} else if (usesAnchor) { + const anchor = values["公共风格锚点"].replace(/\s+/g, " ").trim(); + if (state.config?.promptLanguage === "zh-CN" && !isPrimarilyChinese(anchor)) { + console.error("公共风格锚点必须以中文为主。"); + process.exit(1); + } + if (anchor.length > styleAnchorMaxLength) { + console.error("公共风格锚点超过 " + styleAnchorMaxLength + " 字(当前 " + anchor.length + " 字)。锚点是唯一逐字拼接段,请压缩为一段短而干净的文本。"); + process.exit(1); + } + const anchorDedicatedTerms = findDedicatedDeliveryLayoutTerms(anchor); + if (anchorDedicatedTerms.length) { + console.error("公共风格锚点包含资产专属交付版式:" + anchorDedicatedTerms.join("、") + "。版式要求属于 assetPresentation 需求规格,不进锚点。"); + process.exit(1); + } + if (/角色|人物|肤质|发丝/.test(anchor)) { + console.error("公共风格锚点包含角色专属措辞(角色/人物/肤质/发丝)。锚点被场景和道具共享,只写媒介、世界风格与渲染质量。"); + process.exit(1); + } +} else { + if (state.config?.promptLanguage === "zh-CN") { + const nonChinese = ["公共视觉语言", "全局渲染语言"].filter((heading) => !isPrimarilyChinese(values[heading])); + if (nonChinese.length) { + console.error("以下章节必须以中文为主:" + nonChinese.join("、")); + process.exit(1); + } + } + const dedicatedTerms = findDedicatedDeliveryLayoutTerms(values["全局渲染语言"]); + if (dedicatedTerms.length) { + console.error("全局渲染语言包含资产专属交付版式:" + dedicatedTerms.join("、") + "。请把白底、胸像、多视图、正背面、画幅比例等要求留在对应 assetPresentation 配置中。"); process.exit(1); } -} -const dedicatedTerms = findDedicatedDeliveryLayoutTerms(values["全局渲染语言"]); -if (dedicatedTerms.length) { - console.error("全局渲染语言包含资产专属交付版式:" + dedicatedTerms.join("、") + "。请把白底、胸像、多视图、正背面、画幅比例等要求留在对应 assetPresentation 配置中。"); - process.exit(1); } state.workflow = { appliedOps: [], ...(state.workflow ?? {}) }; @@ -106,11 +161,26 @@ state.config.styleProfile = { contentHash, approvedAt: new Date().toISOString() }; -state.config.promptProfile = { - version: promptProfileVersion, - commonVisualLanguage: [state.config?.visualPreset?.stylePromptSeed, values["公共视觉语言"]].filter(Boolean).join("。 ").replace(/\s+/g, " "), - globalRenderLanguage: values["全局渲染语言"].replace(/\s+/g, " ") -}; +state.config.promptProfile = usesBody + ? { + // v3 整段正文:无任何逐字拼接段;媒介与画质留在总纲文档作为岗位四输入材料。 + version: promptProfileBodyVersion + } + : usesAnchor + ? { + // v2 锚定式:风格种子已由美术总监消化进锚点,不再拼接原文。 + version: promptProfileAnchorVersion, + styleAnchor: values["公共风格锚点"].replace(/\s+/g, " ").trim() + } + : { + version: promptProfileVersion, + commonVisualLanguage: [state.config?.visualPreset?.stylePromptSeed, values["公共视觉语言"]] + .map((value) => String(value ?? "").trim().replace(/[。\s]+$/u, "")) + .filter(Boolean) + .join("。") + .replace(/\s+/g, " "), + globalRenderLanguage: values["全局渲染语言"].replace(/\s+/g, " ") + }; nextRevision(state); await writeJsonAtomic(statePath, state); console.log("项目创作方向已固化:" + briefPath + " revision=" + state.revision); diff --git a/scripts/record_execution_approval.mjs b/scripts/record_execution_approval.mjs index ebdb27d..63fef26 100644 --- a/scripts/record_execution_approval.mjs +++ b/scripts/record_execution_approval.mjs @@ -18,11 +18,32 @@ if (!confirmedHash || confirmedHash !== plan.executionPlanHash) { console.error("确认 hash 不匹配;模型、费用、范围或提示词可能已变化,请重新展示计划。"); process.exit(1); } +// --auto:消费用户的持久授权(config.authorizations.autoApproveBelow)。仅当预计消耗不超过阈值时可自动确认; +// 快照与哈希照常留档。授权由 record_standing_authorizations.mjs 在用户明确同意后写入。 +let approvedBy = "user"; +if (process.argv.includes("--auto")) { + const stateFile = option("--state"); + if (!stateFile) { + console.error("--auto 需要 --state 以读取用户授权。"); + process.exit(1); + } + const state = await readJson(stateFile); + const threshold = state.config?.authorizations?.autoApproveBelow; + if (!Number.isFinite(threshold)) { + console.error("项目未记录扣费免确认授权;请向用户展示计划并等待确认,或在用户授权后运行 record_standing_authorizations.mjs。"); + process.exit(1); + } + if (!(Number.isFinite(plan.estimatedCost) && plan.estimatedCost <= threshold)) { + console.error("预计消耗 " + plan.estimatedCost + " 积分超过免确认阈值 " + threshold + ";必须向用户展示计划并等待明确确认。"); + process.exit(1); + } + approvedBy = "user-standing-authorization(<=" + threshold + ")"; +} plan.approval = { executionPlanHash: plan.executionPlanHash, batchHash: plan.batchHash, approvedAt: new Date().toISOString(), - approvedBy: "user", + approvedBy, retryPolicy: option("--retry-policy", "failed-only"), approvalHash: hashValue({ executionPlanHash: plan.executionPlanHash, diff --git a/scripts/record_standing_authorizations.mjs b/scripts/record_standing_authorizations.mjs new file mode 100644 index 0000000..42629fc --- /dev/null +++ b/scripts/record_standing_authorizations.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node +// 项目级持久授权:只在用户明确说出授权后运行(如"100 积分以下不用问我""提示词批量放行")。 +// 授权写入 config.authorizations,默认全部关闭;撤销用 --auto-approve-below off / --batch-prompt-approval off。 +import { nextRevision, readJson, schemaVersion, writeJsonAtomic } from "./lib/project.mjs"; + +function option(name, fallback = null) { + const position = process.argv.indexOf(name); + return position >= 0 ? process.argv[position + 1] : fallback; +} + +const [stateFile] = process.argv.slice(2); +if (!stateFile || stateFile.startsWith("--")) { + console.error("用法: node record_standing_authorizations.mjs [--auto-approve-below <积分>|off] [--batch-prompt-approval on|off]"); + process.exit(1); +} + +const autoApproveRaw = option("--auto-approve-below"); +const batchApprovalRaw = option("--batch-prompt-approval"); +if (autoApproveRaw == null && batchApprovalRaw == null) { + console.error("至少提供一个授权项:--auto-approve-below <积分>|off 或 --batch-prompt-approval on|off"); + process.exit(1); +} + +const state = await readJson(stateFile); +if (state.schemaVersion !== schemaVersion) { + console.error("schemaVersion 不匹配:期望 " + schemaVersion + ",实际为 " + state.schemaVersion); + process.exit(1); +} +state.config = state.config ?? {}; +const authorizations = { ...(state.config.authorizations ?? {}) }; + +if (autoApproveRaw != null) { + if (autoApproveRaw === "off") { + delete authorizations.autoApproveBelow; + console.log("已撤销扣费免确认授权。"); + } else { + const threshold = Number(autoApproveRaw); + if (!Number.isFinite(threshold) || threshold <= 0) { + console.error("--auto-approve-below 必须为正数积分或 off"); + process.exit(1); + } + authorizations.autoApproveBelow = threshold; + console.log("已记录扣费免确认授权:预计消耗 ≤ " + threshold + " 积分的批次可自动确认(快照与哈希仍全量播报留档)。"); + } +} +if (batchApprovalRaw != null) { + if (batchApprovalRaw === "on") { + authorizations.batchPromptApproval = true; + console.log("已记录提示词批量放行授权:自检通过的提示词可由 approve_prompt_batch.mjs 批量置为 approved(带可读文字资产默认仍留人审)。"); + } else if (batchApprovalRaw === "off") { + delete authorizations.batchPromptApproval; + console.log("已撤销提示词批量放行授权。"); + } else { + console.error("--batch-prompt-approval 只接受 on 或 off"); + process.exit(1); + } +} +authorizations.updatedAt = new Date().toISOString(); +state.config.authorizations = authorizations; +nextRevision(state); +await writeJsonAtomic(stateFile, state); +console.log("授权状态:" + JSON.stringify({ autoApproveBelow: authorizations.autoApproveBelow ?? null, batchPromptApproval: authorizations.batchPromptApproval ?? false })); diff --git a/scripts/record_story_bible.mjs b/scripts/record_story_bible.mjs index 70776e0..0981e15 100644 --- a/scripts/record_story_bible.mjs +++ b/scripts/record_story_bible.mjs @@ -14,14 +14,16 @@ if (!stateFile || stateFile.startsWith("--")) { process.exit(1); } +// "确定性处理结果" 自 2026-07-18 替代旧标题 "别名、重复项和待确认项";旧标题仍兼容(headingAliases)。 const requiredHeadings = [ "世界观与美术语境", "剧情梗概与时间线", "角色档案", "场景与地点", "道具与状态", - "别名、重复项和待确认项" + "确定性处理结果" ]; +const headingAliases = { "确定性处理结果": ["别名、重复项和待确认项"] }; const statePath = path.resolve(stateFile); const projectRoot = path.dirname(statePath); const state = await readJson(statePath); @@ -47,16 +49,26 @@ if (sourceHash !== state.project?.sourceHash) { process.exit(1); } -const missing = []; -for (const heading of requiredHeadings) { +function sectionContent(heading) { const headingMatch = bible.match(new RegExp(`^##\\s+${heading}\\s*$`, "m")); - const sectionStart = headingMatch ? headingMatch.index + headingMatch[0].length : -1; - const remaining = sectionStart >= 0 ? bible.slice(sectionStart) : ""; + if (!headingMatch) return null; + const remaining = bible.slice(headingMatch.index + headingMatch[0].length); const nextHeading = remaining.search(/^##\s+/m); - const content = (nextHeading >= 0 ? remaining.slice(0, nextHeading) : remaining) - ?.replace(//g, "") + return (nextHeading >= 0 ? remaining.slice(0, nextHeading) : remaining) + .replace(//g, "") .replace(/^>.*$/gm, "") .trim(); +} + +const missing = []; +for (const heading of requiredHeadings) { + let content = sectionContent(heading); + if (content == null) { + for (const alias of headingAliases[heading] ?? []) { + content = sectionContent(alias); + if (content != null) break; + } + } if (!content || /^待补充/.test(content)) missing.push(heading); } if (missing.length) { @@ -64,25 +76,31 @@ if (missing.length) { process.exit(1); } -// 软校验(只警告不阻塞):具名角色档案应有带集-场证据的状态列表。 -// 圣经锁定后不可改,状态在这里漏了,下游编译只会产出单一默认态。 +// 软校验(只警告不阻塞):具名角色档案应有带集-场证据的外观版本列表。 +// 圣经锁定后不可改,外观版本在这里漏了,下游名册只会产出单一默认外观。 const characterHeading = bible.match(/^##\s+角色档案\s*$/m); if (characterHeading) { const rest = bible.slice(characterHeading.index + characterHeading[0].length); const end = rest.search(/^##\s+/m); const body = end >= 0 ? rest.slice(0, end) : rest; const thin = []; + const missingIdentityFacts = []; for (const subsection of body.split(/^###\s+/m).slice(1)) { const name = subsection.split("\n")[0].trim(); - if (/群演|群众|功能人物|一次性|无名/.test(name)) continue; + if (/群演|群众|功能人物|类别代表|一次性|无名/.test(name)) continue; const pointers = (subsection.match(/\b\d{1,3}-\d{1,3}\b/g) ?? []).length + (subsection.match(/\bE\d{1,3}\b/g) ?? []).length; - if (!/状态/.test(subsection) || pointers < 2) thin.push(name + "(集-场证据 " + pointers + " 处)"); + if (!/外观版本|造型版本|状态/.test(subsection) || pointers < 2) thin.push(name + "(集-场证据 " + pointers + " 处)"); + if (!/性别/.test(subsection)) missingIdentityFacts.push(name); } if (thin.length) { - console.warn("警告:以下角色档案缺少带集-场证据的状态列表;长剧本主要角色通常有多套造型,请与用户确认剧本确实无造型变化后再锁定:"); + console.warn("警告:以下角色档案缺少带集-场证据的外观版本列表;长剧本主要角色通常有多套造型,请与用户确认剧本确实无造型变化后再锁定:"); for (const item of thin) console.warn(" - " + item); } + if (missingIdentityFacts.length) { + console.warn("警告:以下角色档案缺少基础身份事实(性别 / 年龄段 / 族裔或地域出身;剧本未写的应显式填\"未明确\");缺失会导致生图阶段被模型默认值替代:"); + for (const item of missingIdentityFacts) console.warn(" - " + item); + } } state.workflow = { appliedOps: [], ...(state.workflow ?? {}) }; diff --git a/scripts/validate_project_state.mjs b/scripts/validate_project_state.mjs index 2d15ac0..52b719d 100644 --- a/scripts/validate_project_state.mjs +++ b/scripts/validate_project_state.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { artDirection, assetPresentation, assetPresentationScope, assetPresentationVersion, defaultState, executionStatus, hasCompletedArtDirection, hasCompletedSetup, hasCompletedStoryBible, hashValue, imageLocator, imageSettingsForRecord, isPrimarilyChinese, listAssets, promptProfileVersion, readJson, readiness, resolvedPromptProfile, schemaVersion, setup, storyBible } from "./lib/project.mjs"; +import { artDirection, assetPresentation, assetPresentationScope, assetPresentationVersion, defaultState, executionStatus, findAnchorStyleTerms, findMismatchedRatioTerms, findUnspecifiedImageTextTerms, hasCompletedArtDirection, hasCompletedSetup, hasCompletedStoryBible, hashValue, imageLocator, imageSettingsForRecord, isPrimarilyChinese, listAssets, promptProfileAnchorVersion, promptProfileBodyVersion, promptProfileVersion, readJson, readiness, resolvedPromptProfile, schemaVersion, setup, storyBible, styleAnchorMaxLength } from "./lib/project.mjs"; import { access, readFile } from "node:fs/promises"; import path from "node:path"; @@ -125,7 +125,18 @@ if (!state.config?.styleProfile) warnings.push("未设置 styleProfile:资产 if (!state.config?.promptProfile) warnings.push("未设置 promptProfile:将使用兼容字段 prompt"); if (state.config?.promptLanguage != null) assert(state.config.promptLanguage === "zh-CN", "config.promptLanguage 目前仅支持 zh-CN"); const promptProfile = resolvedPromptProfile(state); -if (state.config?.promptProfile?.version === promptProfileVersion) { +if (state.config?.promptProfile?.version === promptProfileBodyVersion) { + // v3 整段正文:promptProfile 只承载版本标记,不得残留拼接字段。 + assert(state.config.promptProfile.styleAnchor == null, "promptProfile v3 不承载 styleAnchor(无逐字拼接段)"); +} else if (state.config?.promptProfile?.version === promptProfileAnchorVersion) { + assert(typeof state.config.promptProfile.styleAnchor === "string" && state.config.promptProfile.styleAnchor.trim(), "promptProfile.styleAnchor 必须为非空字符串"); + assert(promptProfile.styleAnchor.length <= styleAnchorMaxLength, "公共风格锚点超过 " + styleAnchorMaxLength + " 字上限"); + assert(promptProfile.removedDedicatedTerms.length === 0, "公共风格锚点不得包含资产专属交付版式:" + promptProfile.removedDedicatedTerms.join("、")); + assert(!/角色|人物|肤质|发丝/.test(promptProfile.styleAnchor), "公共风格锚点不得包含角色专属措辞(被场景和道具共享)"); + if (state.config?.promptLanguage === "zh-CN" && promptProfile.styleAnchor) { + assert(isPrimarilyChinese(promptProfile.styleAnchor), "公共风格锚点必须以中文为主"); + } +} else if (state.config?.promptProfile?.version === promptProfileVersion) { assert(typeof state.config.promptProfile.commonVisualLanguage === "string", "promptProfile.commonVisualLanguage 必须为字符串"); assert(typeof state.config.promptProfile.globalRenderLanguage === "string", "promptProfile.globalRenderLanguage 必须为字符串"); assert(promptProfile.removedDedicatedTerms.length === 0, "promptProfile.globalRenderLanguage 不得包含资产专属交付版式:" + promptProfile.removedDedicatedTerms.join("、")); @@ -136,12 +147,22 @@ if (state.config?.promptLanguage === "zh-CN") { if (promptProfile.commonVisualLanguage) assert(isPrimarilyChinese(promptProfile.commonVisualLanguage), "项目公共视觉语言必须以中文为主"); if (promptProfile.globalRenderLanguage) assert(isPrimarilyChinese(promptProfile.globalRenderLanguage), "全局渲染语言必须以中文为主"); } +// v2 与 v3 都以 promptBody 为正文;仅 v2 有锚点与防漂移词表。 +const anchoredProfile = promptProfile.version === promptProfileAnchorVersion; +const bodyProfile = anchoredProfile || promptProfile.version === promptProfileBodyVersion; for (const character of state.assets?.characters ?? []) { registerId(character.id, "角色"); assert(typeof character.name === "string" && character.name.trim(), "角色 " + character.id + " 缺少 name"); - assert(typeof character.identityPrompt === "string" && character.identityPrompt.trim(), "角色 " + character.id + " 缺少 identityPrompt"); - if (state.config?.promptLanguage === "zh-CN") assert(isPrimarilyChinese(character.identityPrompt), "角色 " + character.id + " 的 identityPrompt 必须以中文为主"); + if (bodyProfile) { + // v2/v3:identityPrompt 是岗位四的输入材料与审计字段,允许为空;有值时仍须中文。 + if (state.config?.promptLanguage === "zh-CN" && character.identityPrompt?.trim()) { + assert(isPrimarilyChinese(character.identityPrompt), "角色 " + character.id + " 的 identityPrompt 必须以中文为主"); + } + } else { + assert(typeof character.identityPrompt === "string" && character.identityPrompt.trim(), "角色 " + character.id + " 缺少 identityPrompt"); + if (state.config?.promptLanguage === "zh-CN") assert(isPrimarilyChinese(character.identityPrompt), "角色 " + character.id + " 的 identityPrompt 必须以中文为主"); + } const anchor = defaultState(character); assert((character.states ?? []).filter((item) => item.sequence === 0).length === 1, "角色 " + character.id + " 必须恰好有一个默认状态"); for (const asset of character.states ?? []) { @@ -161,7 +182,23 @@ for (const record of listAssets(state)) { if (!ids.has(asset.id)) registerId(asset.id, "资产"); assert(typeof asset.name === "string" && asset.name.trim(), asset.id + " 缺少 name"); assert(allowedStatuses.has(asset.status), asset.id + ".status 无效"); - if (state.config?.promptLanguage === "zh-CN") assert(isPrimarilyChinese(asset.deltaPrompt ?? asset.prompt), asset.id + " 的 deltaPrompt 必须以中文为主"); + if (bodyProfile) { + // v2/v3:提示词正文是发给模型的正文;deltaPrompt/identityPrompt 退为岗位四的输入材料,不再强制。 + if (asset.promptBody?.trim()) { + if (state.config?.promptLanguage === "zh-CN") assert(isPrimarilyChinese(asset.promptBody), asset.id + " 的 promptBody 必须以中文为主"); + if (anchoredProfile) { + const drift = findAnchorStyleTerms(asset.promptBody); + assert(drift.length === 0, asset.id + " 的 promptBody 包含应由公共风格锚点承载的风格词:" + drift.join("、")); + } else { + const unspecifiedText = findUnspecifiedImageTextTerms(asset.promptBody); + assert(unspecifiedText.length === 0, asset.id + " 的 promptBody 要求画面出现可读文字(" + unspecifiedText.join("、") + ")但未指定文字语言或声明不可读"); + const mismatchedRatios = findMismatchedRatioTerms(asset.promptBody, assetPresentation(state, record)?.ratio); + assert(mismatchedRatios.length === 0, asset.id + " 的 promptBody 中的画幅比例(" + mismatchedRatios.join("、") + ")与呈现配置不一致"); + } + } + } else if (state.config?.promptLanguage === "zh-CN") { + assert(isPrimarilyChinese(asset.deltaPrompt ?? asset.prompt), asset.id + " 的 deltaPrompt 必须以中文为主"); + } if (asset.execution?.status === "succeeded") assert(imageLocator(asset.image), asset.id + " 执行成功但缺少有效图片定位"); if (state.config?.assetPresentation?.version === assetPresentationVersion) { const scope = assetPresentationScope(record); diff --git a/tests/anchored-prompt-v2.test.mjs b/tests/anchored-prompt-v2.test.mjs new file mode 100644 index 0000000..f67aaf7 --- /dev/null +++ b/tests/anchored-prompt-v2.test.mjs @@ -0,0 +1,352 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +import { + compilePrompt, + hashValue, + isPrimarilyChinese, + joinPromptSegments, + listAssets, + readiness +} from "../scripts/lib/project.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +const layoutRequirements = [ + { label: "白色无缝背景", match: "白色.{0,8}背景|白底" }, + { label: "正面胸像特写", match: "胸像" }, + { label: "正面全身图", match: "正面全身" }, + { label: "背面全身图", match: "背面全身|回首背面" } +]; + +function anchoredState() { + return { + schemaVersion: 3, + revision: 5, + project: { id: "project-v2-test", title: "锚定式测试", sourceScript: "剧本.md", sourceHash: "hash-script" }, + config: { + promptLanguage: "zh-CN", + styleProfile: { id: "art-direction-test", name: "测试创作方向", contentHash: "hash-style" }, + promptProfile: { version: 2, styleAnchor: "高精度三维漫剧 CG,三维动画渲染与 PBR 材质,明净东方幻想色域,统一世界质感与干净成像。" }, + image: { quality: "high" }, + assetPresentation: { + version: 1, + characterDefault: { id: "closeup-front-back", name: "特写 + 正背面设定", ratio: "4:3", prompt: "白色无缝背景的角色资产设定板", requirements: layoutRequirements }, + characterState: { id: "single-character-state", name: "单一角色状态图", ratio: "16:9", prompt: "单一角色状态资产图", requirements: [{ label: "单一角色主体", match: "单一|单人|一名|一位|同一角色" }] }, + location: { id: "environment-wide", name: "宽幅场景设定图", ratio: "16:9", prompt: "单一场景环境设定图", requirements: [{ label: "环境全景", match: "场景|环境|空间|全景" }] }, + prop: { id: "single-prop", name: "单一道具设定图", ratio: "4:3", prompt: "单一道具设定图", requirements: [{ label: "单一道具主体", match: "道具|器物|物件|主体" }] } + } + }, + workflow: { + storyBible: { path: "story-asset-bible.md", sourceHash: "hash-script", contentHash: "hash-bible", status: "complete", updatedAt: "2026-07-18T00:00:00.000Z", lockedAt: "2026-07-18T00:00:00.000Z" }, + artDirection: { path: "art-direction-brief.md", sourceHash: "hash-script", storyBibleHash: "hash-bible", setupHash: null, contentHash: "hash-direction", status: "complete", updatedAt: "2026-07-18T00:00:00.000Z", lockedAt: "2026-07-18T00:00:00.000Z" }, + appliedOps: [] + }, + assets: { + characters: [{ + id: "character-lun-yi", + name: "轮椅青年", + identityPrompt: "不该出现在最终提示词里的身份材料", + states: [{ + id: "state-lun-yi-default", + name: "默认外观", + sequence: 0, + status: "approved", + review: { promptStatus: "approved" }, + deltaPrompt: "不该出现在最终提示词里的差量材料", + promptBody: "白色无缝背景的角色资产设定板,画面左侧为正面胸像特写,神情平静;画面右侧为同一角色的正面全身轮椅坐姿图与回首背面全身图,排版紧密,比例与服装细节一致。轮椅上的清瘦青年,暗青长袍,束发,膝上覆一条素色毛毯。" + }] + }], + locations: [], + props: [] + } + }; +} + +function defaultRecord(state) { + return listAssets(state)[0]; +} + +test("v2 最终提示词 = 锚点 ⊕ 正文,无隐藏追加、无双句号", () => { + const state = anchoredState(); + const record = defaultRecord(state); + const compiled = compilePrompt(state, record); + const expected = joinPromptSegments([state.config.promptProfile.styleAnchor, record.asset.promptBody]); + assert.equal(compiled.text, expected); + assert.ok(compiled.text.startsWith("高精度三维漫剧 CG"), "锚点必须逐字出现在开头"); + assert.ok(!compiled.text.includes("。。"), "不得出现双句号"); + assert.ok(!compiled.text.includes("不该出现在最终提示词里"), "identityPrompt/deltaPrompt 是输入材料,不得拼进最终提示词"); + assert.ok(!compiled.text.includes("白色无缝背景的角色资产设定板,画面左侧为正面胸像特写,表情平静、平视端庄"), "呈现模板原文不得隐藏追加"); +}); + +test("v2 轮椅角色:坐姿正文满足需求规格即 ready(站立不再是硬拼接)", () => { + const state = anchoredState(); + const record = defaultRecord(state); + const check = readiness(record, state); + assert.deepEqual(check.reasons, [], "坐姿全身图应满足全部必备呈现要素:" + check.reasons.join("、")); + assert.equal(check.ready, true); +}); + +test("v2 正文缺必备呈现要素时拒绝 ready", () => { + const state = anchoredState(); + const record = defaultRecord(state); + record.asset.promptBody = "白色无缝背景,轮椅上的清瘦青年正面全身坐姿图与回首背面全身图。"; + const check = readiness(record, state); + assert.equal(check.ready, false); + assert.ok(check.reasons.some((reason) => reason.includes("正面胸像特写")), "应报缺少胸像特写:" + check.reasons.join("、")); +}); + +test("v2 正文含锚点级风格词时拒绝 ready(防风格漂移)", () => { + const state = anchoredState(); + const record = defaultRecord(state); + record.asset.promptBody += "整体保持 PBR 材质与高精度渲染品质。"; + const check = readiness(record, state); + assert.equal(check.ready, false); + assert.ok(check.reasons.some((reason) => reason.includes("风格词")), "应报风格漂移:" + check.reasons.join("、")); +}); + +test("海外剧本专有名词豁免:拉丁人名不挤占中文占比", () => { + assert.equal(isPrimarilyChinese("玛丽亚(María Fernández Santos)身穿红裙,Johnson 站在她身侧,二人神情凝重。"), true, "首字母大写专有名词应被豁免"); + assert.equal(isPrimarilyChinese("cinematic lighting with soft shadows"), false, "纯英文描述仍应被拒绝"); + assert.equal(isPrimarilyChinese("高清 render of a beautiful girl in red dress"), false, "英文为主的混杂描述仍应被拒绝"); + + const state = anchoredState(); + const record = defaultRecord(state); + record.asset.promptBody = "白色无缝背景的角色资产设定板,画面左侧为 María Fernández 的正面胸像特写;右侧为同一角色的正面全身站姿图与回首背面全身图,比例一致。拉美裔年轻女性,深棕卷发,红裙。"; + const check = readiness(record, state); + assert.equal(check.ready, true, "含拉丁人名的中文正文应通过:" + check.reasons.join("、")); +}); + +test("record v2:缺\"人物群体呈现\"章节被拒绝", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "comic-demographic-")); + try { + const script = "第1集第1场:测试。"; + const bible = "# 故事资产圣经\n\n## 角色档案\n\n### 某角色(C-01)\n"; + const state = { + schemaVersion: 3, + revision: 1, + project: { id: "project-demographic-test", title: "章节测试", sourceScript: "剧本.md", sourceHash: hashValue(script) }, + config: { promptLanguage: "zh-CN" }, + workflow: { + storyBible: { path: "story-asset-bible.md", sourceHash: hashValue(script), contentHash: hashValue(bible), status: "complete" }, + appliedOps: [] + }, + assets: { characters: [], locations: [], props: [] } + }; + await writeFile(path.join(dir, "剧本.md"), script); + await writeFile(path.join(dir, "story-asset-bible.md"), bible); + const stateFile = path.join(dir, "project-state.json"); + await writeFile(stateFile, JSON.stringify(state, null, 2)); + await writeFile(path.join(dir, "art-direction-brief.md"), `# 项目创作方向 + +## 创作命题 + +测试。 + +## 用户确认的方向 + +测试。 + +## 公共风格锚点 + +高精度三维动画渲染,统一世界质感与干净成像。 + +## 分类型美术语言 + +### 角色 + +测试。 + +## 正向设计重点 + +测试。 + +## 角色、场景与道具设计原则 + +测试。 + +## 待确认项 + +无 +`); + const rejected = spawnSync("node", [path.join(root, "scripts/record_art_direction_brief.mjs"), stateFile], { encoding: "utf8" }); + assert.equal(rejected.status, 1, "缺人物群体呈现章节必须被拒绝"); + assert.match(rejected.stderr, /人物群体呈现/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("v2 缺正文时拒绝 ready", () => { + const state = anchoredState(); + const record = defaultRecord(state); + delete record.asset.promptBody; + const check = readiness(record, state); + assert.equal(check.ready, false); + assert.ok(check.reasons.some((reason) => reason.includes("promptBody"))); +}); + +test("名册对账:漏处置候选编号时合并失败,齐全时写入名册", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "comic-roster-")); + try { + const script = "第1集第1场:测试。"; + const state = anchoredState(); + state.project.sourceHash = hashValue(script); + state.workflow.storyBible.sourceHash = state.project.sourceHash; + const bible = "# 故事资产圣经\n\n## 角色档案\n\n### 轮椅青年(C-01)\n\n### 路人甲长老(C-02)\n\n## 场景与地点\n\n### 山门(L-01)\n"; + state.workflow.storyBible.contentHash = hashValue(bible); + state.workflow.artDirection.storyBibleHash = state.workflow.storyBible.contentHash; + await writeFile(path.join(dir, "剧本.md"), script); + await writeFile(path.join(dir, "story-asset-bible.md"), bible); + const stateFile = path.join(dir, "project-state.json"); + await writeFile(stateFile, JSON.stringify(state, null, 2)); + + const basePatch = { + schemaVersion: 3, + projectId: state.project.id, + runId: "run-test", + jobId: "compile-roster", + baseRevision: state.revision, + inputHash: "test", + sourceChunks: [], + operations: [], + conflicts: [], + downgraded: [] + }; + const incomplete = { + ...basePatch, + opId: "roster-incomplete", + roster: [ + { candidateId: "C-01", name: "轮椅青年", disposition: "included", reason: "主角,全剧出场" }, + { candidateId: "L-01", name: "山门", disposition: "included", reason: "多场复用" } + ] + }; + const incompleteFile = path.join(dir, "patch-incomplete.json"); + await writeFile(incompleteFile, JSON.stringify(incomplete, null, 2)); + const failed = spawnSync("node", [path.join(root, "scripts/merge_agent_patches.mjs"), stateFile, incompleteFile], { encoding: "utf8" }); + assert.equal(failed.status, 1, "漏处置时必须失败"); + assert.match(failed.stderr, /未处置.*C-02/); + + const complete = { + ...basePatch, + opId: "roster-complete", + roster: [ + { candidateId: "C-01", name: "轮椅青年", disposition: "included", reason: "主角,全剧出场", assetIds: ["character-lun-yi"] }, + { candidateId: "C-02", name: "路人甲长老", disposition: "excluded", reason: "无台词单场路人,不承担剧情功能" }, + { candidateId: "L-01", name: "山门", disposition: "included", reason: "多场复用" } + ] + }; + const completeFile = path.join(dir, "patch-complete.json"); + await writeFile(completeFile, JSON.stringify(complete, null, 2)); + const passed = spawnSync("node", [path.join(root, "scripts/merge_agent_patches.mjs"), stateFile, completeFile], { encoding: "utf8" }); + assert.equal(passed.status, 0, passed.stderr); + assert.match(passed.stdout, /名册对账通过:3 个候选/); + const merged = JSON.parse(await readFile(stateFile, "utf8")); + assert.equal(merged.workflow.roster.entries.length, 3); + + const updatePatch = { + ...basePatch, + opId: "update-body", + baseRevision: merged.revision, + operations: [{ op: "update", id: "state-lun-yi-default", changes: { promptBody: "白色无缝背景,胸像特写与正面全身、回首背面全身图,坐于轮椅。" } }] + }; + const updateFile = path.join(dir, "patch-update.json"); + await writeFile(updateFile, JSON.stringify(updatePatch, null, 2)); + const updated = spawnSync("node", [path.join(root, "scripts/merge_agent_patches.mjs"), stateFile, updateFile], { encoding: "utf8" }); + assert.equal(updated.status, 0, "promptBody 必须是允许更新的字段:" + updated.stderr); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("record_art_direction_brief v2:锚点写入 profile,角色措辞被拒绝", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "comic-anchor-")); + try { + const script = "第1集第1场:测试。"; + const bible = "# 故事资产圣经\n\n## 角色档案\n\n### 某角色(C-01)\n"; + const brief = (anchor) => `# 项目创作方向 + +## 创作命题 + +东方幻想的成长故事。 + +## 用户确认的方向 + +用户确认走三维动画方向。 + +## 公共风格锚点 + +${anchor} + +## 人物群体呈现 + +剧本设定于架空东方世界,未明确角色按剧本地域语境呈现东亚面貌。 + +## 分类型美术语言 + +### 角色 + +修长比例,服饰有宗门纹样体系。 + +### 场景 + +白玉青石与原木构成的空间层级。 + +### 道具 + +器物带灵力光效识别。 + +## 正向设计重点 + +轮廓可辨识。 + +## 角色、场景与道具设计原则 + +共享同一世界质感。 + +## 待确认项 + +无 +`; + const state = { + schemaVersion: 3, + revision: 1, + project: { id: "project-anchor-test", title: "锚点测试", sourceScript: "剧本.md", sourceHash: hashValue(script) }, + config: { promptLanguage: "zh-CN", visualPreset: { id: "cg-general", name: "通用漫剧 CG", stylePromptSeed: "种子文案。" } }, + workflow: { + storyBible: { path: "story-asset-bible.md", sourceHash: hashValue(script), contentHash: hashValue(bible), status: "complete" }, + appliedOps: [] + }, + assets: { characters: [], locations: [], props: [] } + }; + await writeFile(path.join(dir, "剧本.md"), script); + await writeFile(path.join(dir, "story-asset-bible.md"), bible); + const stateFile = path.join(dir, "project-state.json"); + await writeFile(stateFile, JSON.stringify(state, null, 2)); + + await writeFile(path.join(dir, "art-direction-brief.md"), brief("高精度三维动画渲染与 PBR 材质,明净东方幻想色域,统一材质尺度与干净成像。")); + const passed = spawnSync("node", [path.join(root, "scripts/record_art_direction_brief.mjs"), stateFile], { encoding: "utf8" }); + assert.equal(passed.status, 0, passed.stderr); + const recorded = JSON.parse(await readFile(stateFile, "utf8")); + assert.equal(recorded.config.promptProfile.version, 2); + assert.match(recorded.config.promptProfile.styleAnchor, /东方幻想色域/); + assert.ok(!recorded.config.promptProfile.styleAnchor.includes("种子文案"), "种子原文不得再被拼接"); + assert.equal(Object.hasOwn(recorded.config.promptProfile, "commonVisualLanguage"), false); + + const stale = JSON.parse(await readFile(stateFile, "utf8")); + delete stale.config.promptProfile; + stale.workflow.artDirection = undefined; + await writeFile(stateFile, JSON.stringify(stale, null, 2)); + await writeFile(path.join(dir, "art-direction-brief.md"), brief("高精度三维角色渲染,均匀哑光肤质与分束发丝。")); + const rejected = spawnSync("node", [path.join(root, "scripts/record_art_direction_brief.mjs"), stateFile], { encoding: "utf8" }); + assert.equal(rejected.status, 1, "角色措辞锚点必须被拒绝"); + assert.match(rejected.stderr, /角色专属措辞/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/authorizations.test.mjs b/tests/authorizations.test.mjs new file mode 100644 index 0000000..e7e6c1c --- /dev/null +++ b/tests/authorizations.test.mjs @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const run = (script, args) => spawnSync("node", [path.join(root, "scripts", script), ...args], { encoding: "utf8" }); + +const mediaSentence = "商业电影级真人实拍质感,低调电影光、清晰微纹理与跨资产一致的成像。"; + +function baseState() { + return { + schemaVersion: 3, + revision: 2, + project: { id: "project-auth-test", title: "授权测试", sourceScript: "剧本.md", sourceHash: "hash-script" }, + config: { + promptLanguage: "zh-CN", + styleProfile: { id: "s", name: "测试", contentHash: "h" }, + promptProfile: { version: 3 }, + image: { quality: "high" }, + assetPresentation: { + version: 1, + characterDefault: { id: "l", name: "布局", ratio: "4:3", prompt: "设定板", requirements: [{ label: "白色无缝背景", match: "白色.{0,8}背景|白底" }] }, + characterState: { id: "ls", name: "状态", ratio: "4:3", prompt: "状态图", requirements: [] }, + location: { id: "loc", name: "场景", ratio: "16:9", prompt: "场景图", requirements: [] }, + prop: { id: "p", name: "道具", ratio: "4:3", prompt: "道具图", requirements: [{ label: "单一道具主体", match: "道具|主体" }] } + } + }, + workflow: { + storyBible: { path: "story-asset-bible.md", sourceHash: "hash-script", contentHash: "hash-bible", status: "complete" }, + artDirection: { path: "art-direction-brief.md", sourceHash: "hash-script", storyBibleHash: "hash-bible", setupHash: null, contentHash: "hash-dir", status: "complete" }, + appliedOps: [] + }, + assets: { + characters: [{ + id: "character-a", + name: "阿一", + states: [{ + id: "character-a-default", + sequence: 0, + status: "needs_review", + review: { promptStatus: "needs_review" }, + promptBody: mediaSentence + "白色无缝背景,东亚面貌成年男性,写实七至八头身,素色长袍。" + }] + }], + locations: [], + props: [{ + id: "prop-signed", + name: "带字标签道具", + status: "needs_review", + review: { promptStatus: "needs_review" }, + promptBody: mediaSentence + "单一道具主体为洗旧纸袋,标签为英文 \"Daffodil\",封口结构清晰。" + }] + } + }; +} + +test("批量放行:未授权拒绝;授权后放行普通资产、带可读文字资产留人审", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "comic-auth-")); + try { + const stateFile = path.join(dir, "project-state.json"); + const decisionsFile = path.join(dir, "review-decisions.json"); + await writeFile(stateFile, JSON.stringify(baseState(), null, 2)); + await writeFile(decisionsFile, JSON.stringify({ schemaVersion: 3, projectId: "project-auth-test", revision: 0, decisions: {} }, null, 2)); + + const denied = run("approve_prompt_batch.mjs", [stateFile]); + assert.equal(denied.status, 1, "未授权必须拒绝"); + assert.match(denied.stderr, /未获得提示词批量放行授权/); + + const grant = run("record_standing_authorizations.mjs", [stateFile, "--batch-prompt-approval", "on"]); + assert.equal(grant.status, 0, grant.stderr); + + const approved = run("approve_prompt_batch.mjs", [stateFile]); + assert.equal(approved.status, 0, approved.stderr); + const decisions = JSON.parse(await readFile(decisionsFile, "utf8")).decisions; + assert.equal(decisions["character-a-default"]?.status, "approved", "普通资产应被放行"); + assert.equal(decisions["prop-signed"], undefined, "带可读文字资产默认留人审"); + assert.match(approved.stdout, /留待人审/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("扣费免确认:无授权拒绝;阈值内自动确认、超阈值拒绝", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "comic-auto-")); + try { + const stateFile = path.join(dir, "project-state.json"); + await writeFile(stateFile, JSON.stringify(baseState(), null, 2)); + const planFile = path.join(dir, "execution-plan.json"); + const writePlan = (cost) => writeFile(planFile, JSON.stringify({ executionPlanHash: "hash-plan", batchHash: "hash-batch", estimatedCost: cost }, null, 2)); + + await writePlan(50); + const noAuth = run("record_execution_approval.mjs", [planFile, "--auto", "--state", stateFile, "--confirm-plan-hash", "hash-plan"]); + assert.equal(noAuth.status, 1, "未授权必须拒绝自动确认"); + + const grant = run("record_standing_authorizations.mjs", [stateFile, "--auto-approve-below", "100"]); + assert.equal(grant.status, 0, grant.stderr); + + const under = run("record_execution_approval.mjs", [planFile, "--auto", "--state", stateFile, "--confirm-plan-hash", "hash-plan"]); + assert.equal(under.status, 0, under.stderr); + const plan = JSON.parse(await readFile(planFile, "utf8")); + assert.match(plan.approval.approvedBy, /standing-authorization/); + + await writePlan(150); + const over = run("record_execution_approval.mjs", [planFile, "--auto", "--state", stateFile, "--confirm-plan-hash", "hash-plan"]); + assert.equal(over.status, 1, "超阈值必须回到人工确认"); + assert.match(over.stderr, /超过免确认阈值/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/presentation-scope.test.mjs b/tests/presentation-scope.test.mjs index 2a7e14f..0bdd515 100644 --- a/tests/presentation-scope.test.mjs +++ b/tests/presentation-scope.test.mjs @@ -249,7 +249,10 @@ test("新项目初始化直接持久化四类呈现配置", async (t) => { state = JSON.parse(await readFile(statePath, "utf8")); assert.deepEqual(Object.keys(state.config.assetPresentation).sort(), ["characterDefault", "characterState", "location", "prop", "version"]); assert.equal(state.config.assetPresentation.characterDefault.ratio, "4:3"); - assert.equal(state.config.assetPresentation.characterState.ratio, "16:9"); + // 2026-07-18 B4:角色外观版本跟随所选构图与画幅(不再固定 16:9 单图),消除状态图单/多视图混杂。 + assert.equal(state.config.assetPresentation.characterState.ratio, "4:3"); + assert.equal(state.config.assetPresentation.characterState.id, "closeup-front-back-state"); + assert.ok(state.config.assetPresentation.characterState.requirements.some((item) => item.label === "当前外观版本")); assert.equal(state.config.assetPresentation.location.ratio, "16:9"); assert.equal(state.config.assetPresentation.prop.ratio, "4:3"); }); diff --git a/tests/prompt-body-v3.test.mjs b/tests/prompt-body-v3.test.mjs new file mode 100644 index 0000000..7dd8a02 --- /dev/null +++ b/tests/prompt-body-v3.test.mjs @@ -0,0 +1,350 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +import { + compilePrompt, + findUnspecifiedImageTextTerms, + hashValue, + joinPromptSegments, + listAssets, + readiness +} from "../scripts/lib/project.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +const layoutRequirements = [ + { label: "白色无缝背景", match: "白色.{0,8}背景|白底" }, + { label: "正面胸像特写", match: "胸像" }, + { label: "正面全身图", match: "正面全身" }, + { label: "背面全身图", match: "背面全身|回首背面" } +]; + +const mediaSentence = "商业电影级真人实拍质感,现代哥特调性,低调电影光、清晰微纹理与跨资产一致的成像。"; + +function bodyState() { + return { + schemaVersion: 3, + revision: 5, + project: { id: "project-v3-test", title: "整段正文测试", sourceScript: "剧本.md", sourceHash: "hash-script" }, + config: { + promptLanguage: "zh-CN", + styleProfile: { id: "art-direction-test", name: "测试创作方向", contentHash: "hash-style" }, + promptProfile: { version: 3 }, + image: { quality: "high" }, + assetPresentation: { + version: 1, + characterDefault: { id: "closeup-front-back", name: "特写 + 正背面设定", ratio: "4:3", prompt: "白色无缝背景的角色资产设定板", requirements: layoutRequirements }, + characterState: { id: "single-character-state", name: "单一角色状态图", ratio: "16:9", prompt: "单一角色状态资产图", requirements: [{ label: "单一角色主体", match: "单一|单人|一名|一位|同一角色" }] }, + location: { id: "environment-wide", name: "宽幅场景设定图", ratio: "16:9", prompt: "单一场景环境设定图", requirements: [{ label: "环境全景", match: "场景|环境|空间|全景" }] }, + prop: { id: "single-prop", name: "单一道具设定图", ratio: "4:3", prompt: "单一道具设定图", requirements: [{ label: "单一道具主体", match: "道具|器物|物件|主体" }] } + } + }, + workflow: { + storyBible: { path: "story-asset-bible.md", sourceHash: "hash-script", contentHash: "hash-bible", status: "complete", updatedAt: "2026-07-18T00:00:00.000Z", lockedAt: "2026-07-18T00:00:00.000Z" }, + artDirection: { path: "art-direction-brief.md", sourceHash: "hash-script", storyBibleHash: "hash-bible", setupHash: null, contentHash: "hash-direction", status: "complete", updatedAt: "2026-07-18T00:00:00.000Z", lockedAt: "2026-07-18T00:00:00.000Z" }, + appliedOps: [] + }, + assets: { + characters: [{ + id: "character-evelyn", + name: "Evelyn", + identityPrompt: "不该出现在最终提示词里的身份材料", + states: [{ + id: "state-evelyn-default", + name: "默认外观", + sequence: 0, + status: "approved", + review: { promptStatus: "approved" }, + deltaPrompt: "不该出现在最终提示词里的差量材料", + promptBody: mediaSentence + "白色无缝背景的角色资产设定板,画面左侧为正面胸像特写;右侧为同一角色的正面全身图与回首背面全身图,比例一致。欧美面貌的成年女性,深色长发,黑金礼服。" + }] + }], + locations: [], + props: [{ + id: "prop-seed-packet", + name: "种子包", + status: "approved", + review: { promptStatus: "approved" }, + promptBody: mediaSentence + "单一道具主体为手掌大小的洗旧厚纸袋,正面印黑金家徽,纹样化处理、不构成可读文字,麻线短扣与折叠封口清晰。" + }] + } + }; +} + +function recordById(state, id) { + return listAssets(state).find((record) => record.asset.id === id); +} + +test("v3 最终提示词 = 正文本身,无锚点、无隐藏追加", () => { + const state = bodyState(); + const record = recordById(state, "state-evelyn-default"); + const compiled = compilePrompt(state, record); + assert.equal(compiled.text, joinPromptSegments([record.asset.promptBody])); + assert.ok(compiled.text.startsWith("商业电影级真人实拍质感"), "正文第一句(媒介句)必须位于开头"); + assert.ok(!compiled.text.includes("不该出现在最终提示词里"), "identityPrompt/deltaPrompt 是输入材料,不得拼进最终提示词"); +}); + +test("v3 正文必须自带媒介词:防漂移词表不再适用", () => { + const state = bodyState(); + const record = recordById(state, "state-evelyn-default"); + assert.ok(/实拍质感/.test(record.asset.promptBody), "夹具正文应含 v2 会拒绝的媒介词"); + const check = readiness(record, state); + assert.deepEqual(check.reasons, [], "v3 正文含媒介词必须通过:" + check.reasons.join("、")); + assert.equal(check.ready, true); +}); + +test("v3 正文缺必备呈现要素时拒绝 ready", () => { + const state = bodyState(); + const record = recordById(state, "state-evelyn-default"); + record.asset.promptBody = mediaSentence + "白色无缝背景,欧美面貌成年女性的正面全身图与回首背面全身图。"; + const check = readiness(record, state); + assert.equal(check.ready, false); + assert.ok(check.reasons.some((reason) => reason.includes("正面胸像特写")), "应报缺少胸像特写:" + check.reasons.join("、")); +}); + +test("v3 画面内文字:要求可读文字但未指定语言时拒绝 ready", () => { + const state = bodyState(); + const record = recordById(state, "prop-seed-packet"); + record.asset.promptBody = mediaSentence + "单一道具主体为洗旧厚纸袋,标签分列水仙种子与山楂种子,家族铭文清楚呈现。"; + const check = readiness(record, state); + assert.equal(check.ready, false); + assert.ok(check.reasons.some((reason) => reason.includes("可读文字")), "应报文字语言缺失:" + check.reasons.join("、")); + + record.asset.promptBody = mediaSentence + "单一道具主体为洗旧厚纸袋,标签为英文 \"Daffodil / Hawthorn\",家族铭文为英文缩写,封口结构清晰。"; + const specified = readiness(record, state); + assert.deepEqual(specified.reasons, [], "指定语言后必须通过:" + specified.reasons.join("、")); + + record.asset.promptBody = mediaSentence + "单一道具主体为洗旧厚纸袋,正面家徽与标签均纹样化处理、不构成可读文字。"; + const ornamental = readiness(record, state); + assert.equal(ornamental.ready, true, "声明不可读必须通过:" + ornamental.reasons.join("、")); +}); + +test("findUnspecifiedImageTextTerms:词表保守,无文字要求时不误伤", () => { + assert.deepEqual(findUnspecifiedImageTextTerms("黑曜石戒圈托起旧银印台,宝石与纹样清晰。"), []); + assert.deepEqual(findUnspecifiedImageTextTerms("戒面内圈铭文清楚呈现。"), ["铭文"]); + assert.deepEqual(findUnspecifiedImageTextTerms("店面招牌为中文\"回春堂\"。"), []); +}); + +test("v3 比例数字与呈现配置不一致时拒绝 ready,一致时放行", () => { + const state = bodyState(); + const record = recordById(state, "state-evelyn-default"); + record.asset.promptBody = mediaSentence + "制作 16:9 角色资产设定板,白色无缝背景,正面胸像特写,正面全身图与回首背面全身图,欧美面貌成年女性。"; + const mismatched = readiness(record, state); + assert.equal(mismatched.ready, false); + assert.ok(mismatched.reasons.some((reason) => reason.includes("画幅比例")), "应报比例不一致:" + mismatched.reasons.join("、")); + + record.asset.promptBody = record.asset.promptBody.replace("16:9", "4:3"); + const matched = readiness(record, state); + assert.ok(!matched.reasons.some((reason) => reason.includes("画幅比例")), "与配置一致的比例应放行:" + matched.reasons.join("、")); +}); + +test("record v3:角色块可含人物钉子,场景道具块含人物钉子被拒绝", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "comic-v3-media-")); + try { + const script = "第1集第1场:测试。"; + const bible = "# 故事资产圣经\n\n## 角色档案\n\n### 某角色(C-01)\n"; + const brief = (scenePropBlock) => `# 项目创作方向 + +## 创作命题 + +国风仙侠。 + +## 用户确认的方向 + +用户确认游戏立绘向。 + +## 媒介与画质 + +- **主维度·角色**:国风仙侠三维游戏角色美术,非真人感,低细节皮肤,无毛孔,哑光分束发丝,夸张五官比例,柔和漫射光,高清细节。 +- **主维度·场景与道具**:${scenePropBlock} + +## 人物群体呈现 + +架空东方世界,未明确角色按东亚面貌呈现。 + +## 画面内文字语言 + +架空东方世界,画面内可读文字为中文;装饰类纹样化不可读。 + +## 分类型美术语言 + +### 角色 + +宗门服制体系。 + +### 场景 + +白玉青石空间层级。 + +### 道具 + +灵力器物体系。 + +## 正向设计重点 + +主角一眼可辨。 + +## 角色、场景与道具设计原则 + +共享同一世界质感。 + +## 待确认项 + +无 +`; + const state = { + schemaVersion: 3, + revision: 1, + project: { id: "project-v3-media", title: "媒介块测试", sourceScript: "剧本.md", sourceHash: hashValue(script) }, + config: { promptLanguage: "zh-CN" }, + workflow: { + storyBible: { path: "story-asset-bible.md", sourceHash: hashValue(script), contentHash: hashValue(bible), status: "complete" }, + appliedOps: [] + }, + assets: { characters: [], locations: [], props: [] } + }; + await writeFile(path.join(dir, "剧本.md"), script); + await writeFile(path.join(dir, "story-asset-bible.md"), bible); + const stateFile = path.join(dir, "project-state.json"); + await writeFile(stateFile, JSON.stringify(state, null, 2)); + + await writeFile(path.join(dir, "art-direction-brief.md"), brief("国风仙侠三维美术,体积结构清晰,材质光照统一,高清细节。")); + const passed = spawnSync("node", [path.join(root, "scripts/record_art_direction_brief.mjs"), stateFile], { encoding: "utf8" }); + assert.equal(passed.status, 0, "角色块含人物钉子应通过:" + passed.stderr); + + const stale = JSON.parse(await readFile(stateFile, "utf8")); + delete stale.config.promptProfile; + stale.workflow.artDirection = undefined; + await writeFile(stateFile, JSON.stringify(stale, null, 2)); + await writeFile(path.join(dir, "art-direction-brief.md"), brief("国风仙侠三维美术,发丝分束清晰,材质光照统一。")); + const rejected = spawnSync("node", [path.join(root, "scripts/record_art_direction_brief.mjs"), stateFile], { encoding: "utf8" }); + assert.equal(rejected.status, 1, "场景道具块含人物钉子必须被拒绝"); + assert.match(rejected.stderr, /人物专属措辞/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("局部构图排除项:上半身肖像正文写鞋靴被拒绝,裁剪后放行", () => { + const state = bodyState(); + state.config.assetPresentation.characterDefault = { + id: "upper-body", + name: "上半身肖像", + ratio: "3:4", + prompt: "白色无缝背景的角色上半身肖像", + requirements: [ + { label: "白色无缝背景", match: "白色.{0,8}背景|白底" }, + { label: "上半身肖像", match: "上半身|半身" } + ], + exclusions: [{ label: "下半身内容", match: "鞋|靴|足部|脚踝|裤脚|裙摆" }] + }; + const record = recordById(state, "state-evelyn-default"); + record.asset.promptBody = mediaSentence + "白色无缝背景的角色上半身肖像,欧美面貌成年女性,深色长发,黑金礼服上身与蕾丝领口,黑色皮靴。"; + const rejected = readiness(record, state); + assert.equal(rejected.ready, false); + assert.ok(rejected.reasons.some((reason) => reason.includes("呈现范围之外")), "应报排除项命中:" + rejected.reasons.join("、")); + + record.asset.promptBody = mediaSentence + "白色无缝背景的角色上半身肖像,欧美面貌成年女性,深色长发,黑金礼服上身与蕾丝领口。"; + const passed = readiness(record, state); + assert.deepEqual(passed.reasons, [], "裁剪到可见范围后应放行:" + passed.reasons.join("、")); +}); + +test("v3 缺正文时拒绝 ready", () => { + const state = bodyState(); + const record = recordById(state, "state-evelyn-default"); + delete record.asset.promptBody; + const check = readiness(record, state); + assert.equal(check.ready, false); + assert.ok(check.reasons.some((reason) => reason.includes("promptBody"))); + assert.ok(!check.reasons.some((reason) => reason.includes("公共风格锚点")), "v3 不得要求锚点"); +}); + +test("record v3:媒介与画质模板写入 version 3,缺画面内文字语言被拒绝", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "comic-v3-record-")); + try { + const script = "第1集第1场:测试。"; + const bible = "# 故事资产圣经\n\n## 角色档案\n\n### 某角色(C-01)\n"; + const brief = (withTextSection) => `# 项目创作方向 + +## 创作命题 + +现代哥特婚姻惊悚。 + +## 用户确认的方向 + +用户确认真人实拍方向。 + +## 媒介与画质 + +主维度:商业电影级真人实拍质感,现代哥特调性,低调电影光与跨资产一致的成像。 + +## 人物群体呈现 + +西式命名与西式世界观,未明确角色按欧美面貌为主呈现。 +${withTextSection ? ` +## 画面内文字语言 + +西式世界观,画面内可读文字为英文;装饰类纹样化不可读。 +` : ""} +## 分类型美术语言 + +### 角色 + +王庭收束轮廓,主角组独占绯红识别色。 + +### 场景 + +维多利亚骨架嵌入现代设施。 + +### 道具 + +旧银与皮革的契约器物体系。 + +## 正向设计重点 + +主角一眼可辨。 + +## 角色、场景与道具设计原则 + +共享同一世界质感。 + +## 待确认项 + +无 +`; + const state = { + schemaVersion: 3, + revision: 1, + project: { id: "project-v3-record", title: "v3 模板测试", sourceScript: "剧本.md", sourceHash: hashValue(script) }, + config: { promptLanguage: "zh-CN", visualPreset: { id: "live-action", name: "真人通用", stylePromptSeed: "种子文案。" } }, + workflow: { + storyBible: { path: "story-asset-bible.md", sourceHash: hashValue(script), contentHash: hashValue(bible), status: "complete" }, + appliedOps: [] + }, + assets: { characters: [], locations: [], props: [] } + }; + await writeFile(path.join(dir, "剧本.md"), script); + await writeFile(path.join(dir, "story-asset-bible.md"), bible); + const stateFile = path.join(dir, "project-state.json"); + await writeFile(stateFile, JSON.stringify(state, null, 2)); + + await writeFile(path.join(dir, "art-direction-brief.md"), brief(false)); + const rejected = spawnSync("node", [path.join(root, "scripts/record_art_direction_brief.mjs"), stateFile], { encoding: "utf8" }); + assert.equal(rejected.status, 1, "缺画面内文字语言章节必须被拒绝"); + assert.match(rejected.stderr, /画面内文字语言/); + + await writeFile(path.join(dir, "art-direction-brief.md"), brief(true)); + const passed = spawnSync("node", [path.join(root, "scripts/record_art_direction_brief.mjs"), stateFile], { encoding: "utf8" }); + assert.equal(passed.status, 0, passed.stderr); + const recorded = JSON.parse(await readFile(stateFile, "utf8")); + assert.equal(recorded.config.promptProfile.version, 3); + assert.equal(Object.hasOwn(recorded.config.promptProfile, "styleAnchor"), false, "v3 不承载 styleAnchor"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/prompt-shards.test.mjs b/tests/prompt-shards.test.mjs new file mode 100644 index 0000000..7a93471 --- /dev/null +++ b/tests/prompt-shards.test.mjs @@ -0,0 +1,74 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { planPromptShards } from "../scripts/plan_prompt_shards.mjs"; + +function sampleState() { + return { + revision: 7, + project: { id: "project-test" }, + assets: { + characters: [ + { + id: "character-a", + name: "阿一", + states: [ + { id: "character-a-default", sequence: 0, promptBody: "已写" }, + { id: "character-a-armor", sequence: 1 } + ] + }, + { id: "character-b", name: "阿二", states: [{ id: "character-b-default", sequence: 0 }] }, + { id: "character-empty", name: "无状态", states: [] } + ], + locations: [1, 2, 3, 4, 5].map((n) => ({ id: `location-${n}`, name: `场景${n}` })), + props: [1, 2, 3, 4].map((n) => ({ id: `prop-${n}`, name: `道具${n}`, promptBody: n === 1 ? "已写" : "" })) + } + }; +} + +test("每个角色一个分片且包含全部外观版本;场景按上限聚类", () => { + const plan = planPromptShards(sampleState(), { maxPerShard: 4 }); + const characterShards = plan.shards.filter((shard) => shard.assetType === "character"); + assert.equal(characterShards.length, 2); + assert.deepEqual(characterShards[0].assetIds, ["character-a-default", "character-a-armor"]); + assert.equal(characterShards[0].characterId, "character-a"); + const locationShards = plan.shards.filter((shard) => shard.assetType === "location"); + assert.equal(locationShards.length, 2); + assert.deepEqual(locationShards[0].assetIds, ["location-1", "location-2", "location-3", "location-4"]); + assert.deepEqual(locationShards[1].assetIds, ["location-5"]); + assert.equal(plan.summary.total, 2 + 2 + 1); + assert.equal(plan.summary.assetCount, 3 + 5 + 4); +}); + +test("分片结果确定性:同一状态两次生成完全一致", () => { + const first = planPromptShards(sampleState(), { maxPerShard: 4 }); + const second = planPromptShards(sampleState(), { maxPerShard: 4 }); + assert.deepEqual(first.shards, second.shards); +}); + +test("打包:多外观角色独占一包,普通分片按资产数聚合", () => { + const state = sampleState(); + state.assets.characters.push({ + id: "character-c", + name: "阿三", + states: [1, 2, 3].map((n) => ({ id: `character-c-s${n}`, sequence: n - 1 })) + }); + const plan = planPromptShards(state, { maxPerShard: 4 }); + const solo = plan.bundles.find((bundle) => bundle.shardIds.includes("shard-character-character-c")); + assert.equal(solo.solo, true, "3 外观角色应独占一包"); + assert.deepEqual(solo.shardIds, ["shard-character-character-c"]); + const characterBundle = plan.bundles.find((bundle) => bundle.shardIds.includes("shard-character-character-a")); + assert.ok(characterBundle.shardIds.includes("shard-character-character-b"), "普通角色分片应聚合进同一包"); + for (const bundle of plan.bundles) { + const cap = { character: 6, location: 10, prop: 10 }[bundle.assetType]; + assert.ok(bundle.solo || bundle.assetCount <= cap, bundle.bundleId + " 超出打包上限"); + } + assert.equal(plan.summary.bundles, plan.bundles.length); +}); + +test("missingOnly 只保留缺正文的资产并丢弃空分片", () => { + const plan = planPromptShards(sampleState(), { maxPerShard: 4, missingOnly: true }); + const characterA = plan.shards.find((shard) => shard.characterId === "character-a"); + assert.deepEqual(characterA.assetIds, ["character-a-armor"]); + const propShard = plan.shards.find((shard) => shard.assetType === "prop"); + assert.deepEqual(propShard.assetIds, ["prop-2", "prop-3", "prop-4"]); +}); diff --git a/tests/style-presets.test.mjs b/tests/style-presets.test.mjs new file mode 100644 index 0000000..eafd1f6 --- /dev/null +++ b/tests/style-presets.test.mjs @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { findDedicatedDeliveryLayoutTerms } from "../scripts/lib/project.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +test("预设种子卫生:无版式词(人物钉子豁免)、无比例数字、均带定位标签", async () => { + const manifest = JSON.parse(await readFile(path.join(root, "assets/setup-app/style-presets.json"), "utf8")); + assert.equal(manifest.version, 3); + for (const preset of manifest.styles) { + assert.ok(typeof preset.positioning === "string" && preset.positioning.trim(), preset.id + " 缺少 positioning 定位标签"); + if (!preset.stylePromptSeed) continue; + // 人物防漂移钉子(面部/五官/发丝/服装等)是有意保留的;其余版式词(比例/白底/胸像/三视图/正背面/排版)不得进种子。 + const layoutTerms = findDedicatedDeliveryLayoutTerms(preset.stylePromptSeed).filter((term) => term !== "角色局部细节"); + assert.deepEqual(layoutTerms, [], preset.id + " 的种子含版式词:" + layoutTerms.join("、")); + assert.ok(!/\d+\s*[::]\s*\d+/.test(preset.stylePromptSeed), preset.id + " 的种子含比例数字"); + assert.ok(!/二次元/.test(preset.stylePromptSeed) || preset.family !== "CG 人设", preset.id + " 的 CG 种子不应含“二次元”(曾造成用户媒介预期错配)"); + } +}); From 1797ca8184400f1d97221c61ceb4d7211806c024 Mon Sep 17 00:00:00 2001 From: Shanzoon <82895720+Shanzoon@users.noreply.github.com> Date: Mon, 20 Jul 2026 03:09:03 +0800 Subject: [PATCH 07/11] =?UTF-8?q?=E8=A1=8C=E4=B8=BA=E4=BF=9D=E6=8C=81?= =?UTF-8?q?=E5=9E=8B=E7=B2=BE=E7=AE=80=E4=B8=80/=E4=BA=8C=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=E4=B8=8E=20v3.1=20=E8=A7=86=E8=A7=89=E8=B4=A8?= =?UTF-8?q?=E9=87=8F=E7=AC=AC=E4=B8=80=E6=89=B9(2026-07-19=20=E4=B8=89?= =?UTF-8?q?=E6=89=B9=E5=90=88=E5=B9=B6=E6=8F=90=E4=BA=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 一阶段:SKILL 与 references 重构为阶段能力协议;新增 invariants/legacy-profiles;删除 image-consistency;校验归并 promptBodyFindings;viewer 词表去重与 /api/view-state - 二阶段:P1-P5 已批准行为变化(SOP 降 rubric、结构化 textPolicy 第一步、取消鞋靴 exclusions、开放调度、确认收敛) - v3.1 第一批:F02 边界收窄、localTone 与差异矩阵上移、软预算与像素租金、4:3 角色板释放姿态、只读 Prompt 审计 audit_prompt_bodies - 附:C4 执行工具 map_awb_image_batch / normalize_awb_image_results 及其测试 - 注:三批中间状态未单独保留,合并为一次提交;各批详情见 CHANGELOG 2026-07-19 条目与交接报告 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 39 ++ SKILL.md | 187 ++++----- assets/setup-app/style-presets.json | 8 +- assets/viewer-app/viewer.js | 27 +- references/creative-charter.md | 32 +- references/execution-modes.md | 48 ++- references/image-consistency.md | 22 - references/invariants.md | 39 ++ references/legacy-profiles.md | 39 ++ references/lj-awb-quickref.md | 28 +- references/project-schema.md | 394 +++++++----------- references/prompt-authoring.md | 114 +++--- references/provider-policy.md | 36 +- references/subagent-contracts.md | 99 +++-- scripts/apply_review_decisions.mjs | 4 + scripts/apply_setup_decisions.mjs | 6 +- scripts/approve_prompt_batch.mjs | 35 +- scripts/audit_prompt_bodies.mjs | 470 ++++++++++++++++++++++ scripts/bootstrap_art_direction_brief.mjs | 76 +++- scripts/bootstrap_story_bible.mjs | 33 +- scripts/lib/project.mjs | 102 +++-- scripts/map_awb_image_batch.mjs | 179 ++++++++ scripts/merge_agent_patches.mjs | 4 +- scripts/normalize_awb_image_results.mjs | 175 ++++++++ scripts/plan_prompt_shards.mjs | 27 +- scripts/plan_work_dag.mjs | 15 +- scripts/prepare_awb_execution.mjs | 35 +- scripts/record_art_direction_brief.mjs | 9 +- scripts/record_execution_approval.mjs | 10 +- scripts/validate_project_state.mjs | 22 +- scripts/workspace_server.mjs | 32 +- tests/authoring-templates.test.mjs | 66 +++ tests/authorizations.test.mjs | 4 +- tests/awb-batch-mapper.test.mjs | 146 +++++++ tests/awb-result-normalizer.test.mjs | 191 +++++++++ tests/execution-plan.test.mjs | 130 ++++++ tests/prompt-audit.test.mjs | 222 ++++++++++ tests/prompt-body-v3.test.mjs | 80 +++- tests/prompt-shards.test.mjs | 16 + tests/style-presets.test.mjs | 19 + 40 files changed, 2565 insertions(+), 655 deletions(-) delete mode 100644 references/image-consistency.md create mode 100644 references/invariants.md create mode 100644 references/legacy-profiles.md create mode 100644 scripts/audit_prompt_bodies.mjs create mode 100644 scripts/map_awb_image_batch.mjs create mode 100644 scripts/normalize_awb_image_results.mjs create mode 100644 tests/authoring-templates.test.mjs create mode 100644 tests/awb-batch-mapper.test.mjs create mode 100644 tests/awb-result-normalizer.test.mjs create mode 100644 tests/execution-plan.test.mjs create mode 100644 tests/prompt-audit.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 40aac49..623bf26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,45 @@ 后续接手此 skill 的窗口在修改前应先阅读本文件、`SKILL.md` 与相关数据契约。记录应说明行为变化、兼容性和验证方式,而不是逐行重复代码差异。 +## 当前行为速览 + +- 新项目默认 promptProfile v3:最终 prompt 等于 `promptBody`,无隐藏拼接;v1/v2 只在恢复存量项目时按条件加载兼容说明。 +- 新建 v3 项目同时启用 `textPolicyVersion: 1`;每个正文声明 none / decorative / readable。无标记的存量 v3 项目继续兼容旧链路,G03 词表暂时保留。 +- 五阶段为 setup → 故事资产圣经 → 创作方向与正式名册 → Prompt / 审查 → 增量生成 / 收割;圣经和总纲锁定后在原项目不可改。 +- 项目状态、审查、执行与供应商事实分层保存;canonical 只经官方脚本写入,执行确认与 checkpoint 由 hash 保护。 +- 四类资产使用各自 presentation 与 ratio;角色后续版本只引用本角色锁定的默认 artifact。 +- 可逆动作直接执行并复述;锁定、超出持久授权的扣费和其他不可逆动作需要确认。高后果创作项必须用成图后果句显式呈现。 +- 运行时按阶段读取 references;生产不变量、岗位准入、Prompt 契约、旧版本兼容和供应商边界各有唯一权威文件。 + +## 2026-07-19 — v3.1 视觉质量与注意力预算第一批 + +- **F02 边界收窄,契约不放松**:总纲模板把“媒介与画质”限定为渲染范式、材质/体积/光照、人物防漂移钉子与跨资产一致性;项目题材、全局情绪、角色关系、善恶评价和局部场景气氛不得进入媒介核。岗位四仍须把对应 `维度 × 资产类型` 的 F02 整行逐字放在正文开头。 +- **局部气质与核心角色差异上移**:总纲新增“世界语言与局部气质分流”,只把当前资产的一句 `localTone` 摘入任务卡;只为高碰撞核心角色建立轻量差异矩阵,验收至少三项非颜色差异且不共享“脸型 + 发型 + 姿态”组合。调度任务卡契约同步携带相关矩阵行与 localTone,不新增 canonical schema 字段。 +- **故事圣经与 Prompt 减负**:剧情梗概默认目标不超过 300 个汉字,复杂长剧目标不超过 600,只保留影响资产判断的转折;角色/场景/道具改为短事实条目。Prompt Agent 新增“像素租金”、分资产软信息预算和角色组对审;它们只用于创作审查,不是字符或气质词表门禁,四步写法仍是 RECIPE。 +- **4:3 角色板释放姿态**:`closeup-front-back` 保留占主要视觉面积的面部近景、正面全身和背面/三分之四背面信息;移除统一“平静、平视端庄、排版紧密”。体态事实优先,允许不遮挡轮廓和服装结构的非对称、自然或角色化姿态,不强制直立、放松或端庄站姿。ratio、layout id 与 requirements 不变;存量项目不迁移、不重写。 +- **新增只读 Prompt 审计**:`audit_prompt_bodies.mjs` 从总纲取证 F02,输出关系/叙事/逐视图重复、可变正文软预算、角色词汇碰撞和 `textPolicy` 分布;证据不足时返回 `unsupported`。全部结果明确是 observation,不参与 readiness、不证明视觉质量、绝不写项目。 +- **独立前向审查**:布局盲审发现“足够大”与“放松站姿”仍可能欠约束/过约束,已改为“占主要视觉面积 + 体态事实优先”。另一个隔离 Agent 在旧项目上自然拉开苏念/苏雅五项非颜色差异,同时仍被旧 F02 迫使地宫携带“梦幻唯美”,证明新边界须在新总纲或派生项目生效,不能改写锁定项目。 +- **验证与兼容**:47/47 全量测试、Node 语法、`git diff --check` 与 skill-creator `quick_validate.py` 通过。两个真实项目只读审计成功:23/23 与 57/57 个正文均可精确取证 F02;《你也配修仙》观测到 6 个关系词资产、1 个叙事词资产、4 个逐视图重复资产;《三年之约》的碰撞启发式标出苏念/苏雅。未建批、未调用 AWB、未扣费。 + +## 2026-07-19 — 强 Agent 第二阶段:rubric、结构化文字策略与开放调度 + +- **P1 Prompt 自主性**:四步写法从硬 SOP 降为推荐 `RECIPE`,正文改按事实忠实、风格一致、视觉可执行、信息经济、规格完整和锚点价值六项 rubric 审查。F02“对应媒介块原样抄写”继续是不可协商 `CONTRACT`;主美式完整示例保留。下一次真实项目需统计关系词、叙事词、逐视图重复是否回潮。 +- **P2 第一步完成,第二步明确未做**:新建 v3 项目记录 `promptProfile.textPolicyVersion: 1`;每个有正文的资产声明 `textPolicy.mode = none / decorative / readable`,readable 还必须声明语言和逐条原文。readiness、validate、批量放行、DAG 完整性、missing-only 分片、sidecar merge、review patch 和 prompt sourceHash 已接入。无标记的存量 v3 继续兼容;G03 可读文字词表仍并行生效,必须完成至少一轮真实项目实测后才能在后续独立改动中评估删除。 +- **P3 取消鞋靴机械排除**:删除 presentation exclusions 的传播、预设词表和 readiness 拒绝;“局部构图只描述可见范围”保留为语义 rubric,不再用鞋、靴、裙摆词表猜测越界。 +- **P4 调度开放且可观测**:shard 继续作为硬审计单位,bundle 改为 planner 建议;主代理可按复杂度和槽位调整。保留“禁止按资产类型三分”负面清单;planner 输出建议并发和预期波数,收尾必须回报实际波数、总耗时和偏差。 +- **P5 确认收敛**:人物群体呈现、画面内文字语言可并入一个总纲确认包;两项仍须分别用“这意味着成图……”一类后果句醒目呈现,不要求逐条问答,不得退化为笼统“请确认总纲”。扣费、锁定和 approval hash 审计不变。 +- **验证**:35/35 测试通过;新增用例证明结构化 textPolicy 生效、旧 G03 仍生效、validate 会拒绝缺策略资产、missing-only 会追回缺策略资产;Node 语法检查通过。 + +## 2026-07-19 — 面向强 Agent 的第一阶段精简(行为保持) + +- **入口重写**:`SKILL.md` 从补丁式全量 SOP 改为“入口判断 + 统一授权模型 + 五阶段完成条件 + 按需加载索引 + 上下文预算”。仍保留当前锁定、确认、调度、审查与恢复行为。 +- **单一事实源**:新增 `references/invariants.md` 承载生产不变量,新增 `references/legacy-profiles.md` 隔离 v1/v2 兼容;重写 creative-charter、prompt-authoring、subagent-contracts、project-schema、execution-modes、provider-policy。删除已过时且与当前差量白名单冲突的 `image-consistency.md`,其有效内容已归入 invariants / schema / prompt 契约。 +- **模板降噪**:重写两个 bootstrap 内嵌模板,保留全部 required headings、候选编号、身份事实、媒介双块、人物群体呈现、文字语言和辨识度语义;模板只说明产物字段,详细原则回到权威 reference。 +- **校验合并但不松门禁**:readiness、validate、批量放行共享 `promptBodyFindings()`;角色专属措辞和带可读文字判断统一到后端 lib。viewer 的旧 render 过滤改由 `/api/view-state` 使用后端唯一兼容解析器提供,`/api/project-state` 仍返回原始 canonical 数据。 +- **体积**:盘点范围总字符 40,890 → 27,165,下降 33.6%;其中运行文档 32,047 → 20,352,下降 36.5%。 +- **兼容性**:v1/v2/v3 编译、当前全部词表/正则门禁、名册对账、授权、checkpoint、viewer 展示与真实项目数据不变。用户已批准的 rubric、textPolicy、鞋靴排除门禁取消、调度开放和确认收敛留给独立第二阶段,不在本批“边建边拆”。 +- **验证**:`node --check` 覆盖改动脚本;32/32 测试通过;工作台 `/api/view-state`、`/api/project-state`、首页均返回 200,旧项目只读投影正确过滤污染 render;skill-creator `quick_validate.py` 通过。 + ## 2026-07-18 — 呈现排除项:局部构图的"不能有什么"清单(用户提问暴露的缺口) - 背景:需求规格(requirements)只是"必须有什么"的存在性检查——选"上半身肖像"后,正文写整套服装(皮靴/裙摆)照样通过校验,而画外内容会诱导生图模型拉远构图破坏版式;B4 状态图继承构图后该风险被放大。 diff --git a/SKILL.md b/SKILL.md index 156a1c7..5a723a9 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,173 +1,136 @@ --- name: comic-assets -description: 将用户上传的剧本直接转成可审查、可恢复、逐角色流式生成的 AI 漫剧资产图片项目。用于从剧本提取角色/状态/场景/道具,生成分层视觉提示词,在对话中完成资产确认,依赖 lj-awb 估价、批量生图、轮询、重试和收割结果;也用于继续中断项目、只重跑失败资产或导入上游资产清单。 +description: 将任意语言剧本转成可审查、可恢复、逐角色流式生成的 AI 漫剧资产项目。用于提取角色与外观版本、场景、道具,建立故事资产圣经和美术总纲,编写与审查中文生图提示词,通过 lj-awb 估价、批量生图、轮询、重试和收割结果;也用于恢复中断项目、只重跑失败资产或导入上游资产清单。 --- # 漫剧资产生图 -目标是让用户只做三件事:上传剧本、补充缺失的美术风格、确认审查与付费批次。其余目录、ID、DAG、checkpoint、提示词编译和结果合并由本 skill 完成。 +把剧本转成可审查、可恢复、可增量生成的资产项目。让 Agent 对理解、设计、调度和交付质量负责;把稳定 ID、锁定、hash、审查、扣费、checkpoint 与恢复交给确定性脚本。 -## 接手与变更记录 +## 先判断入口 -修改本 skill 前必须先阅读 [CHANGELOG.md](CHANGELOG.md)。它记录当前行为、兼容性和已验证的改动;不要在不了解既有锁定规则的情况下覆盖流程。 +- **新剧本**:初始化项目并立即启动统一工作台。 +- **已有项目**:使用同一入口加 `--resume`,根据项目文件恢复,不从头重做。 +- **只审查或继续生图**:重新启动工作台,读取当前 DAG,不重建已存在产物。 +- **依赖不可用**:仍交付完整项目、审查数据和供应商无关批次;不要伪造生图完成。 -## 零配置入口 +要求 Node.js >=20;真实生图还要求 lj-awb 可用、联网并已登录。 -要求 Node.js >=20。真实生图还要求安装 lj-awb skill、联网并完成 AWB 登录;依赖不可用时仍交付完整项目、审查数据和供应商无关批次。 - -剧本可以是任何语言与地域(国内、东南亚、欧美、拉美等):专有名词保留原文;角色的性别、年龄段、族裔只采剧本明确写出的事实,未明确的由 AI 美术总监在创作方向"人物群体呈现"章节按剧本地域语境统一决策,不交给生图模型的默认值。收到新剧本后直接初始化,不要求用户命名目录: - -~~~bash +```bash node "/scripts/run_project.mjs" "<剧本文件>" --project-dir "<项目目录>" -~~~ - -初始化后,**先立即启动统一项目工作台**,不要先问用户模型或参数。初始化 Dialog 是工作台的首次浮层,用户单选一个视觉范式和一个默认角色资产构图;预览图只用于帮助选择,绝不作为图生图参考上传。工作台使用同一个本地地址贯穿项目五阶段,提交选择后不得关闭服务或另开查看地址: - -~~~bash node "/scripts/workspace_server.mjs" "<项目目录>" -# 在浏览器打开终端给出的 URL。用户提交选择并返回 Agent 对话后: -node "/scripts/apply_setup_decisions.mjs" "<项目目录>/project-state.json" -~~~ +``` -**初始化选择的授权规则**:setup 在总纲锁定前可以随时重选,不是不可逆动作,因此**不做前置确认**——用户在对话里明确说出了视觉范式和构图选择(说了即授权),主代理直接代写 `setup-decisions.json` 并运行 apply,然后在**同一条回复里**复述所写的预设名、定位、种子原文与构图(`apply_setup_decisions.mjs` 会输出这些内容),用户有异议当场重选即可,不要求用户去页面点选、不做多轮确认。两条边界不变:① 用户**没有**明确表达时不得自行猜测匹配预设、不得参考其他项目的历史偏好——引导用户在 Dialog 中点选;② 用户的描述与现有预设/构图不完全对应时,列出最接近的候选(含定位标签)让用户挑,不代做取舍。 +工作台使用同一本地地址贯穿项目。初始化预览图只帮助用户选择,绝不作为图生图参考上传。 -工作台只把选择写入 `setup-decisions.json`,不会直接修改 canonical 状态;`apply_setup_decisions.mjs` 校验后才把风格的中文提示词种子、默认角色构图描述和比例写入项目状态。提交后页面显示“设置已保存,等待 Agent 继续”,并自动轮询项目状态;不得让用户在阶段之间切换地址。随后**必须派岗位一子代理(剧本统筹与资产研究员)**用自然语言完成 `story-asset-bible.md` 的世界观、剧情时间线、角色档案(含外观版本)、场景、道具、证据和确定性处理结果;派发任务卡只含剧本路径与事实提取规则,不携带画风、构图、模型参数或无关对话历史;主代理不得自行读取剧本原文(见"主代理上下文纪律")。不要要求子代理输出 JSON 或供应商提示词。证据一律用"集-场"编号指针而非台词全文;角色只记录真正的外观版本(整套服装/装备体系变化、永久身体变化),不记录表情、姿势、临时受伤等瞬时变化——定义与入册条件见圣经模板与 [references/subagent-contracts.md](references/subagent-contracts.md)。完成后固化该全局理解: +## 自主权与确认 -~~~bash -node "/scripts/record_story_bible.mjs" "<项目目录>/project-state.json" -~~~ +按后果决定是否暂停: -确认会锁定故事资产圣经。后续日常修改应落在美术风格、提示词差量、审查意见或图片锚点;如果发现全局剧情理解错误,保留当前项目和产物,另建或派生项目重新开始,不在原项目内改写圣经。 +- **可逆动作**:直接执行并透明复述,包括初始化选择的落盘、草稿、分片、局部重写和状态扫描。 +- **用户已明确选择**:视为授权;setup 锁定前可直接代写 `setup-decisions.json` 并运行 apply,同条回复复述预设名、定位、种子原文和构图。 +- **选择不明确**:不得猜测匹配预设或借用其他项目偏好;给最接近候选让用户选择。 +- **不可逆动作**:锁定故事圣经、锁定创作方向、锁定参考图、超出持久授权的扣费与破坏性迁移必须确认。 +- **持久授权**:只在记录的范围和阈值内自动执行,仍保留快照、hash 和日志。 -故事资产圣经锁定后,岗位二与岗位三**并行**推进: +创作方向确认可合并成一个确认包,但“人物群体呈现”和“画面内文字语言”必须在包内分别用成图后果句醒目呈现,例如“这意味着成图主角将呈现为欧美面孔”“道具文字将渲染为英文”。用户可以一次确认整个包;不得退化为笼统的“请确认总纲”,也不得让高后果项被其他摘要淹没。 -- **岗位二(AI 美术总监)**填写 `art-direction-brief.md`:提出创作命题、用户确认方向、媒介与画质(每个媒介维度一句,含感性的题材气质与调性,禁止具体色名与材质枚举)、人物群体呈现、画面内文字语言、分类型美术语言(含阵营色彩体系与主角辨识度层级)、正向设计重点与跨资产设计原则。任何章节不得承载白底、胸像、三视图、正背面、排版或比例。 -- **岗位三(名册编制)**同时依据圣经和资产准入规则生成 canonical 资产 sidecar patch(见下),它不读取美术总纲,不写美术提示词,二者互不等待。 +完整授权、审查和执行状态见 [references/execution-modes.md](references/execution-modes.md)。所有阶段共同遵守 [references/invariants.md](references/invariants.md)。 -**高后果章节确认协议**:请求用户确认总纲前,主代理必须把"人物群体呈现"与"画面内文字语言"两节**单独摘出**,各用一句后果化语言复述其成图后果(例:"这意味着成图主角将呈现为欧美面孔——确认吗?"、"这意味着道具上的可读文字将渲染为英文——确认吗?"),等用户逐条回应后才可固化;不得让这两节淹没在整体确认里。美术总纲经用户确认后固化: +## 五阶段 -~~~bash -node "/scripts/record_art_direction_brief.mjs" "<项目目录>/project-state.json" -~~~ +### 1. 初始化 -新模板(整段正文 v3)下它校验"媒介与画质""人物群体呈现""画面内文字语言"等必填章节并把 `promptProfile` 标记为版本 3——**没有任何逐字拼接段**:媒介、画质、风格全部作为总纲输入材料,由岗位四消化后写进每条正文(正文第一句原样使用本维度的媒介与画质句)。历史模板继续兼容录入:含"公共风格锚点"章节的按锚定式 v2(锚点 ⊕ 正文),含"公共视觉语言/全局渲染语言"的按 v1。运行时只遵循 [references/creative-charter.md](references/creative-charter.md) 与 [references/prompt-authoring.md](references/prompt-authoring.md) 的短原则;不加载外部美术知识库原文作为逐条命令,也不使用负向提示词。 +打开工作台,让用户选择视觉范式和默认角色构图;明确选择也可由主代理代写 sidecar。工作台只写 `setup-decisions.json`,随后运行: -岗位三的编译 Agent 只依据故事资产圣经和资产准入规则(两层闸门,见 subagent-contracts)生成 canonical 资产 sidecar patch,主代理运行 `merge_agent_patches.mjs` 合并;这一步可以在美术总纲确认之前完成。岗位四写提示词必须等美术总纲锁定。仅在需要核验覆盖率或补漏时,才按剧本切片: +```bash +node "/scripts/apply_setup_decisions.mjs" "<项目目录>/project-state.json" +``` -~~~bash -node "/scripts/build_script_shards.mjs" "<项目目录>/<原始剧本>" --output "<项目目录>/workflow/chunks" -~~~ +### 2. 全剧事实 -换机或首次执行 AWB 前运行: +派岗位一完成 `story-asset-bible.md`。任务卡只含剧本路径和事实提取规则,不携带画风、构图、模型参数或无关对话;主代理不读剧本全文。岗位边界、外观版本定义和准入规则只读 [references/subagent-contracts.md](references/subagent-contracts.md)。 -~~~bash -node "/scripts/preflight.mjs" --bootstrap -~~~ +```bash +node "/scripts/record_story_bible.mjs" "<项目目录>/project-state.json" +``` -恢复已有项目时使用同一入口并加 --resume,随后扫描状态,不从头重做;需要浏览时重新运行 `workspace_server.mjs`,它会从项目文件恢复当前阶段,不依赖聊天上下文。 +这一步会锁定全局理解。发现重大理解错误时保留产物并派生项目,不在原项目改写。 -## 核心 DAG +### 3. 创作方向与正式名册 -1. 项目启动即打开统一工作台,初始化 Dialog 覆盖在同一应用外壳上。用户单选视觉范式和默认角色资产构图;提交后页面保持在线并引导返回 Agent,`apply_setup_decisions.mjs` 将选择固化为项目事实。预览图仅供人判断,不进入任何模型输入。 -2. 岗位一(剧本统筹与资产研究员)先写 `story-asset-bible.md`,建立全局实体、别名、外观版本、证据与确定性处理结果;任务卡只含剧本与提取规则。运行 `record_story_bible.mjs` 后锁定,才可继续。 -3. 圣经锁定后**并行**:岗位二(AI 美术总监)依据圣经、用户意图和已选视觉范式提出 `art-direction-brief.md`,用户确认后运行 `record_art_direction_brief.mjs` 锁定创作方向;岗位三(名册编制)同时依据圣经和两层闸门准入规则生成 canonical 资产 sidecar patch,并在 patch 的 `downgraded` 数组回报降级清单(见 subagent-contracts)。岗位三不读取美术总纲。 -4. 名册对账:圣经中的每个外观版本要么成为 state 要么进 downgraded;只出默认外观视为编译不完整,主代理退回重编。主代理运行 `merge_agent_patches.mjs`,统一稳定 ID、revision 和冲突。 -5. 剧本分片只用于核验与补漏。worker 不能在核验阶段自行创建最终 canonical ID;疑似新实体或别名冲突必须回报主代理。 -6. 岗位四(Prompt Agent)在创作方向锁定且名册合并后,为每个资产用中文编写**整段提示词正文**(`promptBody`)并自行确认;新项目(promptProfile v3)最终提示词 = 正文本身,逐字物化,无任何拼接或隐藏追加——正文第一句原样使用总纲"媒介与画质"中本维度的句子,画面内可读文字必须指定语言或声明不可读(校验拒绝违规)。呈现模板降级为需求规格:正文必须语义覆盖该资产类型的必备呈现要素(白底、胸像、正/背面全身等),但措辞服从角色事实(轮椅角色写坐姿而非站立)。角色默认态、角色状态、场景和道具各自继承自己的需求规格与比例。存量项目兼容:锚定式 v2 = 锚点 ⊕ 正文(正文禁锚点级风格词);v1 按五段拼接编译。专有名词可保留必要原文。 - **并发派发按打包清单执行(硬要求)**:先运行 `node "/scripts/plan_prompt_shards.mjs" "<项目目录>/project-state.json" --missing-only`。分片(每角色一片含全部外观版本;场景、道具 ≤4 聚类)是**审计与对账单位**;脚本同时输出的包(bundle,普通分片按资产数聚合,多外观角色独占一包)才是**派发单位**。每包派一个子代理,完成一包立即领取下一包(常驻消费,不等整波齐),并发子代理数 = min(包数, 可用槽位);完成后按 shardId 对账,缺片补派。**任务卡必须自包含**:该包各资产的名册条目、圣经事实摘录、本类型媒介与画质词块、构图需求规格与文字策略——子代理不回读完整剧本、圣经或项目状态。**禁止按资产类型只开三个子代理,也禁止一片一派**(一片一派曾把 159 资产拖成 26 波、51 分钟)。 -7. 运行 apply_review_decisions.mjs 消费稀疏审查 patch,再运行 plan_work_dag.mjs。 -8. 默认态按角色独立释放。某角色默认图成功且用户锁定具体 artifactId 后,只解锁该角色后续状态;不等待其他角色。 -9. 场景和道具按各自 ready 状态独立入批。单个阻塞项不得阻止其他可执行项。 +圣经锁定后并行推进: -详细数据契约见 [references/project-schema.md](references/project-schema.md),子代理交接见 [references/subagent-contracts.md](references/subagent-contracts.md),创作原则见 [references/creative-charter.md](references/creative-charter.md),Prompt Agent 契约见 [references/prompt-authoring.md](references/prompt-authoring.md)。 +- 岗位二依据圣经、用户意图和视觉范式完成 `art-direction-brief.md`;只读 [references/creative-charter.md](references/creative-charter.md)。 +- 岗位三依据圣经和准入规则生成 canonical sidecar patch;不读美术总纲,不写 prompt。主代理运行 `merge_agent_patches.mjs`。 -## 主代理上下文纪律 +名册必须对每个候选和每个外观版本给出唯一处置;未生成的版本进入 `downgraded` 并说明理由。总纲确认后运行: -主代理是调度者,不是执行者。违反本节会让长剧本项目在生图完成前耗尽主上下文窗口。 +```bash +node "/scripts/record_art_direction_brief.mjs" "<项目目录>/project-state.json" +``` -- 全文理解(圣经)、canonical 资产编译、分层提示词撰写三个环节**必须派子代理执行**;主代理只下发任务、运行脚本、接收结论摘要。 -- 主代理不读取剧本原文,也不读取 image-batch、awb-results、task record 等大 JSON 全文——脚本输出已含所需摘要;需要个别字段时定向取值。 -- 圣经与创作方向锁定后按需引用条目,不整篇读入主上下文。 -- 对话式审查按角色或分组分批展示,不一次性倾倒全部资产的提示词。 -- 不为项目即兴编写前端页面或查看器;给用户看进度和资产一律使用官方统一工作台(`workspace_server.mjs`)或 `download_artifacts.mjs` 下载到项目内的本地文件路径。 -- **不得即兴编写脚本写入项目目录或绕过审查/扣费闸门**(实跑中出现过主代理自建 approve-self-checked-prompts.mjs 之类的野生脚本)。批量放行、免确认等需求一律走官方脚本(`approve_prompt_batch.mjs`、`record_standing_authorizations.mjs`);官方脚本没覆盖的需求,报告用户而不是自己发明通道。 -- 任何结构化产物(canonical patch、审查 decisions、名册、超过约 50 行的 JSON/Markdown)必须用文件写入工具**整体写入**项目内路径;对话与终端只传路径、条目数和关键摘要。**禁止用 echo/heredoc 在终端拼接大文件**——终端摘要截断会产生半截 JSON,主代理和子代理同样适用。 +### 4. Prompt 与审查 -## 审查(对话式)+ 统一工作台 +创作方向锁定且名册合并后,岗位四按 [references/prompt-authoring.md](references/prompt-authoring.md) 编写中文 `promptBody` 并同步声明结构化 `textPolicy`。新项目最终 prompt 就是该正文;处理 v1/v2 或无文字策略标记的存量项目时才读 [references/legacy-profiles.md](references/legacy-profiles.md)。 -审查决定在 Agent 对话中收集,没有可编辑的审查界面。初始化时已经启动的统一工作台会在资产出现后自动切换为三栏浏览;恢复项目或服务已退出时重新运行同一入口: +先生成确定性工作清单: -~~~bash -node "/scripts/workspace_server.mjs" "<项目目录>" -~~~ +```bash +node "/scripts/plan_prompt_shards.mjs" "<项目目录>/project-state.json" --missing-only +``` -把终端输出的工作台 URL 发给用户。工作台在初始化阶段只允许写入 `setup-decisions.json`;设置固化后保持只读,提供五阶段状态、三栏浏览(资产列表 / 图片预览 / 详情检查器)、与正式建批编译逐字一致的最终提示词展示,以及故事资产圣经与项目创作方向的只读 markdown 渲染视图(`/doc.html`,草稿阶段即可预览),**无任何编辑、审批、锁定、生图或付费操作**。`bootstrap_review_package.mjs` 初始化 `review-decisions.json`(run_project 已自动执行)。 +shard 是审计单位,bundle 是 planner 给出的建议派发单位。保持同一角色全部外观在同一上下文,覆盖全部 shard;主代理可按复杂度和可用槽位自主调整 bundle 分配。**禁止按资产类型只开三个大任务**。执行前播报 planner 的建议并发与预期波数;完成后按 shardId 对账并回报实际波数、总耗时和偏差原因。 -主代理向用户展示待审内容并收集决定: +做新规则回归或收到 Prompt 质量反馈时,可运行只读审计;它只输出关系词、叙事词、逐视图重复、软预算、F02 取证、角色碰撞和文字策略的观测 JSON,不参与 readiness,也不能替代成图判断: -- 提示词:查看台只展示最终文本(与建批提交内容字节一致)。需要解释来源时在对话中说明:整段正文项目(v3)即单段正文;锚定式项目(v2)为"公共风格锚点 / 资产正文"两段;旧项目为"公共视觉语言 / 全局渲染语言 / 当前资产呈现 / 身份锚点 / 状态差量"五段。分段仅为展示,不写回状态。 -- 图片:查看台预览优先使用项目内 `localPath`(结果回收后由 `download_artifacts.mjs` 下载写回);对话中给本地文件路径,不要给带时效签名的远程 URL。 -- 用户的确认、修改意见或直接给出的新文本,由主代理转写为稀疏审查 patch 写入 `review-decisions.json`:`status`(approved / changes_requested)、`note`、`promptBody`(锚定式项目的正文修改)或 `identityPrompt` / `deltaPrompt`(旧项目局部修改)、默认态锁定(`lockedReference` + `artifactId`)。patch 的 `baseRevision` 必须等于该文件当前 `revision`。不提供、也不接受直接覆盖完整编译结果的整段替换。 +```bash +node "/scripts/audit_prompt_bodies.mjs" "<项目目录>/project-state.json" --art-direction-brief "art-direction-brief.md" +``` -写入后依次运行: +审查决定只写稀疏 `review-decisions.json`,然后统一应用: -~~~bash +```bash node "/scripts/apply_review_decisions.mjs" "<项目目录>/project-state.json" node "/scripts/validate_project_state.mjs" "<项目目录>/project-state.json" node "/scripts/plan_work_dag.mjs" "<项目目录>/project-state.json" -~~~ +``` -apply 之后立即向用户复述哪些资产被打回及其意见,作为下一轮提示词重写的输入;不要让 changes_requested 静默滞留。 +立即把 `changes_requested` 的资产和理由回流岗位四,不让打回意见静默滞留。用户授权“提示词不用逐条确认”后,用 `record_standing_authorizations.mjs --batch-prompt-approval on` 记录,再运行 `approve_prompt_batch.mjs`;带可读文字资产默认仍留人审,人工打回项永不覆盖。 -**提示词批量放行(需用户持久授权)**:用户明确表示"提示词不用逐条确认"后,运行 `node "/scripts/record_standing_authorizations.mjs" "<项目目录>/project-state.json" --batch-prompt-approval on` 记录授权;此后每轮审查可运行 `node "/scripts/approve_prompt_batch.mjs" "<项目目录>/project-state.json"`——质量自检通过的正文批量写入 approved 决定,**带可读文字的资产默认留人审**(文字内容错一字即报废),人工打回的 changes_requested 永不覆盖;随后照常 apply → validate → plan。向用户展示放行摘要并建议抽查主角资产。未授权时禁止任何形式的批量代批。 +### 5. 增量生成与收割 -## 增量生图 +只从 canonical ready 构建批次;默认态按角色独立释放,后续外观只等待本角色锁定的默认 artifact,场景和道具各自独立 ready。 -只从 canonical ready 构建批次;approved、submitted、succeeded 不得直接或重复入批: - -~~~bash +```bash node "/scripts/build_image_batch.mjs" "<项目目录>/project-state.json" --scope "character:<角色ID>" --output "<项目目录>/image-batch.json" -~~~ - -真实执行的接口知识以 [references/lj-awb-quickref.md](references/lj-awb-quickref.md) 为准(含适配版本号)——不要整篇阅读 lj-awb skill 文档;只有 `preflight.mjs` 报告的 lj-awb 版本与速查页不一致、或命令与速查页不符时,才回读 lj-awb 完整文档并更新速查页。执行前置的时序纪律: +``` -- **前移并行**:preflight、渠道/模型/项目组查询不依赖提示词内容,应在岗位四并发写正文期间由主代理并行完成,不要等提示词全部写完再串行开始。 -- **缓存**:渠道能力查询结果写入 `<项目目录>/workflow/awb-capabilities.json`(记录 fetchedAt 与 lj-awb 版本);同一项目 24 小时内且版本未变直接复用。估价与总积分永远全量重算并向用户展示。 -- **透明度**:该阶段每步向用户播报进行到哪一步(正在查询渠道能力 / 正在估价 / 正在校验第 x/N 项),不要让用户面对沉默的长等待。 +进入真实执行阶段才读取 [references/lj-awb-quickref.md](references/lj-awb-quickref.md) 和 [references/provider-policy.md](references/provider-policy.md)。preflight、渠道能力和项目组查询可与 Prompt 编写并行;能力缓存可复用,估价与总积分必须全量重算并展示。 -模型、参数、价格与项目组动态查询后,生成执行确认快照: - -~~~bash +```bash +node "/scripts/preflight.mjs" --bootstrap node "/scripts/prepare_awb_execution.mjs" "<项目目录>/image-batch.json" --model-group-code "" --project-group-no "" --estimated-cost "<积分>" -~~~ - -向用户展示模型、尺寸、数量、预计积分、失败项重试策略和 executionPlanHash。用户明确确认后才运行 record_execution_approval.mjs,再按 lj-awb 当前 dry-run / --yes 规则提交。 -**小额免确认(需用户持久授权)**:用户明确说出阈值(如"100 积分以下不用问我")后,运行 `record_standing_authorizations.mjs --auto-approve-below <积分>` 记录;此后预计消耗 ≤ 阈值的批次可直接 `record_execution_approval.mjs --auto --state --confirm-plan-hash ` 自动确认并提交——快照、估价与哈希仍必须完整播报留档。超过阈值、或项目未记录授权时,一律等待用户明确确认。详细边界见 [references/execution-modes.md](references/execution-modes.md) 与 [references/provider-policy.md](references/provider-policy.md)。 - -紧邻供应商调用前后使用 update_execution_checkpoint.mjs 记录 prepared → submitting → submitted;调用中断且没有 taskId 时转 submission_unknown。 +``` -将 AWB 结果归一化后合并,并**立即下载图片到本地**——供应商返回的是带时效签名的 URL(约数日后失效),不下载等于丢结果: +确认当前 executionPlanHash 后才记录 approval 并提交。用户已设置 `--auto-approve-below <积分>` 时只在阈值内自动确认。供应商调用前后更新 checkpoint;回收后立即本地化: -~~~bash +```bash node "/scripts/reconcile_awb_results.mjs" "<项目目录>/project-state.json" "<项目目录>/awb-results.json" --batch "<项目目录>/image-batch.json" node "/scripts/download_artifacts.mjs" "<项目目录>/project-state.json" node "/scripts/plan_work_dag.mjs" "<项目目录>/project-state.json" -~~~ +``` -图片落在项目 `assets/images/` 下并写回 `image.localPath`;向用户展示结果时给这些本地路径。 +向用户展示项目内 `localPath`,不要依赖带时效签名的远程 URL。 -## 断点恢复 +## 主代理上下文预算 -- job 的 inputHash 未变化时保留 checkpoint;变化时仅使该 job 失效。 -- 已有 taskId 只恢复轮询,不重新创建。 -- 提交状态为 submitting 且没有 taskId 时标记 submission_unknown,暂停并人工核对;不得自动重提。 -- 失败只重试失败项;成功 artifact 不覆盖、不删除。 -- prompt、风格、参考 artifact、模型或批次范围变化后,旧确认自动失效。故事资产圣经锁定后不得在原项目内修改;重大剧情理解修订须新建或派生项目。 +- 把全文理解、canonical 名册编制和批量 Prompt 写作交给隔离上下文;主代理只调度、合并和报告。 +- 圣经与总纲锁定后只摘取相关条目,不整篇重复载入。 +- 不读取 image-batch、AWB 回执、task record 等大 JSON 全文;用脚本摘要或定向字段。 +- 结构化产物整体写入项目文件;终端和对话只传路径、数量、冲突与摘要,不用 stdout、echo 或 heredoc 拼大文件。 +- 不为项目即兴写查看器或 canonical 写入脚本;使用统一工作台和 skill 自带脚本。 -## 不变量 +## 维护本 skill -- project-state.json 是资产业务投影;不可变批次、AWB 原始回执和 task record 是独立审计事实。 -- 原剧本是剧情真相源;上游 Prompt B 是镜头真相源;本项目只维护资产运营状态。 -- 审查状态与供应商执行状态分轴保存,旧审查 patch 不能覆盖执行终态。 -- 后续角色状态必须直接引用本角色唯一默认态,并绑定用户锁定的确切 artifactId。 -- 真实提交、扣费、切换项目组和重试始终遵守当前 lj-awb 确认规则。 +修改运行行为前阅读 [CHANGELOG.md](CHANGELOG.md) 的当前行为速览和相关历史条目;保持真实项目只读。改后运行全量测试并记录行为、兼容性和验证。数据字段见 [references/project-schema.md](references/project-schema.md)。 diff --git a/assets/setup-app/style-presets.json b/assets/setup-app/style-presets.json index 44fb9af..be52da9 100644 --- a/assets/setup-app/style-presets.json +++ b/assets/setup-app/style-presets.json @@ -145,7 +145,7 @@ "id": "closeup-front-back", "name": "特写 + 正背面设定", "ratio": "4:3", - "prompt": "白色无缝背景的角色资产设定板,画面左侧为占主要视觉面积的正面胸像特写,表情平静、平视端庄;画面右侧为同一角色的正面全身站立图与回首背面全身图,排版紧密,人物比例与服装细节一致。", + "prompt": "白色无缝背景的角色资产设定板,画面左侧以占主要视觉面积的正面胸像特写清楚呈现面孔与发型;画面右侧完整呈现同一角色的正面全身图,以及背面全身图或三分之四背面全身图,确保人物比例与服装前后结构可审。体态事实优先;在不遮挡人物轮廓和服装前后结构的前提下,允许非对称重心、肩胯变化、自然手势或符合角色身份的姿态,不强制统一直立、放松或端庄站姿,同时保持面貌、发型和服装细节一致。", "image": "layout-examples/closeup-front-back.jpg", "requirements": [ { @@ -281,12 +281,6 @@ "label": "上半身肖像", "match": "上半身|半身" } - ], - "exclusions": [ - { - "label": "下半身内容(超出上半身肖像可见范围,会诱导模型拉远构图)", - "match": "鞋|靴|足部|脚踝|裤脚|裙摆" - } ] } ] diff --git a/assets/viewer-app/viewer.js b/assets/viewer-app/viewer.js index c509a4a..59ca55a 100644 --- a/assets/viewer-app/viewer.js +++ b/assets/viewer-app/viewer.js @@ -149,29 +149,6 @@ const fallbackPresentations = { prop: { prompt: "单一道具设定图,完整清晰呈现主体轮廓、结构、材质、尺度与关键识别细节", ratio: "4:3" } }; -const dedicatedDeliveryLayoutPatterns = [ - /(?:^|[^\d])\d+\s*[::]\s*\d+(?:[^\d]|$)/u, - /(?:角色|人物)(?:资产)?(?:设定板|设定图|设定卡|排版)/u, - /白底|纯白背景|白色(?:无缝|纯色|影棚)?背景|影棚背景/u, - /胸像|半身像|上半身肖像/u, - /三视图?|多视角|转面图|正侧背/u, - /正背面|正反面|正面(?:全身)?(?:图)?(?:与|和|及|、|\+|+|\/).*(?:背面|反面)|(?:背面|反面)(?:全身)?(?:图)?/u, - /全身站立|自然站姿|端正站姿/u, - /面部|五官|手部|发型|服装|配饰|背部设计|人物比例/u, - /并列展示|版式|排版/u, - /(?:画面)?左侧.*(?:画面)?右侧|(?:画面)?右侧.*(?:画面)?左侧/u -]; - -function sanitizeGlobalRenderLanguage(value) { - return uniqueSegments( - String(value ?? "") - .trim() - .replace(/\s+/g, " ") - .split(/[。!?;;,,\n]+/u) - .filter((segment) => segment && !dedicatedDeliveryLayoutPatterns.some((pattern) => pattern.test(segment))) - ).join(","); -} - function resolvedPromptProfile() { const profile = app.state.config?.promptProfile ?? {}; if (profile.version === 3) { @@ -193,7 +170,7 @@ function resolvedPromptProfile() { version: scoped ? 1 : null, styleAnchor: "", commonVisualLanguage: scoped ? profile.commonVisualLanguage ?? "" : profile.style ?? "", - globalRenderLanguage: sanitizeGlobalRenderLanguage(scoped ? profile.globalRenderLanguage : profile.render) + globalRenderLanguage: scoped ? profile.globalRenderLanguage ?? "" : profile.render ?? "" }; } @@ -688,7 +665,7 @@ function render() { async function loadState(conditional = false) { const headers = conditional && app.etag ? { "If-None-Match": app.etag } : {}; - const result = await requestJson("/api/project-state", { headers }); + const result = await requestJson("/api/view-state", { headers }); if (result.notModified) return false; app.state = result.payload; app.etag = result.response.headers.get("etag") ?? app.etag; diff --git a/references/creative-charter.md b/references/creative-charter.md index d987514..33320d2 100644 --- a/references/creative-charter.md +++ b/references/creative-charter.md @@ -1,28 +1,28 @@ # AI 美术总监创作宪法 -这是运行时的短原则,不是材料、服制、面孔或题材知识库。模型应使用自身的世界知识和创作能力;只有项目事实、用户确认和生产不变量限制其决定。 +使用模型自身的世界知识和创作能力。只让剧本事实、用户决定、生产不变量和本项目的叙事需要限制设计;不要把外部美术知识库复制成逐条命令。 ## 决策顺序 1. 剧本硬事实与用户明确修订。 -2. 用户确认的项目创作方向。 -3. 世界观与剧情功能。 -4. 角色关系、阶段与可读性。 +2. 用户确认的项目方向与视觉范式。 +3. 世界观、剧情功能和角色关系。 +4. 清晰度、辨识度与跨资产一致性。 5. 模型的自主美术推导。 ## 创作原则 -- 不把未证实的视觉设计伪装成剧情事实。事实层的别名、重复项按确定性处置规则落地(归并/拆分/类别/排除/矛盾告警)并附一行理由留痕;只有重大创作取向的分歧才升级给用户确认。 -- 用轮廓、比例、色彩、材质、空间和重复视觉锚点建立可见差异,不依赖细碎堆砌。 -- 让角色、场景和道具共同服务同一叙事世界;每个核心设计选择都应能解释其与人物或剧情的关系。 -- 默认资产优先稳定、清晰和可复用;状态资产才承载剧情阶段、情绪、动作或损耗。 -- 不根据名字、语言或模糊描述推断**个别角色**的族裔、文化背景或其他敏感身份。剧本未明确时,由 AI 美术总监在创作方向"人物群体呈现"章节给出项目级呈现决策并写明理由(随总纲整体确认):默认按**剧本文化语境的主流面孔**统一呈现(西式命名与西式世界观 → 欧美面貌为主;东南亚背景 → 东南亚面貌为主);**除非剧本明确写了多元设定,不得以"国际化都市/开放港城"等语境推演为由做多族裔均衡分布**——本 skill 服务的短剧面向明确目标市场,均衡分布不是商业默认。不把族裔编码为善恶或权力等级;用户明确指定时以用户为准。不允许把这个决定留空——留空等于交给生图模型的默认值。 -- 画面内出现的可读文字(招牌、标签、铭文、文件、菜单等)遵循创作方向"画面内文字语言"的项目级决策;要求可读文字的资产正文须写明语言与内容原文,装饰性文字默认纹样化处理、不构成可读文字。这个决定同样不允许留空——留空等于让生图模型把提示词语言画上画面。 -- 已确认的视觉锚点必须在后续状态中继承,除非剧情或用户明确要求改变。 +- 不把创作推断伪装成剧情事实。别名、重复项和指代冲突要确定性处置并留理由;只有会改变项目方向的分歧才升级给用户。 +- 用轮廓、比例、色彩、材质、空间和重复视觉锚点建立差异,不靠细碎堆砌。角色、场景和道具应属于同一叙事世界。 +- 默认资产优先稳定、清晰和复用;剧情阶段变化只进入对应外观版本或镜头,不污染默认锚点。 +- 项目题材和总情绪不是所有资产的固定前缀。媒介核只锁定渲染事实;温暖、压迫、荒凉、诡异等局部气质应随当前资产的叙事功能分流,不能用一句全局口号覆盖相反情境。 +- 辨识度服从人设:英雄型主角可使用独占轮廓、专属色或贯穿符号;普通人代入型主角保持大众形象,只用少量稳定特征定位。功能角色明确让位。 +- 核心角色的戏剧功能必须通过年龄层级、轮廓、眼神、妆发、姿态、服装与材质等可见选择被读出,但不自动把善恶绑定到固定面孔类型或生理丑化。高碰撞角色应先做轻量并排差异设计,不靠岗位四临时堆形容词补救。 +- 不根据名字或剧本语言推断个别角色的族裔。剧本未明确时,在“人物群体呈现”中作项目级决定:默认按剧本文化语境的主流面孔统一呈现;除非剧本明确写了多元设定,不把多族裔均衡分布当商业默认。不把族裔编码为善恶或权力等级。 +- 在“画面内文字语言”中决定可读文字策略。证据类文字写明语言和内容原文;装饰文字默认纹样化、不可读。不得把选择留给生图模型默认值。 -## 不可替代的生产边界 +## 生产边界 -- 不得违反已锁定的故事资产圣经。 -- 不得绕过用户对项目创作方向、提示词或付费批次的确认。 -- 不得用复杂背景、额外人物或无关可手持道具破坏被要求的隔离资产。 -- 不使用负向提示词参数通道、排除词清单或"避免……"式模型规避补丁。**内联否定描述词不在此列**:"无毛孔""无油光""非真人感""不带文字""不要露沟"等是正常且常常必要的风格描述(防风格漂移的钉子),允许并应当按需写入提示词。 +- 不违反已锁定的故事资产圣经、用户确认的项目方向和 `invariants.md`。 +- 不用复杂背景、额外人物或无关手持道具破坏隔离资产。 +- 不使用负向提示词参数、排除词清单或“避免……”式补丁;“无毛孔”“无油光”“非真人感”“不带文字”等内联否定描述是合法的正向风格钉子。 diff --git a/references/execution-modes.md b/references/execution-modes.md index 4498762..801a9e2 100644 --- a/references/execution-modes.md +++ b/references/execution-modes.md @@ -1,35 +1,41 @@ -# 执行、审查与确认 +# 授权、审查与执行状态 -## 模式 +## 授权模型 + +- 可逆动作直接执行并复述结果;选择不明确时请求用户决定,不替用户猜。 +- 锁定故事圣经、锁定创作方向、锁定默认参考图和真实扣费属于不可逆动作,必须有明确确认或已记录的持久授权。 +- 持久授权只覆盖记录的动作与阈值,可撤销;项目输入或 executionPlanHash 变化不会继承旧确认。 +- 高后果创作决定必须用成图后果句单独呈现,不能被笼统的总纲确认淹没。 + +## 审查模式 | mode | 行为 | | --- | --- | | manual | 只准备批次,每次提交单独确认。 | -| staged | 默认;角色默认态、角色后续态、场景/道具分阶段。 | -| continuous | 用户确认明确预算与批次范围后连续轮询和释放;仍不绕过 lj-awb 确认。 | +| staged | 默认;角色默认态、后续态、场景和道具按依赖逐步释放。 | +| continuous | 用户确认预算与范围后连续轮询和释放;仍遵守当前扣费授权。 | + +`reviewGate` 默认 streaming;只有用户明确要求才使用 global。assisted 允许 Agent 先做内部 QA,但不能替代用户对未授权付费范围的确认。 -reviewGate 默认 streaming。global 仅在用户明确要求全部资产一起审查时使用;assisted 允许 Agent 先做内部 QA,但最终付费范围仍由用户确认。 +用户授权提示词批量放行后,运行 `record_standing_authorizations.mjs --batch-prompt-approval on`。`approve_prompt_batch.mjs` 只批准通过当前机械校验的 v2/v3 正文;人工打回项不覆盖,带可读文字资产默认留人审。 -## 机器可验证确认 +用户明确给出小额阈值后,运行 `record_standing_authorizations.mjs --auto-approve-below <积分>`。只有预计积分不超过阈值且 executionPlanHash 匹配时,`record_execution_approval.mjs --auto` 才可记录自动确认。 -prepare_awb_execution.mjs 生成 executionPlanHash,覆盖: +## 机器可验证的执行确认 -- batchHash、全部 generationKey 和数量 -- modelGroupCode 与 projectGroupNo -- 画幅、质量和参考 artifact -- 预计积分 +`prepare_awb_execution.mjs` 生成 executionPlanHash,覆盖 batchHash、全部 generationKey、固定每项张数与总张数、模型、项目组、ratio、quality、参考 artifact、预计积分、并发、预期波数和重试策略。默认并发 4,预期波数为 `ceil(itemCount / concurrency)`;固定单张模型显式使用 `--fixed-generate-num 1`,需要失败项最多重试一次时显式使用 `--retry-policy failed-only-once`。 -向用户展示动态模型、真实参数、数量、预计积分、项目组和失败项重试策略。用户明确确认该范围后,用 record_execution_approval.mjs 写 approval。prompt、风格、参考图、模型、项目组、价格或范围变化都会改变 hash,必须重新确认。 +向用户展示真实模型、参数、数量、预计积分、项目组、并发、预期波数和失败重试策略。确认当前范围后才写 approval。prompt、风格、参考图、模型、项目组、价格、调度或范围任一变化都必须重新确认。`record_execution_approval.mjs` 默认消费计划内的 retryPolicy;不得在确认时换成不同策略。 -approval 只记录业务范围,不替代 lj-awb schema 的 safety。当前命令 supportsDryRun=true 时先 dry-run;requiresConfirmation=true 时得到用户确认后才加 --yes。 +业务 approval 不替代 lj-awb safety:支持 dry-run 时先 dry-run;命令要求 confirmation 时只有获得授权才加 `--yes`。 -## 恢复边界 +## 恢复状态 -- prepared:尚未调用供应商,可安全继续。 -- submitting:调用已开始但本地尚无可靠 taskId。 -- submission_unknown:进程在 submitting 窗口中断;禁止自动重提,先检查 AWB task record 或人工核对。 -- submitted/running:已有 taskId,只恢复查询。 -- failed:仅按已确认 retryPolicy 重试失败项。 -- succeeded:永不自动重提。 +- `prepared`:尚未调用供应商,可继续。 +- `submitting`:调用开始但本地没有可靠 taskId。 +- `submission_unknown`:在 submitting 窗口中断;先检查 task record 或人工核对,禁止自动重提。 +- `submitted` / `running`:已有 taskId,只恢复查询。 +- `failed`:只按已确认 retryPolicy 重试失败项。 +- `succeeded`:永不自动重提。 -当前 lj-awb 没有跨请求幂等键,因此不能承诺 exactly-once。正确目标是:可靠记录 taskId 时不重提;不确定是否已扣费时停止而不是猜测。 +lj-awb 没有跨请求幂等键,因此不承诺 exactly-once。可靠目标是:有 taskId 时不重提,不确定是否已扣费时停止而不是猜测。 diff --git a/references/image-consistency.md b/references/image-consistency.md deleted file mode 100644 index 7b73950..0000000 --- a/references/image-consistency.md +++ /dev/null @@ -1,22 +0,0 @@ -# 角色一致性与提示词降噪 - -## 身份与状态 - -每个角色只保存一次 identityPrompt:脸型、年龄感、肤色、体型、发型和不可变身份特征。每个状态只保存 deltaPrompt:服饰、发饰、姿势、情绪、污损和当前环境变化。剧情解释放 description,镜头目标放 visualBrief,不塞进供应商 prompt。 - -完整 prompt 固定按“公共 style → 公共 render → identityPrompt → deltaPrompt”编译。编译器去除空段和完全重复段。 - -只写希望模型呈现的内容,例如“自然中性空手站姿”;不写负向提示词、排除词清单或用于规避模型错误的补丁。若输出不符合预期,优先改善正向创作方向、资产设计或审查反馈,而不是叠加反向约束。 - -## 逐角色流式释放 - -同一角色的全部提示词可提前并行准备;实际生图只遵循该角色自己的依赖: - -1. 审查并生成默认态。 -2. 用户审查结果图片并锁定当前 artifactId。 -3. DAG 立即释放该角色已通过提示词审查的后续状态。 -4. 其他角色、场景或道具未完成时不形成全局屏障。 - -后续状态必须直接引用唯一默认态。批次保留 reference 的 artifactId 与全部可用定位信息,由 lj-awb 适配阶段优先使用 backendPath。更换默认图会改变下游 generationKey,旧批次和旧确认随即失效。 - -不要把某个偶然成功的后续状态升级为锚点。若用户明确更换锚点,创建新 referenceAnchor 版本并重建受影响批次。 diff --git a/references/invariants.md b/references/invariants.md new file mode 100644 index 0000000..433927f --- /dev/null +++ b/references/invariants.md @@ -0,0 +1,39 @@ +# 生产不变量 + +只把客观、不可协商的边界放在这里。创作取舍见 `creative-charter.md`,岗位分工与资产准入见 `subagent-contracts.md`,字段定义见 `project-schema.md`。 + +## 真相与写入 + +- 原剧本保存剧情事实;上游 Prompt B 保存镜头事实;`project-state.json` 保存当前资产运营投影。 +- 批次、AWB 原始回执和 task record 是不可变执行事实,不压回可覆盖的业务投影。 +- canonical 状态只经 skill 自带脚本写入。不得即兴创建旁路脚本绕过审查、锁定、扣费或稳定 ID。 +- 每次 canonical 变化递增 revision;sidecar patch 必须绑定 projectId、baseRevision 和输入 hash。 + +## 锁定与失效 + +- `setup-decisions.json` 先由工作台或主代理记录,再由 `apply_setup_decisions.mjs` 校验并写入 canonical 状态。 +- 故事资产圣经锁定后在原项目不可改。全局剧情理解错误时恢复锁定版本或新建/派生项目。 +- 项目创作方向绑定源剧本、setup 与故事圣经 hash;锁定后不得在原项目改写。 +- prompt、风格、呈现规格、参考 artifact、模型、项目组、价格或批次范围变化后,相关旧审查或执行确认按 hash 失效。 + +## Prompt 与审查 + +- 新项目 `promptProfile.version: 3`:最终 prompt 等于 `promptBody`,无隐藏追加;`textPolicyVersion: 1` 要求每个正文显式声明画面文字策略。 +- v2:最终 prompt 等于 `styleAnchor ⊕ promptBody`;v1:按历史五段式兼容编译。只在处理旧项目时读取 `legacy-profiles.md`。 +- 工作台展示、用户审查、批次保存与供应商提交必须使用同一份物化 prompt;审查后不得静默改写。 +- 审查轴与供应商执行轴分开保存。`approved` 不等于 `ready`;旧 review patch 不得覆盖 submitted 或 succeeded。 +- `changes_requested` 不得被批量放行覆盖。 + +## 资产与生成依赖 + +- 每名角色恰有一个默认态。后续外观版本只直接引用该默认态,不形成引用链。 +- 后续版本只有在默认态成功、具有有效图片定位且用户锁定确切 artifactId 后才可生成。 +- 角色默认态、角色后续态、场景和道具分别读取自己的呈现配置与比例,禁止跨类型继承。 +- 成功 artifact 追加保存,不覆盖历史结果;更换锚点后必须产生新的 generationKey。 + +## 执行与恢复 + +- 真实提交前必须有与当前 executionPlanHash 匹配的用户确认或持久授权。 +- 供应商调用前后原子记录 prepared → submitting → submitted。`submitting` 中断且无可靠 taskId 时转 `submission_unknown`,禁止自动重提。 +- 已有 taskId 只恢复查询;失败只重试明确失败项;成功项永不自动重提。 +- reconcile 后立即下载成功图片到项目内并写回 `localPath`;短期签名 URL 不是可持久交付。 diff --git a/references/legacy-profiles.md b/references/legacy-profiles.md new file mode 100644 index 0000000..4f8f47c --- /dev/null +++ b/references/legacy-profiles.md @@ -0,0 +1,39 @@ +# v1 / v2 与早期 v3 存量项目兼容 + +只在恢复旧项目或排查兼容行为时读取。新项目默认使用带 `textPolicyVersion: 1` 的 promptProfile v3,不需要本文件。 + +## 识别顺序 + +- 含“媒介与画质”的创作方向:v3,最终 prompt 等于 `promptBody`;2026-07-19 前创建的 v3 可能没有结构化文字策略标记,继续使用旧词表兼容校验,不主动迁移。 +- 含“公共风格锚点”的创作方向:v2,最终 prompt 等于 `styleAnchor ⊕ promptBody`。 +- 含“公共视觉语言 / 全局渲染语言”的创作方向:v1,按五段式编译。 +- 未声明 profile version 的更早项目:使用兼容解析器读取历史 `style` / `render` / `prompt` 字段,不主动迁移。 + +## v2 锚定式 + +- `config.promptProfile.version: 2`,`styleAnchor` 是唯一公共逐字段,最长 240 字。 +- `promptBody` 不重复锚点级媒介、流派和画质词;当前防漂移词表仍参与 readiness 与 validate。 +- 最终 prompt 经 `joinPromptSegments` 连接锚点和正文;呈现、identityPrompt、deltaPrompt 不再额外拼接。 +- `identityPrompt` / `deltaPrompt` 只作岗位四输入材料与审计,可为空。 +- source hash 覆盖 styleAnchor、presentation id / ratio / requirements 与 promptBody;变化后旧审查失效。 + +## v1 分层式 + +最终 prompt 顺序为: + +1. `commonVisualLanguage`; +2. `globalRenderLanguage`; +3. 当前资产类型的 presentation prompt; +4. 角色 `identityPrompt`; +5. 当前 `deltaPrompt` 或兼容 `prompt`。 + +`description`、`visualBrief` 和 `sourceRefs` 不进入供应商 prompt。审查 patch 只修改已存在分层字段,不引入独立的最终 prompt 真相源。 + +## 只读兼容 + +- 历史 `promptProfile.style` 读作公共视觉语言;`render` 读作全局渲染语言。 +- 旧 render 中命中角色交付版式的分句只在编译和查看时过滤,不改写 archived canonical 状态。 +- 已有 characterDefault、但缺少 characterState / location / prop 的 setup-era 项目,运行时使用内置分域默认值。 +- 完全没有 assetPresentation 的更早项目,才回退到历史 `config.image.ratio`。 +- 需要持久化兼容结果时,只在备份或派生项目重跑相同 setup;不得改已锁定方向、成功 artifact 或参考锚点。 +- 兼容补齐引起新的 promptHash / generationKey 时,必须重新审查并确认执行。 diff --git a/references/lj-awb-quickref.md b/references/lj-awb-quickref.md index e29234b..18a2a7e 100644 --- a/references/lj-awb-quickref.md +++ b/references/lj-awb-quickref.md @@ -33,17 +33,37 @@ lj-awb model create-spec --model-group-code -f text lj-awb create image-fee --model-group-code --prompt "<样例prompt>" \ --ratio --quality --project-group-no -# 4. 批量提交(JSONL 每行只写差异字段;公共参数放命令行) +# 4. 从 canonical 批次确定性生成 JSONL 与逐行 mapping sidecar +node scripts/map_awb_image_batch.mjs ./image-batch.json \ + --fixed-generate-num 1 \ + --output ./awb-image-input.jsonl \ + --mapping ./awb-image-input.mapping.json + +node scripts/prepare_awb_execution.mjs ./image-batch.json \ + --model-group-code \ + --project-group-no \ + --estimated-cost <积分> \ + --fixed-generate-num 1 \ + --concurrency 4 \ + --expected-waves \ + --retry-policy failed-only-once + +# 5. 批量提交(只提交 mapper 产物;公共参数放命令行) lj-awb create image-batch \ - --input-file ./image.jsonl \ + --input-file ./awb-image-input.jsonl \ --model-group-code \ --project-group-no \ --concurrency 4 \ --yes -# 5. 轮询与收割 +# 6. 轮询与收割 lj-awb task wait --task-id --wait-seconds 600 -f text lj-awb task status --task-id -f text + +# 捕获全部终态状态后,纯本地归一化;脚本不联网、不轮询 +node scripts/normalize_awb_image_results.mjs \ + ./image-batch.json ./execution-plan.json ./awb-statuses.json \ + --output ./awb-results.json ``` JSONL 行格式(仅差异字段): @@ -52,7 +72,7 @@ JSONL 行格式(仅差异字段): {"prompt":"...","ratio":"16:9","quality":"2k"} ``` -允许字段:`prompt`、`ratio`、`quality`、`generate_num`、`resources`、`resource`、`model_params`。参考图生图时资源写 `{"type":"image","usage":"reference","source":{"kind":"url","value":"material/..."}}`。 +mapper 只输出当前批次实际需要且模型 options 暴露的 `prompt`、`ratio`、`quality`、`resources`、`model_params`;对于 GPT2 这类固定单张且不暴露 `generateNum` 的模型,用 `--fixed-generate-num 1` 只在 mapping sidecar 记录 `resolvedGenerateNum=1` 与 `quantitySource=model-fixed`,不向 AWB JSONL 发送未暴露参数。未知图片参数会拒绝,避免静默丢字段。参考图只接受 canonical reference 的稳定 `backendPath`,并映射为 `{"type":"image","usage":"reference","source":{"kind":"url","value":"material/..."}}`。 ## 硬规则(0.5.5,违反会扣错钱或被后端拒) diff --git a/references/project-schema.md b/references/project-schema.md index 87903eb..2515701 100644 --- a/references/project-schema.md +++ b/references/project-schema.md @@ -1,166 +1,130 @@ # 项目状态契约 v3 -## 真相边界 +本文件描述当前 canonical 数据和绑定关系;业务不变量见 `invariants.md`,v1/v2 兼容见 `legacy-profiles.md`。 -- 原剧本保存剧情事实。 -- 上游 Prompt B 保存镜头事实。 -- project-state.json 保存当前资产运营投影。 -- workflow/batches、AWB 原始回执和 task record 保存不可变执行事实;不要压回一个可覆盖文件。 +## 目录 -## 项目根节点 +- 根节点与配置 +- 工作流锁定 +- 资产记录 +- 审查与 readiness +- Prompt 物化 +- Patch 与名册 +- 批次、结果与 artifact +- 授权与执行计划 -~~~json +## 根节点与配置 + +```json { "schemaVersion": 3, "revision": 4, - "updatedAt": "2026-07-16T10:00:00.000Z", + "updatedAt": "ISO-8601", "project": { - "id": "project-abc123", - "title": "示例项目", + "id": "project-...", + "title": "项目名", "sourceScript": "原始剧本.md", "sourceHash": "sha256" }, - "config": { - "mode": "standalone", - "promptLanguage": "zh-CN", - "styleProfile": { - "id": "art-direction-abc123", - "name": "AI 美术总监创作方向:示例项目", - "source": "art-direction-brief.md", - "contentHash": "sha256", - "approvedAt": "2026-07-16T10:00:00.000Z" - }, - "promptProfile": { - "version": 1, - "commonVisualLanguage": "公共画风与统一视觉语言", - "globalRenderLanguage": "所有资产共享的材质、光影、清晰度与一致性语言" - }, - "visualPreset": { - "id": "cg-stylized-3d", - "name": "柔和三维动画", - "family": "CG", - "stylePromptSeed": "高品质三维动画电影质感,富有亲和力的角色塑形,柔和电影光影,细腻材质与明快叙事色彩", - "manifestVersion": 1 - }, - "assetPresentation": { - "version": 1, - "characterDefault": { - "id": "closeup-front-back", - "name": "特写 + 正背面三视图", - "ratio": "4:3", - "prompt": "白色影棚背景的角色资产排版:左侧胸像特写,右侧全身正面和背面站立图,画面紧凑清晰" - }, - "characterState": { - "id": "single-character-state", - "name": "单一角色状态图", - "ratio": "16:9", - "prompt": "单一角色状态资产图,完整呈现当前造型、服装、发型、妆容、姿态与随身物变化,主体清晰且便于复用" - }, - "location": { - "id": "environment-wide", - "name": "宽幅场景设定图", - "ratio": "16:9", - "prompt": "单一场景环境设定图,宽幅构图,清晰呈现空间布局、建筑结构、材质、光线与可复用环境细节" - }, - "prop": { - "id": "single-prop", - "name": "单一道具设定图", - "ratio": "4:3", - "prompt": "单一道具设定图,完整清晰呈现主体轮廓、结构、材质、尺度与关键识别细节" - } - }, - "image": { "quality": "2k" }, - "execution": { - "mode": "staged", - "reviewGate": "streaming", - "confirmation": "batch", - "concurrency": 4 - } - }, - "workflow": { - "appliedOps": [], - "setup": { - "path": "setup-decisions.json", - "contentHash": "sha256", - "status": "complete", - "manifestVersion": 1, - "updatedAt": "2026-07-17T09:00:00.000Z", - "completedAt": "2026-07-17T09:00:00.000Z" - }, - "storyBible": { - "path": "story-asset-bible.md", - "sourceHash": "sha256", - "contentHash": "sha256", - "status": "complete", - "updatedAt": "2026-07-16T10:00:00.000Z", - "lockedAt": "2026-07-16T10:00:00.000Z" - }, - "artDirection": { - "path": "art-direction-brief.md", - "sourceHash": "sha256", - "storyBibleHash": "sha256", - "contentHash": "sha256", - "status": "complete", - "updatedAt": "2026-07-17T10:00:00.000Z", - "lockedAt": "2026-07-17T10:00:00.000Z" - } - }, + "config": {}, + "workflow": {}, "assets": { "characters": [], "locations": [], "props": [] } } -~~~ - -schemaVersion 必须精确等于 3。呈现分域是 v3 内的兼容扩展,以 `promptProfile.version: 1` 和 `assetPresentation.version: 1` 判别;每次 canonical 数据变化递增 revision。styleProfile、promptProfile、资产呈现配置、项目创作方向、源剧本或批次范围变化后,旧执行确认按 hash 失效。 +``` -## 故事资产圣经 +`schemaVersion` 必须精确等于 3。每次 canonical 变化递增 revision。 -## 项目初始化选择 +新项目 `config` 的关键字段: -新项目先进入 `workflow.setup: draft`。`workspace_server.mjs` 提供仅本机监听的统一工作台,初始化 Dialog 是它的首次浮层。用户单选视觉范式和默认角色资产构图,选择只写入项目内 `setup-decisions.json`;`apply_setup_decisions.mjs` 再将经过 manifest 校验的选择写入 `config.visualPreset`、`config.assetPresentation.characterDefault` 与 `workflow.setup.contentHash`,同时固化角色状态、场景、道具的独立默认呈现配置。提交后工作台服务保持运行,通过 `project-state.json` 自动切换五阶段;选择不是聊天上下文,也不依赖浏览器状态,任一窗口接手都可从项目文件恢复。 - -预览资源位于 skill 内的 `assets/setup-app/`,仅用于用户理解差异,永不作为生成参考图上传。真正影响生图的是 `visualPreset.stylePromptSeed`(在确认创作方向时合并进公共视觉语言)和四类 `assetPresentation`。`characterDefault` 只作用于每个角色的 `sequence: 0`;`characterState` 只作用于 `sequence > 0`;`location` 与 `prop` 只作用于各自资产类型。每类最终比例只读自身 `ratio`,不得读取 `characterDefault.ratio` 或全局 `config.image.ratio`。选择“按剧本生成”时风格种子可以为空,由 AI 美术总监从剧本与创作方向自主决定视觉语言。 - -`config.image` 只保存跨类型共享的质量等生成参数。历史 `config.image.ratio` 仍可读取,但只作为缺少角色默认态呈现配置的旧项目兜底;新分域项目的批次 item 不从它取比例。 - -旧项目兼容采用只读适配,不提升顶层 schemaVersion:历史 `promptProfile.style` 读取为公共视觉语言;历史 `promptProfile.render` 按全局渲染语言读取,但其中命中角色交付版式的分句会在编译和查看时过滤,validator 给出警告。已存在 `characterDefault`、但缺少 `characterState` / `location` / `prop` 的 setup-era 项目,运行时分别使用内置分域默认值,不修改 `project-state.json`;更早、完全没有 `assetPresentation` 的项目继续使用历史 `config.image.ratio`,避免无关旧项目被强制改画幅。若希望把兼容结果持久化,可在备份或派生项目中用原 `setup-decisions.json` 重新运行 `apply_setup_decisions.mjs`;相同选择只补齐四类呈现配置,不改已锁定创作方向、成功 artifact 或参考锚点。任何因此产生的新 promptHash / generationKey 都必须重新审查和确认后才能提交。 - -项目创作方向锁定后,视觉范式和默认角色构图不允许在原项目更改;需要变化时派生项目重新选择。历史项目没有 `workflow.setup` 时保持兼容,不会因该字段缺失被阻塞。 - -`story-asset-bible.md` 是全文理解的自然语言产物,不是 JSON。它必须覆盖世界观与美术语境、剧情时间线、角色档案、场景与地点、道具与状态、别名/重复/待确认项;关键结论必须保留剧本或 chunk 的证据引用。 +```json +{ + "promptLanguage": "zh-CN", + "visualPreset": { + "id": "cg-stylized-3d", + "name": "柔和三维动画", + "positioning": "...", + "stylePromptSeed": "...", + "manifestVersion": 3 + }, + "styleProfile": { + "id": "art-direction-...", + "source": "art-direction-brief.md", + "contentHash": "sha256", + "approvedAt": "ISO-8601" + }, + "promptProfile": { "version": 3, "textPolicyVersion": 1 }, + "assetPresentation": { + "version": 1, + "characterDefault": { "id": "...", "ratio": "4:3", "prompt": "...", "requirements": [] }, + "characterState": { "id": "...", "ratio": "4:3", "prompt": "...", "requirements": [] }, + "location": { "id": "...", "ratio": "16:9", "prompt": "...", "requirements": [] }, + "prop": { "id": "...", "ratio": "4:3", "prompt": "...", "requirements": [] } + }, + "image": { "quality": "2k" }, + "authorizations": { + "batchPromptApproval": false, + "autoApproveBelow": null + } +} +``` -`bootstrap_story_bible.mjs` 生成模板,`record_story_bible.mjs` 在所有必填章节完成后将其 `contentHash`、`sourceHash`、`status: complete` 和 `lockedAt` 写入 `workflow.storyBible`。新流程中,未完成故事资产圣经的资产不能成为 ready。确认后的圣经是原项目不可变的全局理解快照:重复确认相同内容不会改变 revision;修改后不能在原项目继续建批,必须恢复锁定版本或新建/派生项目。 +四类 presentation 各自保存 prompt、ratio 和需求规格。新项目的最终比例只来自当前资产类型对应的 presentation;`config.image` 只承载跨类型质量等参数。 -故事资产圣经是全局身份真相的工作底稿:编译 Agent 基于它生成 canonical 资产 patch;切片 worker 只可核验、补充证据并报告疑似重复或新实体,不应在缺乏全局上下文时决定最终 ID。 +## 工作流锁定 -## 项目创作方向 +```json +{ + "setup": { + "path": "setup-decisions.json", + "contentHash": "sha256", + "status": "complete", + "manifestVersion": 3, + "completedAt": "ISO-8601" + }, + "storyBible": { + "path": "story-asset-bible.md", + "sourceHash": "sha256", + "contentHash": "sha256", + "status": "complete", + "lockedAt": "ISO-8601" + }, + "artDirection": { + "path": "art-direction-brief.md", + "sourceHash": "sha256", + "storyBibleHash": "sha256", + "setupHash": "sha256", + "contentHash": "sha256", + "status": "complete", + "lockedAt": "ISO-8601" + } +} +``` -`art-direction-brief.md` 是 AI 美术总监依据已锁定故事资产圣经提出、并由用户确认的项目视觉方向。它包含创作命题、用户确认方向、公共视觉语言、全局渲染语言、正向设计重点、跨资产设计原则和待确认项。 +相同内容重复 record 应幂等。锁定文件 hash 改变、源剧本改变或上游绑定不一致时,validate / build 必须停止。 -`record_art_direction_brief.mjs` 要求故事资产圣经和项目初始化选择均已锁定,将创作方向的 `contentHash`、故事资产圣经 hash、初始化选择 hash 与剧本 hash 固化到 `workflow.artDirection`,同时更新 `config.styleProfile`、`promptProfile.commonVisualLanguage` 与 `promptProfile.globalRenderLanguage`。两项必须以中文为主;全局渲染语言只允许所有资产都适用的材质、光影、清晰度和一致性描述,白底、胸像、半身像、多视图、正背面、角色站姿、左右排版和画幅比例会被拒绝。确认后不能在原项目内修改;若故事资产圣经或初始化选择改变,创作方向自动失效,必须派生项目重新确认。 +`workflow.roster` 保存候选处置;`workflow.promptShards` 或 `workflow/prompt-shards.json` 保存确定性工作清单。checkpoint、批次和供应商原始事实独立保存,不把全部事实塞回 workflow。 -运行时只使用 `creative-charter.md` 和 `prompt-authoring.md` 的短原则,不将外部美术知识库原文视为强制提示词。模型可以自主推导材料、发型、配色和设计语言,但不得违反剧本事实、用户确认、生产不变量或明确待确认项。 +## 资产记录 -## 分层角色提示词 +角色拥有稳定 identity 和外观版本: -~~~json +```json { "id": "character-su-wan", "name": "苏婉", - "description": "剧情身份说明", - "identityPrompt": "二十岁女性,鹅蛋脸,黑色长发,清瘦体型", - "sourceRefs": ["chunk-001:12-34"], + "description": "剧情身份说明,不进入 prompt", + "identityPrompt": "岗位四输入材料", + "sourceRefs": ["12-3"], "states": [ { "id": "character-su-wan-default", - "name": "默认立绘", + "name": "默认外观", "sequence": 0, - "visualBrief": "正面三分之二身,平静警惕", - "deltaPrompt": "基础常服,自然中性站姿", - "review": { - "promptStatus": "approved", - "imageStatus": "needs_review", - "note": "" - }, - "status": "ready", + "promptBody": "v3 最终正文", + "textPolicy": { "mode": "none" }, + "deltaPrompt": "差量审计材料", + "review": { "promptStatus": "needs_review", "imageStatus": "needs_review", "note": "" }, + "status": "needs_review", "referenceStateId": null, "referenceAnchor": null, "execution": null, @@ -169,140 +133,94 @@ schemaVersion 必须精确等于 3。呈现分域是 v3 内的兼容扩展,以 } ] } -~~~ +``` -每名角色恰好一个 sequence: 0。后续状态直接引用该默认态,不得形成链式引用。identityPrompt 只存稳定可见身份;deltaPrompt 只存当前状态变化。默认 `promptLanguage: zh-CN` 时,两者均以中文为主。完整 prompt 由 build_image_batch.mjs 按固定顺序编译,不回写为重复长文本。 +每名角色恰好一个 `sequence: 0`。后续 state 的 `referenceStateId` 直接指向默认态。location 和 prop 使用同类资产字段,但没有 character identity 与 referenceStateId。 -向用户展示时,"角色描述"读取 `character.description`,用于帮助用户理解剧情身份,不参与模型输入。"最终生图提示词"按固定顺序编译:公共视觉语言 → 全局渲染语言 → 当前资产类型的呈现 prompt → 角色身份锚点(仅角色)→ 当前差量 → 兼容 `prompt`。分段仅用于展示;只有已存在的分层字段可通过审查 patch 局部修改,不得引入独立的最终 prompt 真相源。 +`description`、`visualBrief`、`sourceRefs` 是解释和证据,不直接进入供应商 prompt。 -## 独立审查轴与执行轴 +新建 v3 项目带 `textPolicyVersion: 1`,每个已有正文的资产必须声明: -- review.promptStatus:needs_review、changes_requested、approved。 -- review.imageStatus:needs_review、changes_requested、approved。 -- execution.status:submitted、succeeded、failed;provider checkpoint 还可使用 prepared、submitting、submission_unknown。 -- status 是兼容显示投影:review approved 且 readiness 通过时为 ready;执行后映射 submitted、succeeded 或 failed。 +- `{ "mode": "none" }`:画面不含文字; +- `{ "mode": "decorative" }`:正文明确文字纹样化或不可读; +- `{ "mode": "readable", "language": "英文", "content": ["Hawthorn"] }`:正文同时逐字包含语言与每条原文。 -readiness 必须实时检查: +未带版本标记的存量 v3 项目继续兼容旧校验。本阶段保留 G03 画面文字词表,与结构化策略并行;只有结构化链路经真实项目实测后,才单独评估拆除旧门禁。 -1. 新项目的初始化选择已完成、已锁定。 -2. 故事资产圣经已完成、已锁定,且文件内容匹配记录的 contentHash。 -3. 项目创作方向已完成、已锁定,并绑定当前故事资产圣经和初始化选择。 -4. 风格、质量和当前资产类型的独立呈现配置存在;最终比例来自该呈现配置。 -5. 分层 prompt 可编译且 promptStatus=approved。 -6. 后续角色状态直接引用本角色默认态。 -7. 默认态 execution.status=succeeded、图片有有效定位、用户锁定确切 artifactId。 +## 审查与 readiness -approved 不是 ready。旧 review patch 不能覆盖 submitted 或 succeeded。 +三条轴分开保存: -## Artifact 与锚点 +- `review.promptStatus`: needs_review / changes_requested / approved +- `review.imageStatus`: needs_review / changes_requested / approved +- `execution.status`: submitted / succeeded / failed;provider checkpoint 另有 prepared / submitting / submission_unknown -成功结果追加为不可变 artifact: +`status` 是显示投影,不是另一个真相源。readiness 实时检查: -~~~json -{ - "artifactId": "artifact-abc123", - "attemptId": "attempt-001", - "taskId": "awb-task-id", - "generationKey": "sha256", - "backendPath": "material/path", - "url": "https://...", - "localPath": null, - "createdAt": "2026-07-16T10:00:00.000Z" -} -~~~ +1. setup、故事圣经和创作方向均完成且 hash 有效; +2. style、质量和当前资产 presentation 存在; +3. Prompt 可编译并通过当前 profile 的正文校验; +4. `review.promptStatus` 为 approved; +5. 后续角色版本直接引用默认态; +6. 默认态已 succeeded、有有效图片定位并锁定确切 artifactId。 -image 指向当前 active artifact;artifacts 保留历史。默认态锁图时写: +`approved` 不等于 `ready`,submitted / succeeded 不得被旧 patch 降回审查态。 -~~~json -{ - "referenceAnchor": { - "artifactId": "artifact-abc123", - "lockedAt": "2026-07-16T10:00:00.000Z", - "sourceStateRevision": 4 - } -} -~~~ +## Prompt 物化 + +- v3:`compilePrompt()` 输出 `promptBody` 本身。 +- v2 / v1:按 `legacy-profiles.md` 兼容。 +- `compilePrompt()` 同时返回 text、segments、hash 和 sourceHash;工作台与 `build_image_batch.mjs` 必须使用相同解析器或等价测试保护的结果。 +- sourceHash 至少覆盖当前 profile、当前 presentation、正文、结构化文字策略与角色引用。输入变化后旧审查显式失效。 -更换锚点后,下游 generationKey 必须变化;旧结果保留但不得覆盖新 active artifact。 +## Patch 与名册 -## 稀疏审查 patch +`review-decisions.json` 只保存发生变化的字段,并绑定当前 projectId、review revision 与 baseStateRevision。null 表示删除覆盖。`apply_review_decisions.mjs` 校验 ID、类型和状态后消费 decisions。 -review-decisions.json 只保存发生变化的字段: +canonical sidecar patch 的字段与边界见 `subagent-contracts.md`。名册对账项: -~~~json +```json { - "schemaVersion": 3, - "projectId": "project-abc123", - "revision": 3, - "baseStateRevision": 4, - "updatedAt": "2026-07-16T10:00:00.000Z", - "decisions": { - "character-su-wan-default": { - "deltaPrompt": "用户修订内容", - "status": "approved", - "updatedAt": "2026-07-16T10:00:00.000Z" - } - } + "candidateId": "C-01", + "name": "苏婉", + "disposition": "included", + "reason": "具名主角", + "assetIds": ["character-su-wan"] } -~~~ +``` -主代理代写 patch 时 baseRevision 必须等于该文件当前 review revision,防止覆盖并发修改。null 表示删除该覆盖字段。apply_review_decisions.mjs 校验 projectId、ID、类型和状态后消费 patch,并清空 decisions。 +候选编号必须唯一,理由必填,并与故事圣经中的 C/L/P 编号 100% 对账。 -## 批次与结果绑定 +## 批次、结果与 artifact -批次项保存 promptHash、promptSourceHash、generationKey、`presentation: { scope, id }`、结构化 reference 和 sourceStateRevision。generationKey 至少覆盖: +批次项保存 itemId、promptHash、promptSourceHash、generationKey、presentation scope / id、图片参数、reference artifactId 和 sourceStateRevision。generationKey 至少覆盖: -- projectId 与 itemId -- 编译后的 promptHash -- styleHash -- 画幅与质量 -- reference artifactId +- projectId、itemId、最终 promptHash; +- 风格与 presentation; +- ratio、quality; +- reference artifactId。 -供应商执行计划再加入模型、项目组和实时参数形成 executionKey。结果至少匹配 projectId、batchId、itemId、generationKey、attemptId、taskId 和 promptHash;sourceStateRevision 只作审计,不代替逐项 hash。 +执行计划再加入模型、项目组、实时参数、估价和批次范围形成 executionPlanHash。 -AWB 归一化结果: +归一化 AWB 结果必须匹配 projectId、batchId、itemId、generationKey、attemptId、taskId 和 promptHash。成功结果追加为不可变 artifact: -~~~json +```json { - "projectId": "project-abc123", - "batchId": "batch-...", - "sourceStateRevision": 4, - "items": [ - { - "id": "character-su-wan-default", - "status": "succeeded", - "attemptId": "attempt-001", - "taskId": "awb-task-id", - "promptHash": "sha256", - "generationKey": "sha256", - "image": { "url": "https://...", "backendPath": "material/path" } - } - ] + "artifactId": "artifact-...", + "attemptId": "attempt-...", + "taskId": "awb-task-id", + "generationKey": "sha256", + "backendPath": "material/...", + "url": "https://...", + "localPath": "assets/images/...", + "createdAt": "ISO-8601" } -~~~ +``` -## 锚定式提示词(promptProfile v2,2026-07-18) +`image` 指向当前 active artifact;`artifacts` 保留历史。默认态锁定时 `referenceAnchor.artifactId` 必须等于当前 image.artifactId。 -新项目由 `record_art_direction_brief.mjs` 从含"公共风格锚点"章节的美术总纲写入: +## 授权与执行计划 -~~~json -{ - "config": { - "promptProfile": { "version": 2, "styleAnchor": "……(≤240 字,唯一逐字拼接段)" }, - "assetPresentation": { - "characterDefault": { - "id": "closeup-front-back", "ratio": "4:3", - "prompt": "……(v1 编译用;v2 下仅作参考文案)", - "requirements": [ { "label": "正面全身图", "match": "正面全身" } ] - } - } - } -} -~~~ - -- 资产新增 `promptBody`:岗位四编写的整段正文。v2 编译输出 = `styleAnchor` ⊕ `promptBody`(`joinPromptSegments` 去尾句号后以"。"连接),呈现/身份/差量字段不再拼接。 -- `requirements` 是呈现需求规格:readiness 逐项以正则做语义存在校验;缺项或正文含锚点级风格词(`findAnchorStyleTerms`)都会阻止 ready。 -- `identityPrompt` / `deltaPrompt` 在 v2 中退为岗位四的输入材料与审计字段(差量白名单:服装、装备体系、永久身体变化);validate 允许为空。 -- 审查 patch 新增 `promptBody` 字段;merge 的 update 白名单同步放行。 -- `compilePrompt` 的 sourceHash 含 `{styleAnchor, presentation(id/ratio/requirements), promptBody}`——锚点或规格变化会改变 promptHash,经既有机制显式失效旧确认。 -- patch 顶层可选 `roster` 数组:`{candidateId(C/L/P-编号), name, disposition(included|merged|excluded|category|split), reason, assetIds}`。merge 校验编号唯一、理由必填,并与圣经中出现的候选编号做 100% 对账(缺一或多一都拒绝合并),通过后写入 `workflow.roster`,工作台 `/roster.html` 展示对照表。 +- `batchPromptApproval: true` 只授权官方批量 prompt 放行;不覆盖 changes_requested,带可读文字资产默认留人审。 +- `autoApproveBelow` 只授权预计积分不超过阈值且 hash 完整匹配的批次;不省略估价、快照或日志。 +- approval 记录业务范围,不替代 lj-awb 自身的 dry-run / confirmation safety。 diff --git a/references/prompt-authoring.md b/references/prompt-authoring.md index d04eb6a..8d35a9c 100644 --- a/references/prompt-authoring.md +++ b/references/prompt-authoring.md @@ -1,73 +1,91 @@ -# Prompt Agent(岗位四):从创作方向到资产提示词 +# Prompt Agent:资产正文契约 -岗位四是 AI 美术总监的执行阶段,而不是规则填空器。运行时仅阅读:故事资产圣经、已锁定的项目创作方向(含媒介与画质、人物群体呈现、画面内文字语言、分类型美术语言)、正式名册条目、相关原文证据和本文件;不要加载外部美术知识库原文作为逐条命令。默认以中文写提示词,专有名词可保留必要原文。 +岗位四把已锁定的故事事实、创作方向、正式名册和呈现规格转成中文 `promptBody`。它是美术总监的执行者,不是规则填表器;但必须让每条正文可执行、可审查、可复用且跨资产一致。 -## 整段正文(promptProfile v3,新项目默认) +## 输入与输出 -最终提示词 = **你写的整段正文(`promptBody`),一字不多一字不少**。系统不做任何逐字拼接或隐藏追加;媒介、画质、风格、身份、呈现方式全部由你写进正文。你对正文的完整质量负责。 +只读取当前任务卡中的原样媒介核、当前资产事实、相关核心角色差异矩阵行、当前资产的一句 localTone、呈现 requirements、可见范围、结构化文字策略和必要证据。不要回读完整剧本、圣经、项目状态、整份美术总纲或外部美术知识库。 -### 写作格式(经真实生图验证的稳定格式,必须遵守) +新项目使用 promptProfile v3:最终 prompt 等于你写的 `promptBody`,无任何隐藏拼接。v1/v2 存量项目才读取 `legacy-profiles.md`。 -正文是**给生图模型的短语铺陈流**,不是给人读的文章。按以下固定顺序组织,全文用逗号分隔的短语堆叠,不写散文长句: +## 不可协商的正文契约 -1. **媒介与画质词块**:原样使用总纲"媒介与画质"中本资产所属维度、**本资产类型**的词块(角色资产抄"××·角色"块,场景与道具抄"××·场景与道具"块)。不要改写、不要换措辞、不要混抄另一类型的块——这一块是跨资产风格一致的锚,里面的"无毛孔/非真人感"等否定式钉子是防漂移关键,不得删减。 -2. **主体一句**:身份用一个短句带过("外表约二十岁的中国古代修仙男性,东亚面貌"),随后铺陈可画的外观短语:脸型五官、发型发色、**头身比例与身高体型**(成年角色默认声明"写实七至八头身"或按定位声明夸张头身比——这是防止角色滑向 Q 版/幼态的关键钉子)、气质神态。 -3. **服装与识别物**:一次性完整铺陈本外观版本的服装结构、材质、佩饰与识别锚点,短语堆叠。**可见范围裁剪**:只写所选呈现范围内画面可见的部分——上半身肖像不写下装、鞋靴、裙摆、腿部(描述画外内容会诱导模型拉远构图破坏版式,命中呈现配置的排除项会被校验拒绝);全身构图才写完整服装体系。圣经记录的整套服装事实不因此丢失,被裁剪的部分留给全身构图的资产或镜头级提示词。 -4. **版式收尾模板**:一次性说清构图(例:"纯白背景,干净商业角色资产设定板排版,左侧大尺寸正面胸像特写,右侧正面全身图与回首背面全身图并列,人物比例、面貌、发型与服装结构一致"),语义覆盖本资产呈现需求规格(`assetPresentation..requirements`)的每一项,措辞服从角色事实。结尾给文字策略:默认"不带文字";需要画面出现可读文字时**逐个点名**(旗面为中文"招徒")。 +- **F02 CONTRACT 必须逐字一致**:正文开头原样使用总纲中“所属维度 × 本资产类型”的完整媒介与画质词块,不改写、不删钉子、不混抄另一类型。这是跨资产风格一致的文字锚。localTone 只能补充当前资产的可见气质,不能改写或替代媒介核。 +- 服从圣经事实和已确认方向。基础身份事实明确的照实写;“未明确”项按项目级“人物群体呈现”决定,缺少决定时回报主代理,不自行发明。 +- 语义覆盖当前呈现 requirements;体态事实优先于通用姿态。比例数字可以省略,写出时必须与配置 ratio 完全一致。 +- 只描述所选构图可见范围;上半身资产不展开画外下装、鞋靴和裙摆。这里由 Agent 的语义自检负责,不用汉语词表猜测越界。 +- 角色资产默认空手,以服装、材质和气质表达身份。只有圣经记录为固定装备体系的佩挂物可以写为佩挂,仍不改成手持。 +- 每个资产随正文输出结构化 `textPolicy`:`none` 表示不含画面文字;`decorative` 表示只含纹样化不可读文字;`readable` 必须填写 `language` 与逐条 `content` 原文。可读文字仍须在正文中逐个点名语言与内容原文;装饰文字明确纹样化不可读。不使用“所有信息性文字均……”一类范围模糊的全称句。 +- 后续外观版本的 `deltaPrompt` 只记录服装、装备体系、永久身体变化和媒介形态变化;表情、姿势、临时伤损、血迹、环境和光效属于镜头层。 +- 不使用负向提示词参数或“避免……”补丁;必要的内联否定风格钉子合法。 -**角色资产默认空手(硬规则)**:双手自然下垂或轻握,不携带包、笔记本电脑、话筒、文件、武器等任何手持道具——职业与身份用服装、材质与气质表达,剧情识别物走独立道具资产。仅当圣经把佩挂记为该外观版本的装备体系事实(如腰侧佩剑、肩背行囊)时按事实写**佩挂**,仍不写手持。 +## 质量 rubric -**只写画得出来的东西(硬规则)**:身份头衔、人物关系、剧情来历、设计意图一律不写字面——"师尊""系统宿主""律宏之妹""仙根被夺后获救""视觉层级服从功能角色""承载反套路故事"这类表述全部禁止;它们只在决定可见外观时转译为视觉短语(金丹修士 → 服饰规制与气质,不写"金丹修士"四个字)。 +正文可以自由组织,只对结果负责: -**其他格式禁令**: +- **事实忠实**:可见身份、体态和外观版本都有来源,不把推断冒充事实。 +- **风格一致**:原样媒介块完整保留;同一角色、同一阵营和同一世界的设计语言可追溯。 +- **视觉可执行**:每个短语都能转成画面,不把关系、来历、剧情结构或流程规则直接交给生图模型。 +- **信息经济**:身份、服装、构图和文字策略各说清一次,不用逐视图重复或范围模糊的全称句。 +- **规格完整**:requirements、体态适配、可见范围、比例和文字策略彼此不冲突。 +- **锚点价值**:默认外观清晰稳定,能作为后续版本的唯一视觉参考。 -- 构图只说一遍(第 4 步),禁止在各视图下重复服装结构;某视图确有独有信息才补一短语("背面呈现半束长发与后领结构")。 -- 画幅比例数字(16:9 等)可省略;写了就必须与本资产呈现配置的 ratio 完全一致,不一致会被校验拒绝。 -- 画面文字禁止全称句("所有信息性文字均使用……")——生图模型对范围限定不敏感;只逐个点名可读文字,其余默认落入"不带文字"。 +### 像素租金 -### 工作顺序 +每个短语都必须支付像素租金:删除它后,如果成图没有任何可判断的变化,就删除它,或把它翻译成当前可见状态。不要把事件过程、人物关系、性格标签或创作意图当作提示词信息。 -1. 从故事资产圣经读取事实:人物身份、外观版本、场景用途、道具识别点。**体态事实(轮椅、义肢、非人形体等)优先级高于任何构图惯例**——需求规格里的"全身图"由你按事实措辞(坐姿、盘踞、悬浮均可)。**基础身份事实(性别、年龄段、族裔或地域面貌)必须写进正文**:圣经明确的照实写;圣经标"未明确"的,按创作方向"人物群体呈现"章节的决策写;两处都没有时**不得自行发明**,回报主代理补决策后再写。 -2. 从创作方向"分类型美术语言"读取该资产类型与所属阵营的设计语言(色彩体系、材质世界、轮廓语言)——它是设计词汇的来源,不是照抄的文本。多方势力的剧本中,按资产所属阵营取用对应色彩与材质,不要把所有阵营的色域混写进一个资产。 -3. **画面内文字**:需要画面出现可读文字(招牌、标签、铭文、文件、菜单等)时,按创作方向"画面内文字语言"的决策**写明语言并给出内容原文**(例:标签为英文 "Daffodil / Hawthorn");不需要可读性时明确写"纹样化处理、不构成可读文字"。违反会被校验拒绝。 -4. 按"写作格式"四步组装正文,自检(见下)后确认,正文经 patch 写入 `promptBody`。批准前的文本永远只是候选。 +- “封印解除后双眼复明”应改为“双眼无遮蔽,金色虹膜清澈,视线准确聚焦”。 +- “她是某人的姐姐”应改为可见的年龄层级、妆发成熟度和姿态差异。 +- “性格恶毒、带绿茶气质”应改为精心维持的温柔表情、审视性目光、完整妆发和控制感姿态。 +- “经历长期囚禁”只有落实为消瘦、旧伤、磨损或凌乱等当前可见事实时才保留。 -### 正文边界(校验会拒绝违规) +### 软信息预算 -- 必备呈现要素缺一不可(语义匹配,非逐字);姿态词服从角色事实。 -- 画面内可读文字必须指定语言或声明不可读。 -- 比例数字与呈现配置不一致即拒绝。 -- 后续外观版本的正文仍配合"默认外观参考图 + 差量"生成:`deltaPrompt` 作为你的中间材料记录差量,**只允许**服装、装备体系、永久身体变化与媒介形态变化——表情、姿势、受伤、血迹、环境属于镜头级提示词,不进资产。 -- 不使用负向提示词参数通道、排除词清单或"避免……"补丁;但"无毛孔""无油光""非真人感""不带文字"等内联否定描述词是合法且常常必要的风格钉子,不要自我审查删除。 +以下只用于审查和压缩,不作字符数硬门禁: -### 物化与失效 +| 资产 | 可变正文建议预算(不含 F02 与 presentation) | +| --- | ---: | +| 角色 | 120–220 个汉字 | +| 场景 | 140–260 个汉字 | +| 道具 | 80–160 个汉字 | -你确认的正文落盘后,审查台展示的文本与建批提交的文本字节一致。呈现规格变化时,系统按来源指纹定位受影响资产、显式失效旧审查——不静默替换。 +超过预算时,只问新增文字是否增加了新的可见事实或解决真实歧义。关系、来历、同义重复和逐视图复述不构成超额理由。 -## 锚定式(promptProfile v2,存量项目兼容) +### 核心角色组对审 -最终提示词 = 公共风格锚点 ⊕ 你写的整段正文。锚点由系统逐字拼在开头,你不重复它;**不写锚点级风格词**(PBR、赛璐璐、实拍质感、渲染品质、电影感光影等媒介与画质词汇全部由锚点承载,正文出现即校验失败)。其余规则与 v3 相同(呈现需求规格、身份事实、差量白名单)。 +高碰撞核心角色不能只逐条自检。同一角色组完成后并排检查:年龄层级是否可读;最相似的两人是否共享脸型、发型和姿态;主角、反派和功能角色的视觉层级是否符合总纲;差异是否只靠换颜色。验收依据相关差异矩阵行,不新增反派、脸型或气质词表门禁。 -## 分层式(promptProfile v1,存量项目兼容) +## 推荐 recipe(非格式门禁) -旧项目继续按五段拼接编译:公共视觉语言 + 全局渲染语言 + 当前资产呈现 + `identityPrompt` + `deltaPrompt`。字段边界: +主美实测中,以下四段短语铺陈能有效抑制叙事概念、人物关系和逐视图重复。优先采用;若其他组织方式同样满足 rubric,不因格式不同打回: -- `promptProfile.commonVisualLanguage` / `globalRenderLanguage`:只读,所有资产共享。 -- `config.assetPresentation.`:四类各自提供 prompt 与 ratio,禁止跨域读取。 -- `identityPrompt`:角色跨外观版本不变的识别锚点(含体态事实);`deltaPrompt`:同差量白名单。 -- 局部修改通过审查 patch(review-decisions.json → apply_review_decisions.mjs),不接受整段覆盖编译结果。 +1. 原样媒介与画质词块。 +2. 主体身份和可见外观:面貌、发型、体型、头身比例、气质。 +3. 当前外观版本的服装、材质、佩饰和识别锚点,只写画面可见部分。 +4. 一次性说清背景、构图、视图一致性、requirements 和文字策略。 -## 通用 +不要把头衔、人物关系、剧情来历、设计意图和叙事概念直接写进正文;把它们转译成可见设计。构图只说一次,不在每个视图下重复整套服装结构。 -- `description`、`visualBrief`、`sourceRefs` 是解释与证据,不直接当作供应商 prompt。 -- 初始化 Dialog 中的示例图只供用户识别方向,绝不随 prompt、批次或 AWB 请求发送。 -- 向用户展示最终提示词时给完整文本;分段说明仅为展示,不写回状态。 +### 完整示例 -## 自检 +> 高品质国风三维动画电影质感,低细节哑光皮肤,无毛孔无油光,非真人感,哑光分束发丝,五官比例适度夸张,七至八头身,年轻东亚男性,清瘦挺拔,窄长脸,黑色高束长发,眉眼沉静克制,深色交领长袍,窄袖收腕,腰间固定佩挂一枚旧玉牌,衣缘以低对比暗纹形成稳定识别,双手自然放松且空手,纯白背景,干净商业角色资产设定板,左侧正面胸像特写,右侧正面全身图与回首背面全身图并列,面貌、发型、服装结构与人物比例一致,不带文字。 -- 该设计是否服从事实(尤其体态事实)和已确认方向,同时保留自主设计空间? -- 正文第一块是否原样使用了本维度、本资产类型的媒介与画质词块(含全部否定式钉子)?必备呈现要素是否全部覆盖?服装描述是否只写了所选呈现范围内可见的部分? -- 全文是否为短语铺陈流?有没有混进头衔、人物关系、剧情来历、设计意图、叙事概念这类画不出来的字面?构图是否只说了一遍? -- 基础身份事实是否已按圣经或"人物群体呈现"决策写入?画面内可读文字是否已逐个点名语言与原文,或默认"不带文字"? -- **辨识度**:把本角色和同阵营其他角色的正文并排读,能否按总纲"分类型美术语言·角色"**声明的辨识手段**认出主角?英雄型主角看独占轮廓/专属色/贯穿符号是否落进正文;普通人设主角看声明的贯穿特征(发型、常穿单品、体态)是否稳定复现——**不得给普通人设主角自行添加英雄式专属设计**。 -- 默认外观是否稳定、清晰、适合成为后续版本的参考锚点? +示例只展示组织方式,不提供项目事实或可复用美术答案;实际正文必须使用任务卡的原样媒介块、事实和呈现规格。 + +与正文一起返回,例如: + +```json +{ "promptBody": "……标签为英文 Daffodil / Hawthorn……", "textPolicy": { "mode": "readable", "language": "英文", "content": ["Daffodil / Hawthorn"] } } +``` + +## 交付前自检 + +- **事实**:有没有把关系、来历或推断伪装成事实?体态、年龄、面貌与外观版本是否有来源? +- **一致性**:开头媒介块是否逐字完整?与同角色其他版本、同阵营角色并排时,已声明的辨识手段是否稳定? +- **可执行性**:每个短语是否支付了像素租金?是否混入“师尊、宿主、某人之妹、逆袭故事、设计层级”等关系、来历、叙事或流程词? +- **降噪**:构图是否只出现一次?服装是否被逐视图重复?是否有无效全称句? +- **规格**:requirements 是否齐全、可见范围是否匹配、比例是否一致、文字语言和内容是否明确? +- **锚点价值**:默认外观是否清晰、稳定,适合作为后续版本的唯一参考? +- **组间差异**:高碰撞角色并排时是否满足相关矩阵,且至少有三项非颜色差异? + +确认后的正文经 review patch 写入;批准前始终是候选。工作台展示、批次和供应商提交必须使用同一物化文本。 diff --git a/references/provider-policy.md b/references/provider-policy.md index 0e7deff..5c854c8 100644 --- a/references/provider-policy.md +++ b/references/provider-policy.md @@ -1,30 +1,26 @@ -# lj-awb 供应商适配 +# lj-awb 供应商边界 -## 环境 +## 条件加载 -第一次在新电脑运行时执行 preflight.mjs --bootstrap。它优先使用 PATH 中的兼容 lj-awb;否则查找 lj-awb skill 自带 resolver。若仍无法解析 CLI,引导用户参照 [lj-awb CLI 官方安装说明](https://animeworkbench.lingjingai.cn/lj-awb-cli) 完成安装后重试,不猜测包名或下载路径。缺少 Node/npm、登录、项目组、网络或余额时,将 provider job 标记 blocked,保留已有项目与批次。 +先运行 `preflight.mjs --bootstrap`,再使用 `lj-awb-quickref.md` 的适配命令。只有 preflight 报告的版本与 quickref 不一致、命令不符或 quickref 未覆盖当前能力时,才读取 lj-awb 完整 skill 和精确 schema;不要每次执行前加载整套供应商文档。 -## 动态契约 +缺少 CLI、Node/npm、登录、项目组、网络或余额时,把 provider job 标记 blocked,保留已有项目、审查与批次。CLI 无法解析时只指向官方安装说明,不猜包名或下载路径。 -真实估价或提交前读取当前 lj-awb skill。非单条任务先读 schema --brief,再按需读取精确 command schema。动态查询模型、options、create-spec、fee 和 safety;不要固定旧模型名、价格、参数或 CLI 路径。 +## 动态数据 -本 skill 只产出供应商无关 image-batch.json 和执行确认快照,不复制 lj-awb 内部 API。将批次映射成当前 CLI 支持的 image-batch JSONL 时: +- 模型、options、create-spec、价格、项目组和 safety 永远动态查询,不固化在 comic-assets。 +- 能力查询可按项目、lj-awb 版本和 24 小时窗口缓存;估价和总积分每个执行计划都全量重算。 +- comic-assets 只维护供应商无关 `image-batch.json` 和执行快照,不复制 lj-awb 内部 API。 -- 保留 item ID、generationKey、promptHash 和行号映射。 -- 参考图保留 backendPath、url、localPath 结构,优先复用 backendPath。 -- 正式任务传 project-group-no。 -- 批量提交显式传独立 task-record-file。 -- 保存原始 dry-run、提交回执、taskId 和 statusCommand;不要只保存最终图片。 +## 映射与审计 -## 提交 checkpoint +- 使用 `scripts/map_awb_image_batch.mjs` 从 canonical `image-batch.json` 确定性生成 JSONL 与 mapping sidecar;sidecar 保留 item ID、行号、generationKey、promptHash、ratio、quality、固定张数来源、逐行 hash 和整份 JSONL hash。模型未暴露 `generateNum` 时只记录 `quantitySource=model-fixed`,不向供应商发送不存在的参数。公共参数放命令行,逐项差异留在行内。 +- 参考图优先使用 backendPath,保留 backendPath / url / localPath 结构;不要把短期签名 URL 当永久输入。 +- 正式任务显式传 project-group-no 和独立 task-record-file。 +- 保存 dry-run、原始提交回执、success / failure、每项 taskId、statusCommand 与 error,不只保存最终图片。 -1. 调用前写 prepared attempt。 -2. 紧邻调用前原子写 submitting。 -3. CLI 返回后立即保存原始回执与每项 taskId。 -4. 有 taskId 的项只查询,不重建。 -5. submitting 且无 taskId 的项转 submission_unknown,停止自动执行。 -6. 批量 best-effort:保存已创建 taskId,只有明确未创建的失败项可按确认策略重试。 +## Checkpoint 与结果 -## 结果归一化 +紧邻供应商调用写 prepared → submitting → submitted。批量是 best-effort:保存所有已创建 taskId,只有明确未创建的失败项可按已确认策略重试;无 taskId 且提交结果不确定时进入 submission_unknown。 -将当前 CLI 输出转成 project-schema.md 定义的 awb-results.json,再运行 reconcile_awb_results.mjs。结果必须绑定 projectId、batchId、generationKey、attemptId、taskId 和 promptHash;成功项必须包含至少一个有效图片定位。旧批次、旧 prompt 或错误 taskId 的结果不得覆盖当前 active artifact。 +把已捕获的终态状态快照交给 `scripts/normalize_awb_image_results.mjs`,与 canonical batch 和已确认 execution plan 按 taskId 硬绑定,生成 `project-schema.md` 定义的 AWB 结果后再运行 reconcile。结果必须绑定 projectId、batchId、itemId、generationKey、attemptId、taskId 和 promptHash;成功项逐字保留 `resultUrls[0]`(包括签名查询串)作为图片定位。旧批次、旧 prompt、非终态或错误 taskId 不得覆盖 active artifact。 diff --git a/references/subagent-contracts.md b/references/subagent-contracts.md index 75e91d9..02d8e07 100644 --- a/references/subagent-contracts.md +++ b/references/subagent-contracts.md @@ -1,80 +1,77 @@ -# 子代理与 checkpoint 契约 +# 岗位、准入与 sidecar 契约 -## 分工(四岗位) +主代理保留一个槽位并作为唯一 canonical 合并者。各岗位只对自己的产物负责,不越权补写其他层。 -主代理保留一个槽位并作为唯一 canonical 合并者。四个岗位的边界: +## 四岗位 -- **岗位一 · 剧本统筹与资产研究员**:完整提取剧情事实、角色身份、外观版本、场景、道具与证据,产出 `story-asset-bible.md`。每个具名角色必须记录基础身份事实(性别 / 年龄段 / 族裔或地域出身;只抄剧本明确写的,未写填"未明确",不得从名字或剧本语言推断)。角色只记录真正的外观版本,不记录情绪和临时剧情状态(定义见圣经模板)。剧本可以是任何语言与地域;专有名词保留原文。**隔离要求**:派发任务卡只包含原始剧本路径与事实提取规则,不携带画风选择、构图选择、模型参数或无关对话历史。 -- **岗位二 · AI 美术总监**:依据圣经与用户意图决定整个项目怎么设计、怎么渲染,产出 `art-direction-brief.md`,由用户确认。 -- **岗位三 · 名册编制(Compiler)**:依据圣经和资产准入规则整理正式资产名册,生成角色、场景、道具的 canonical sidecar patch,并为每个资产保留证据。**不写美术提示词、不做美术设计、不读取美术总纲**;输入只有:圣经(候选清单、别名、身份变化、证据位置)、用户要求的生成范围、资产准入规则。 -- **岗位四 · 提示词编写(Prompt Agent)**:读取短创作宪法、已锁定项目创作方向、正式名册条目和资产证据,自主用中文编写提示词;不加载外部美术知识库原文作为逐条命令,也不把初始化 Dialog 的示例图当成模型参考图。这是主要的高并发阶段,**派发必须以 `plan_prompt_shards.mjs` 生成的打包清单为准**:分片(每个角色一片含其全部外观版本、场景道具 ≤4 聚类)是审计与对账单位;包(bundle)才是派发单位——普通分片按资产数聚合(角色 ≤6、场景/道具 ≤10),多外观角色独占一包。每包派一个子代理,完成一包立即领取下一包,并发子代理数 = min(包数, 可用槽位);完成后按 shardId 对账,缺片补派。任务卡自包含(名册条目、圣经事实摘录、本类型媒介与画质词块、构图需求规格、文字策略),子代理不回读完整剧本/圣经/项目状态。**禁止按资产类型只开三个子代理,也禁止一片一派**。最后由统一一致性检查收尾。 -- Worker A / B / C:只在需要核验覆盖率时读取各自剧本分片,报告遗漏、别名、身份变化和冲突;复核跨分片重复项与低置信度实体。 +| 岗位 | 使命 | 输入 | 产物与边界 | +| --- | --- | --- | --- | +| 剧本统筹与资产研究员 | 建立全剧事实、实体和证据 | 原剧本路径、空白圣经、本文的事实规则 | `story-asset-bible.md`;不读画风/构图/模型,不输出 JSON 或 prompt | +| AI 美术总监 | 决定项目如何设计与渲染 | 锁定圣经、用户意图、视觉范式、`creative-charter.md` | `art-direction-brief.md`;不复制外部知识库,不改剧情事实 | +| 名册编制 | 把候选编译为正式资产台账 | 锁定圣经、生成范围、准入规则 | canonical sidecar patch;不读美术总纲,不写 prompt | +| Prompt Agent | 把事实与创作方向写成可生成正文 | 自包含任务卡、`prompt-authoring.md` | `promptBody` + `textPolicy` patch;不回读完整剧本/圣经/状态,不调用付费 create | -生图并发由 lj-awb 批次控制,不要一张图一个子代理。 +核验 worker 只在需要覆盖率检查时读取自己的剧本 chunk、圣经和当前台账,报告遗漏、别名、身份变化与冲突;不自行创建最终 ID。 -## 资产准入(两层闸门) +## 事实与外观版本 -**闸门一 · 是否入册**(决定角色/场景/道具存在与否,与出场次数无关): +- 每个具名角色记录性别、年龄段、族裔或地域出身;只抄剧本明确事实,未写填“未明确”,不从名字或语言推断。 +- 外观版本是需要独立参考图才能在多个镜头稳定复现的固定视觉形态:整套服装或装备体系变化、永久身体变化、贯穿复现的媒介形态变化。 +- 表情、姿势、动作、临时伤损、血迹污渍、战斗损耗、环境和光效不构成外观版本。 -- 角色:有正式姓名或稳定称呼、有台词或连续对话、承担剧情功能、与主要角色存在固定关系——任一命中即入册,并原则上生成一个默认外观资产。名册宁多勿漏:多录一行的代价是审查多看一眼,漏一个的代价是视频制作中途返工。 -- 无名功能性群体抽象为类别代表角色(一个类别一个代表性单人资产);成员固定的组合必须拆成独立角色。区分标准:成员之间有互动、各自有台词或称呼 = 固定组合必拆;可互换的背景群体 = 类别代表。 -- 场景:多次使用或承担多个镜头的地点入册;一闪而过的背景地点可排除。 +## 两层准入 -**闸门二 · 额外外观版本是否单独出图**(只管第二套及以后的造型,永远不决定角色存在性): +### 是否入册 -- 出现于 ≥2 个场次或跨集,或作为默认外观被后续版本引用 → 建独立 state。 -- 单场出现但贯穿该场主要段落(以该角色场内对话轮次、动作段数为可数依据)→ 建独立 state。 -- 用户点名要求高保真呈现 → 例外资产化,标注理由。 -- 仅一闪而过的闪回、插入镜头中的完整造型 → 降级,留给镜头级提示词处理。 -- 媒介/画风形态变化(如 Q 版形象、异维度固定形态)按同一闸门计:贯穿复现(≥2 场或跨集)才建独立 state 并注明所属媒介维度;单场一闪而过的 Q 版演出降级为镜头级。 -- 降级不是删除:岗位三在 patch 顶层 `downgraded` 数组回报被降级候选(名称、所属资产、出现集-场、降级原因)。merge 不消费该字段;主代理在审查阶段呈现,人工可单条捞回。 +- 角色有正式姓名或稳定称呼、有台词或连续对话、承担剧情功能、与主要角色有固定关系,任一命中即入册;角色存在性不由场次数决定。 +- 无名但可复用的功能性群体抽象为类别代表;成员有独立互动、台词或称呼的固定组合必须拆开。 +- 多次使用或承担多个镜头的场景入册;一闪而过的背景地点可排除。道具只保留确有视觉复用价值的实体。 -**名册对账(硬要求)**:圣经角色档案中的每个外观版本,要么成为独立 state,要么出现在 `downgraded` 里,二者必居其一;states 数 + downgraded 数应与圣经外观版本条目数对得上。只编译默认外观、其余版本既不建也不降级,视为编译不完整,主代理必须退回重编。圣经带候选编号时,名册须对每个候选编号给出唯一处置(纳入 / 合并 / 排除 / 类别 / 拆分)并附一行理由。 +### 额外外观是否单独出图 -准入取舍有误时重建 patch 即可,不得以准入为由改写已锁定的故事资产圣经。 +- 出现于至少两个场次、跨集,或作为默认外观被后续版本引用:建立 state。 +- 单场但贯穿主要段落,可用对话轮次或动作段证明复用:建立 state。 +- 用户点名要求高保真:例外资产化并写理由。 +- 只在插入镜头或闪回中一闪而过:降级到镜头层。 +- 媒介形态变化使用同一门槛;反复出现的 Q 版可建 state,单场演出降级。 -## 最小上下文 +降级不是删除。岗位三在 patch 顶层 `downgraded` 返回名称、所属资产、证据和理由,供用户捞回。 -岗位一只输出故事资产圣经,不输出 JSON 或供应商 prompt。`record_story_bible.mjs` 确认后,任何 Agent 都不得改写该文件;发现全局理解错误时报告并要求新建或派生项目。岗位二读取故事资产圣经、用户创作意图、`config.visualPreset` 和 `creative-charter.md`,先输出项目创作方向,由用户确认。岗位三读取已锁定圣经、当前项目状态和相关证据片段,输出 canonical 资产 patch;不读取美术总纲、画风描述、构图模板或生图参数。核验 worker 只读自己的 chunk、故事资产圣经、当前 canonical 资产台账和任务 scope;不读取完整剧本。 +## 对账 -核验 worker 的结果写 sidecar JSON,只回报路径、数量、冲突、置信度和证据。它不得以局部称呼直接创建最终角色 ID;疑似新实体、别名或身份变化必须回报为冲突或待确认项。 +- 圣经中每个外观版本必须且只能成为 state 或进入 `downgraded`。 +- 圣经含 C/L/P 候选编号时,名册为每个编号给出唯一 disposition:included、merged、excluded、category 或 split,并写理由和 assetIds。 +- 漏项、多项或只生成默认外观而静默丢弃其他版本,均退回重编;不得以准入取舍为由改写锁定圣经。 -**产物交付方式(所有岗位通用)**:patch、名册、审查文件等结构化产物必须用文件写入工具整体写到指定路径,最终回复只报路径 + 条目数 + 关键摘要;禁止把大 JSON 经终端 stdout/heredoc 回传——终端摘要截断会产生半截文件。 +## Prompt 调度 -## Patch +先运行 `plan_prompt_shards.mjs --missing-only`。shard 是审计和覆盖单位;bundle、建议并发和预期波数是 planner 提供的调度基线,不是唯一合法分配。保持同一角色全部版本在同一上下文,主代理可按复杂度和可用槽位调整派发;禁止按资产类型只开三个大任务。完成后按 shardId 对账,并回报实际波数、耗时与计划偏差。 -~~~json +任务卡必须自包含:名册条目、相关圣经事实、对应媒介与资产类型词块、相关核心角色差异矩阵行、当前资产的一句 localTone、呈现 requirements 与可见范围、文字策略。没有相关矩阵行时省略,不为功能角色强造差异;不要让 worker 回读大文件。 + +## Sidecar 与 checkpoint + +sidecar 至少包含: + +```json { "schemaVersion": 3, - "projectId": "project-abc123", + "projectId": "project-...", "runId": "run-...", - "jobId": "extract-chunk-001", - "opId": "extract-chunk-001-attempt-1", + "jobId": "job-...", + "opId": "job-...-attempt-1", "baseRevision": 0, "inputHash": "sha256", - "sourceChunks": ["workflow/chunks/chunk-001.json"], - "operations": [ - { - "op": "add", - "assetType": "character", - "value": { - "id": "character-su-wan", - "name": "苏婉", - "identityPrompt": "", - "states": [] - } - } - ], + "sourceChunks": [], + "operations": [], "conflicts": [], "downgraded": [], "confidence": 0.91 } -~~~ - -支持 add 和 update。add 的 assetType 为 character、location 或 prop;update 只改语义字段,不改 schema、revision、执行状态、artifact 或稳定 ID。主代理运行 merge_agent_patches.mjs;baseRevision、projectId 或 ID 冲突时先依据故事资产圣经归并别名,再重建 patch。 +``` -## Checkpoint +`operations` 支持 character / location / prop 的 add,以及受白名单约束的 update;不得修改 schema、revision、稳定 ID、执行状态或 artifact。冲突先依据圣经归并别名,再重建 patch。 -job checkpoint 保存 inputHash、completedItemIds、patchFile、attempts 和 error。pendingItemIds 由 scope 减 completedItemIds 推导,避免双写漂移。inputHash 未变化时恢复 checkpoint;变化时只重做受影响 job。 +job checkpoint 保存 inputHash、completedItemIds、patchFile、attempts 和 error;pending 由 scope 减 completed 推导。inputHash 不变时恢复,变化时只重做受影响 job。 -人工审查、缺失风格和付费确认是 gate,不伪装成 worker job。子代理不得调用付费 create、修改 project-state.json 或自行改变稳定 ID。 +所有结构化产物必须整体写入指定项目路径。最终只回报路径、条目数、冲突和摘要;禁止通过 stdout、echo 或 heredoc 传输大 JSON。 diff --git a/scripts/apply_review_decisions.mjs b/scripts/apply_review_decisions.mjs index f6de912..0324f21 100644 --- a/scripts/apply_review_decisions.mjs +++ b/scripts/apply_review_decisions.mjs @@ -71,6 +71,9 @@ for (const [id, decision] of Object.entries(document.decisions ?? {})) { for (const field of ["deltaPrompt", "prompt", "promptBody", "imageUrl", "note", "identityPrompt", "artifactId"]) { if (decision[field] != null && typeof decision[field] !== "string") errors.push(id + "." + field + " 必须为字符串"); } + if (decision.textPolicy != null && (!decision.textPolicy || typeof decision.textPolicy !== "object" || Array.isArray(decision.textPolicy))) { + errors.push(id + ".textPolicy 必须为对象"); + } if (decision.lockedReference != null && typeof decision.lockedReference !== "boolean") errors.push(id + ".lockedReference 必须为布尔值"); if (decision.lockedReference === true) { const artifactId = decision.artifactId || record.asset.image?.artifactId; @@ -96,6 +99,7 @@ for (const [id, decision] of Object.entries(document.decisions ?? {})) { if (decision.identityPrompt != null && record.character && asset.sequence === 0) record.character.identityPrompt = decision.identityPrompt.trim(); if (decision.deltaPrompt != null) asset.deltaPrompt = decision.deltaPrompt.trim(); if (decision.promptBody != null) asset.promptBody = decision.promptBody.trim(); + if (decision.textPolicy != null) asset.textPolicy = JSON.parse(JSON.stringify(decision.textPolicy)); if (decision.prompt != null) asset.prompt = decision.prompt.trim(); if (decision.imageUrl != null) asset.image = decision.imageUrl.trim() ? { ...(asset.image ?? {}), url: decision.imageUrl.trim() } : null; asset.review = { ...(asset.review ?? {}) }; diff --git a/scripts/apply_setup_decisions.mjs b/scripts/apply_setup_decisions.mjs index b68a2f1..f0521cd 100644 --- a/scripts/apply_setup_decisions.mjs +++ b/scripts/apply_setup_decisions.mjs @@ -112,8 +112,7 @@ const characterStateFromLayout = { prompt: layout.prompt.replace(/。\s*$/u, "") + ",呈现该角色当前外观版本的完整造型。", ...(Array.isArray(layout.requirements) ? { requirements: [...layout.requirements, { label: "当前外观版本", match: "外观版本|当前造型|造型|服装|装束" }] } - : {}), - ...(Array.isArray(layout.exclusions) ? { exclusions: layout.exclusions } : {}) + : {}) }; state.config.assetPresentation = { version: assetPresentationVersion, @@ -127,8 +126,7 @@ state.config.assetPresentation = { name: layout.name, ratio: layout.ratio, prompt: layout.prompt, - ...(Array.isArray(layout.requirements) ? { requirements: layout.requirements } : {}), - ...(Array.isArray(layout.exclusions) ? { exclusions: layout.exclusions } : {}) + ...(Array.isArray(layout.requirements) ? { requirements: layout.requirements } : {}) }, ...(presentationOnlyUpgrade || grandfathered ? {} : { characterState: characterStateFromLayout }) }; diff --git a/scripts/approve_prompt_batch.mjs b/scripts/approve_prompt_batch.mjs index 8ce7256..a32d9ca 100644 --- a/scripts/approve_prompt_batch.mjs +++ b/scripts/approve_prompt_batch.mjs @@ -5,17 +5,14 @@ // changes_requested(人工打回)永不覆盖。写入后仍走 apply_review_decisions.mjs 统一应用。 import path from "node:path"; import { - assetPresentation, - findAnchorStyleTerms, - findMismatchedRatioTerms, - findUnspecifiedImageTextTerms, - isPrimarilyChinese, + hasReadableImageText, listAssets, - missingPresentationRequirements, + promptBodyFindings, promptProfileAnchorVersion, promptProfileBodyVersion, readJson, resolvedPromptProfile, + requiresStructuredTextPolicy, reviewStatus, schemaVersion, writeJsonAtomic @@ -56,31 +53,24 @@ if (document.projectId !== state.project?.id) { function promptQualityReasons(record) { const reasons = []; - const body = String(record.asset.promptBody ?? "").trim(); + const findings = promptBodyFindings(state, record); + const body = findings.body; if (!body) { reasons.push("缺少正文"); return reasons; } - const missing = missingPresentationRequirements(assetPresentation(state, record), body); - if (missing.length) reasons.push("缺呈现要素:" + missing.join("、")); + if (findings.missingRequirements.length) reasons.push("缺呈现要素:" + findings.missingRequirements.join("、")); if (profile.version === promptProfileAnchorVersion) { - const drift = findAnchorStyleTerms(body); - if (drift.length) reasons.push("含锚点级风格词:" + drift.join("、")); + if (findings.driftTerms.length) reasons.push("含锚点级风格词:" + findings.driftTerms.join("、")); } else { - const unspecified = findUnspecifiedImageTextTerms(body); - if (unspecified.length) reasons.push("画面文字未指定语言:" + unspecified.join("、")); - const badRatios = findMismatchedRatioTerms(body, assetPresentation(state, record)?.ratio); - if (badRatios.length) reasons.push("比例数字与配置不一致:" + badRatios.join("、")); + if (findings.textPolicyReasons.length) reasons.push(...findings.textPolicyReasons); + if (findings.unspecifiedText.length) reasons.push("画面文字未指定语言:" + findings.unspecifiedText.join("、")); + if (findings.mismatchedRatios.length) reasons.push("比例数字与配置不一致:" + findings.mismatchedRatios.join("、")); } - if (state.config?.promptLanguage === "zh-CN" && !isPrimarilyChinese(body)) reasons.push("正文必须以中文为主"); + if (findings.nonChinese) reasons.push("正文必须以中文为主"); return reasons; } -// 画面上真的会渲染可读文字的资产(同时命中文字载体词与语言词)默认留人审——文字内容错一个字就报废。 -const inImageWords = /铭文|招牌|标签|字样|刻字|题字|信笺|菜单|字迹/; -const languageWords = /中文|汉字|英文|拉丁文|法文|德文|西班牙文|日文|韩文/; -const hasReadableText = (body) => inImageWords.test(String(body ?? "")) && languageWords.test(String(body ?? "")); - const approved = []; const heldReadableText = []; const failed = []; @@ -91,7 +81,8 @@ for (const record of listAssets(state)) { if (status === "changes_requested") { skipped.push([record.asset.id, "人工打回,不覆盖"]); continue; } const reasons = promptQualityReasons(record); if (reasons.length) { failed.push([record.asset.id, reasons.join(";")]); continue; } - if (!includeReadableText && hasReadableText(record.asset.promptBody)) { + const structuredReadable = requiresStructuredTextPolicy(state) && record.asset.textPolicy?.mode === "readable"; + if (!includeReadableText && (structuredReadable || hasReadableImageText(record.asset.promptBody))) { heldReadableText.push(record.asset.id); continue; } diff --git a/scripts/audit_prompt_bodies.mjs b/scripts/audit_prompt_bodies.mjs new file mode 100644 index 0000000..0b3c637 --- /dev/null +++ b/scripts/audit_prompt_bodies.mjs @@ -0,0 +1,470 @@ +#!/usr/bin/env node +// Read-only Prompt audit. Every finding is an observation, never a readiness or visual-quality gate. +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { assetPresentation, listAssets, readJson, requiresStructuredTextPolicy } from "./lib/project.mjs"; + +const softBudgets = Object.freeze({ + "character-state": Object.freeze({ min: 120, max: 220 }), + location: Object.freeze({ min: 140, max: 260 }), + prop: Object.freeze({ min: 80, max: 160 }) +}); +const defaultCollisionDetailLimit = 100; + +const relationshipPatterns = Object.freeze([ + ["kinship", /姐姐|妹妹|哥哥|弟弟|母亲|父亲|妈妈|爸爸|妻子|丈夫|女儿|儿子|岳母|岳父|婆婆|公公/gu], + ["master_disciple", /师父|师傅|师尊|徒弟|弟子|门徒/gu], + ["social_relation", /主人|仆人|上司|下属|老板|雇员|未婚夫|未婚妻/gu], + ["relation_construction", /(?:是|作为|身为)[^,。;]{0,12}(?:姐姐|妹妹|哥哥|弟弟|母亲|父亲|妻子|丈夫|女儿|儿子|师父|师尊|徒弟|弟子)/gu] +]); + +const narrativePatterns = Object.freeze([ + ["state_transition_history", /(?:解除|觉醒|复明|重生|穿越|失忆|受伤|获救|入门|成婚|离婚|背叛|囚禁)(?:之前|以后|之后|以来|后|前)/gu], + ["historical_experience", /(?:经历|遭受|曾受|长期|多年|一度)[^,。;]{0,8}(?:囚禁|监禁|流放|虐待|折磨|背叛|受伤)/gu], + ["timeline_marker", /曾经|后来|此前|从此|最终|原本|一度/gu], + ["story_structure", /剧情中|故事中|第\s*\d+\s*(?:集|场|幕)/gu], + ["causal_intent", /为了|因为|因此|从而/gu] +]); + +const presentationClausePattern = /白色.{0,8}背景|白底|无缝背景|资产设定板|设定图|胸像|半身像|上半身肖像|正面全身|背面全身|回首背面|三分之四背面|画面左侧|画面右侧|并列展示|宽幅构图|单一场景|单一道具|人物比例.{0,8}一致|服装.{0,8}一致|画面不含文字|不带文字/gu; +const viewPattern = /特写|胸像|半身|正面|背面|侧面|全身|三分之四/gu; +const nextViewDelimiterPattern = /[,,、](?=[^,,、。!?;;]{0,12}(?:特写|胸像|半身|正面|背面|侧面|全身|三分之四))/u; +const viewTokenStopWords = new Set(["画面", "角色", "人物", "同一", "左侧", "右侧", "正面", "背面", "侧面", "全身", "特写", "胸像", "半身", "设定", "展示", "视图", "一致", "清晰"]); +const collisionColorWords = new Set(["黑色", "白色", "灰色", "蓝色", "红色", "绿色", "紫色", "黄色", "金色", "银色", "棕色", "褐色", "深色", "浅色", "冷色", "暖色"]); +const collisionCategoryPatterns = Object.freeze({ + face: /脸|脸型|下颌|颧骨|骨相|面部|五官/gu, + hair: /头发|发型|发丝|短发|长发|卷发|马尾|束发|发髻|披发|刘海/gu, + eyes: /眉|眼|眼眸|眼型|目光|视线|瞳|嘴角|表情/gu, + body: /身形|体型|体态|轮廓|肩颈|肩线|头身|高挑|清瘦|丰腴|匀称/gu, + posture: /姿态|站姿|重心|抬颌|挺拔|内收|外扩|松弛|端正/gu, + grooming: /妆|妆容|修饰|整洁|精致|朴素/gu, + clothing: /服装|衣|袍|裙|衫|夹克|外套|领|袖|裤|靴|鞋|面料|织物|锦缎|配饰|佩饰/gu +}); + +const segmenter = new Intl.Segmenter("zh-CN", { granularity: "word" }); + +function compactLength(value) { + return Array.from(String(value ?? "").replace(/[\s,。!?;:、,.!?;:'"“”‘’()()《》【】\[\]—…\-]/gu, "")).length; +} + +function typeCounts(records) { + const counts = { "character-state": 0, location: 0, prop: 0 }; + for (const record of records) counts[record.assetType] = (counts[record.assetType] ?? 0) + 1; + return counts; +} + +function assetLabel(record) { + if (record.assetType === "character-state" && record.character) { + const characterName = record.character.name ?? record.character.id; + const stateName = record.asset?.name; + return stateName ? `${characterName}·${stateName}` : characterName; + } + return record.asset?.name ?? record.character?.name ?? record.asset?.id ?? null; +} + +function observationHits(body, patterns) { + const hits = []; + for (const [patternId, pattern] of patterns) { + const matches = [...String(body).matchAll(new RegExp(pattern.source, pattern.flags))].map((match) => match[0]); + for (const match of [...new Set(matches)]) hits.push({ patternId, match }); + } + return hits; +} + +function wordTokens(value, { keepColors = true } = {}) { + const tokens = []; + for (const part of segmenter.segment(String(value ?? ""))) { + const token = part.segment.trim(); + if (!part.isWordLike || Array.from(token).length < 2 || viewTokenStopWords.has(token)) continue; + if (!keepColors && collisionColorWords.has(token)) continue; + tokens.push(token); + } + return [...new Set(tokens)]; +} + +function repeatedViewObservation(body) { + const clauses = String(body) + .split(/[。!?;;]+/u) + .flatMap((item) => item.split(nextViewDelimiterPattern)) + .map((item) => item.trim()) + .filter(Boolean); + const viewClauses = clauses.filter((clause) => (clause.match(viewPattern) ?? []).length > 0); + if (viewClauses.length < 2) return null; + const tokenSets = viewClauses.map((clause) => new Set(wordTokens(clause))); + const repeated = new Set(); + for (let left = 0; left < tokenSets.length; left += 1) { + for (let right = left + 1; right < tokenSets.length; right += 1) { + for (const token of tokenSets[left]) if (tokenSets[right].has(token)) repeated.add(token); + } + } + const tokens = [...repeated].filter((token) => { + const matched = presentationClausePattern.test(token); + presentationClausePattern.lastIndex = 0; + return !matched; + }).slice(0, 12); + if (!tokens.length) return null; + return { viewClauseCount: viewClauses.length, repeatedTokens: tokens }; +} + +function parseMediaBlocks(markdown) { + const text = String(markdown ?? ""); + const start = text.search(/^##\s+媒介与画质\s*$/mu); + if (start < 0) return { status: "unsupported", reason: "media_section_not_found", rows: [] }; + const afterHeading = text.slice(start).replace(/^##\s+媒介与画质\s*\r?\n/mu, ""); + const nextHeading = afterHeading.search(/^##\s+/mu); + const section = nextHeading >= 0 ? afterHeading.slice(0, nextHeading) : afterHeading; + const rows = []; + for (const line of section.split(/\r?\n/u)) { + const match = line.match(/^\s*-\s*\*\*(.+?)\*\*\s*[::]\s*(.+?)\s*$/u); + if (!match) continue; + rows.push({ label: match[1].trim(), text: match[2].trim() }); + } + if (!rows.length) return { status: "unsupported", reason: "media_rows_not_found", rows: [] }; + return { status: "supported", reason: null, rows }; +} + +function mediaCandidatesForType(parsed, assetType) { + if (parsed.status !== "supported") return []; + const labelMatches = (label) => { + if (assetType === "character-state") return /角色/u.test(label) && !/场景|道具/u.test(label); + if (assetType === "location") return /场景/u.test(label); + if (assetType === "prop") return /道具/u.test(label); + return false; + }; + return [...new Set(parsed.rows.filter((row) => labelMatches(row.label)).map((row) => row.text))]; +} + +function f02Evidence(records, parsed, sourcePath) { + const byAssetType = {}; + for (const assetType of Object.keys(softBudgets)) { + const scoped = records.filter((record) => record.assetType === assetType); + if (scoped.length === 0) { + byAssetType[assetType] = { status: "not_applicable", reason: "no_assets_with_prompt_body", checked: 0 }; + continue; + } + const candidates = mediaCandidatesForType(parsed, assetType); + if (parsed.status !== "supported") { + byAssetType[assetType] = { status: "unsupported", reason: parsed.reason, checked: 0 }; + continue; + } + if (candidates.length === 0) { + byAssetType[assetType] = { status: "unsupported", reason: "no_unambiguous_media_block_for_asset_type", checked: 0 }; + continue; + } + if (candidates.length > 1) { + byAssetType[assetType] = { status: "unsupported", reason: "multiple_media_blocks_for_asset_type", candidateCount: candidates.length, checked: 0 }; + continue; + } + const mediaBlock = candidates[0]; + const matches = scoped.filter((record) => record.asset.promptBody.startsWith(mediaBlock)); + const mismatches = scoped.filter((record) => !record.asset.promptBody.startsWith(mediaBlock)); + byAssetType[assetType] = { + status: "supported", + mediaBlock, + checked: scoped.length, + exactPrefixMatches: matches.length, + exactPrefixMismatches: mismatches.length, + matchRate: scoped.length ? matches.length / scoped.length : null, + mismatchAssetIds: mismatches.map((record) => record.asset.id) + }; + } + const applicable = Object.values(byAssetType).filter((item) => item.status !== "not_applicable"); + const supported = applicable.filter((item) => item.status === "supported"); + return { + kind: "contract_evidence", + source: parsed.status === "supported" + ? { status: "supported", path: sourcePath, parsedRows: parsed.rows.map((row) => row.label) } + : { status: "unsupported", path: sourcePath, reason: parsed.reason }, + status: applicable.length === 0 + ? "not_applicable" + : supported.length === 0 ? "unsupported" : supported.length === applicable.length ? "supported" : "partial", + byAssetType + }; +} + +function stripPresentationObservation(value, presentationPrompt) { + let text = String(value ?? "").trim(); + if (presentationPrompt && text.includes(presentationPrompt)) { + return { text: text.replace(presentationPrompt, "").trim(), removal: "exact_configured_prompt" }; + } + const parts = text.split(/([。!?;;]+)/u); + let removed = 0; + const kept = []; + for (let index = 0; index < parts.length; index += 2) { + const clause = parts[index] ?? ""; + const punctuation = parts[index + 1] ?? ""; + const matches = clause.match(presentationClausePattern) ?? []; + presentationClausePattern.lastIndex = 0; + if (matches.length) { + removed += 1; + continue; + } + kept.push(clause + punctuation); + } + text = kept.join("").trim(); + return { text, removal: removed ? "presentation_clause_heuristic" : "none_observed" }; +} + +function variableBodyObservation(state, record, f02ByType) { + const body = record.asset.promptBody; + const evidence = f02ByType[record.assetType]; + if (evidence?.status !== "supported") { + return { status: "unsupported", reason: evidence?.reason ?? "f02_evidence_unavailable" }; + } + if (!body.startsWith(evidence.mediaBlock)) { + return { status: "unsupported", reason: "f02_exact_prefix_mismatch" }; + } + const afterF02 = body.slice(evidence.mediaBlock.length).trim(); + const stripped = stripPresentationObservation(afterF02, assetPresentation(state, record)?.prompt); + return { + status: "observation", + length: compactLength(stripped.text), + presentationRemoval: stripped.removal, + analysisText: stripped.text + }; +} + +function distribution(values) { + const sorted = [...values].sort((left, right) => left - right); + if (!sorted.length) return { count: 0, min: null, p50: null, p90: null, max: null, mean: null }; + const percentile = (fraction) => sorted[Math.ceil(fraction * sorted.length) - 1]; + return { + count: sorted.length, + min: sorted[0], + p50: percentile(0.5), + p90: percentile(0.9), + max: sorted.at(-1), + mean: Number((sorted.reduce((sum, value) => sum + value, 0) / sorted.length).toFixed(1)) + }; +} + +function featureTokens(body) { + const clauses = String(body).split(/[,。!?;;、]+/u).map((item) => item.trim()).filter(Boolean); + const features = {}; + for (const [category, pattern] of Object.entries(collisionCategoryPatterns)) { + const relevant = clauses.filter((clause) => { + const matched = pattern.test(clause); + pattern.lastIndex = 0; + return matched; + }); + features[category] = wordTokens(relevant.join(","), { keepColors: false }); + } + return features; +} + +function jaccard(left, right) { + const a = new Set(left); + const b = new Set(right); + if (!a.size || !b.size) return 0; + let shared = 0; + for (const token of a) if (b.has(token)) shared += 1; + return shared / (a.size + b.size - shared); +} + +function inferredGender(value) { + if (/女性|女孩|少女|妇人|女人/u.test(value)) return "female"; + if (/男性|男孩|少年|男人/u.test(value)) return "male"; + return "unknown"; +} + +function collisionObservations(records, variableById, detailLimit = defaultCollisionDetailLimit) { + const defaults = records.filter((record) => record.assetType === "character-state" && record.asset.sequence === 0); + const profiles = defaults.map((record) => { + const variable = variableById.get(record.asset.id); + const body = variable?.status === "observation" ? variable.analysisText : record.asset.promptBody; + return { record, body, gender: inferredGender(body), features: featureTokens(body) }; + }); + const pairs = []; + for (let left = 0; left < profiles.length; left += 1) { + for (let right = left + 1; right < profiles.length; right += 1) { + const a = profiles[left]; + const b = profiles[right]; + if (a.gender !== "unknown" && b.gender !== "unknown" && a.gender !== b.gender) continue; + const categories = {}; + let sum = 0; + let compared = 0; + let strongCategories = 0; + for (const category of Object.keys(collisionCategoryPatterns)) { + const score = jaccard(a.features[category], b.features[category]); + if (!a.features[category].length || !b.features[category].length) continue; + const shared = a.features[category].filter((token) => b.features[category].includes(token)); + categories[category] = { score: Number(score.toFixed(3)), sharedTokens: shared.slice(0, 8) }; + sum += score; + compared += 1; + if (score >= 0.5) strongCategories += 1; + } + const average = compared ? sum / compared : 0; + const faceHairRisk = (categories.face?.score ?? 0) >= 0.5 && (categories.hair?.score ?? 0) >= 0.4; + const sharedFaceTokens = categories.face?.sharedTokens?.length ?? 0; + const faceGeometryRisk = (categories.face?.score ?? 0) >= 0.65 && sharedFaceTokens >= 3; + if (!faceHairRisk && !faceGeometryRisk && !(strongCategories >= 3 && average >= 0.4)) continue; + const triggerScore = faceHairRisk + ? ((categories.face?.score ?? 0) + (categories.hair?.score ?? 0)) / 2 + : faceGeometryRisk ? categories.face.score : average; + pairs.push({ + left: { characterId: a.record.character.id, assetId: a.record.asset.id, label: assetLabel(a.record) }, + right: { characterId: b.record.character.id, assetId: b.record.asset.id, label: assetLabel(b.record) }, + score: Number(triggerScore.toFixed(3)), + averageCategoryScore: Number(average.toFixed(3)), + reason: faceHairRisk + ? "face_and_hair_lexical_overlap" + : faceGeometryRisk ? "high_face_geometry_lexical_overlap" : "three_or_more_feature_categories_overlap", + categories + }); + } + } + pairs.sort((left, right) => right.score - left.score); + const effectiveLimit = Number.isInteger(detailLimit) && detailLimit >= 0 ? detailLimit : defaultCollisionDetailLimit; + const details = pairs.slice(0, effectiveLimit); + return { + kind: "observation", + method: "默认角色态;排除纯颜色词后,比较面部、发型、眼神、体态、姿态、妆发、服装词汇重合。仅提示文本碰撞风险,不证明成图撞脸。", + pairCount: pairs.length, + candidatePairCount: pairs.length, + returnedPairCount: details.length, + detailLimit: effectiveLimit, + truncated: details.length < pairs.length, + pairs: details + }; +} + +function textPolicySummary(state, records) { + const buckets = ["none", "decorative", "readable", "missing", "invalid"]; + const distribution = Object.fromEntries(buckets.map((key) => [key, 0])); + const byAssetType = Object.fromEntries(Object.keys(softBudgets).map((type) => [type, Object.fromEntries(buckets.map((key) => [key, 0]))])); + for (const record of records) { + const policy = record.asset.textPolicy; + let mode; + if (!policy || typeof policy !== "object" || Array.isArray(policy)) { + mode = "missing"; + } else if (!["none", "decorative", "readable"].includes(policy.mode)) { + mode = "invalid"; + } else if (policy.mode === "readable" && ( + typeof policy.language !== "string" + || !policy.language.trim() + || !Array.isArray(policy.content) + || policy.content.length === 0 + || policy.content.some((item) => typeof item !== "string" || !item.trim()) + )) { + mode = "invalid"; + } else { + mode = policy.mode; + } + distribution[mode] += 1; + byAssetType[record.assetType][mode] += 1; + } + return { + kind: "declaration_distribution", + structuredPolicyRequired: requiresStructuredTextPolicy(state), + assetCount: records.length, + distribution, + byAssetType + }; +} + +export async function auditPromptBodies(state, options = {}) { + const allRecords = listAssets(state); + const records = allRecords.filter((record) => typeof record.asset?.promptBody === "string" && record.asset.promptBody.trim()); + const parsed = options.artDirectionMarkdown == null + ? { status: "unsupported", reason: "art_direction_brief_not_provided", rows: [] } + : parseMediaBlocks(options.artDirectionMarkdown); + const f02 = f02Evidence(records, parsed, options.artDirectionPath ?? null); + const relationshipAssets = []; + const narrativeAssets = []; + const repeatedViewAssets = []; + const lengthRows = []; + const unsupportedLengths = []; + const variableById = new Map(); + + for (const record of records) { + const base = { assetId: record.asset.id, assetType: record.assetType, label: assetLabel(record) }; + const relationship = observationHits(record.asset.promptBody, relationshipPatterns); + if (relationship.length) relationshipAssets.push({ ...base, hits: relationship }); + const narrative = observationHits(record.asset.promptBody, narrativePatterns); + if (narrative.length) narrativeAssets.push({ ...base, hits: narrative }); + const repeated = repeatedViewObservation(record.asset.promptBody); + if (repeated) repeatedViewAssets.push({ ...base, ...repeated }); + const variable = variableBodyObservation(state, record, f02.byAssetType); + variableById.set(record.asset.id, variable); + if (variable.status === "observation") { + const budget = softBudgets[record.assetType]; + lengthRows.push({ ...base, length: variable.length, budget, overSoftMax: variable.length > budget.max, presentationRemoval: variable.presentationRemoval }); + } else { + unsupportedLengths.push({ ...base, reason: variable.reason }); + } + } + + const distributionsByType = {}; + for (const assetType of Object.keys(softBudgets)) { + distributionsByType[assetType] = distribution(lengthRows.filter((row) => row.assetType === assetType).map((row) => row.length)); + } + const observationSummary = (assets) => ({ + kind: "observation", + assetCount: assets.length, + hitCount: assets.reduce((sum, asset) => sum + (asset.hits?.length ?? 1), 0), + assets + }); + + return { + schemaVersion: 1, + auditSemantics: "静态观测;不改变项目、不参与 readiness、不证明视觉质量。", + project: { id: state.project?.id ?? null, revision: state.revision ?? null }, + assetsWithPromptBody: { + total: records.length, + byAssetType: typeCounts(records), + assetsWithoutPromptBody: allRecords.length - records.length + }, + f02, + observations: { + relationshipTerms: observationSummary(relationshipAssets), + narrativeTerms: observationSummary(narrativeAssets), + perViewRepetition: observationSummary(repeatedViewAssets) + }, + variableBodyLength: { + kind: "observation", + method: "先精确移除已取证的 F02 前缀,再精确移除配置中的 presentation prompt;无法精确匹配 presentation 时,仅观测性移除明显版式句。字符数排除空白和常见标点。", + softBudgets, + supportedAssetCount: lengthRows.length, + unsupportedAssetCount: unsupportedLengths.length, + distribution: distribution(lengthRows.map((row) => row.length)), + byAssetType: distributionsByType, + overSoftBudget: lengthRows.filter((row) => row.overSoftMax), + unsupported: unsupportedLengths + }, + characterCollision: collisionObservations(records, variableById, options.collisionDetailLimit), + textPolicy: textPolicySummary(state, records), + limitations: [ + "词语命中可能是合理可见描述,只能作为复审线索,不能据此拒绝资产。", + "角色碰撞使用提示词词汇重合启发式,不等同于身份相似度或成图视觉验证。", + "未提供总纲、媒介行缺失或同资产类型存在多个候选媒介行时,F02 明确标为 unsupported,不推断归属。", + "presentation 非逐字出现时,可变正文长度是启发式观察值,不是字符数门禁。" + ] + }; +} + +function option(name, fallback = null) { + const position = process.argv.indexOf(name); + if (position < 0) return fallback; + const value = process.argv[position + 1]; + if (!value || value.startsWith("--")) { + console.error(`选项 ${name} 缺少值。`); + process.exit(1); + } + return value; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + const [stateFile] = process.argv.slice(2); + if (!stateFile || stateFile.startsWith("--")) { + console.error("用法: node audit_prompt_bodies.mjs [--art-direction-brief art-direction-brief.md]"); + process.exit(1); + } + const statePath = path.resolve(stateFile); + const briefOption = option("--art-direction-brief"); + const briefPath = briefOption ? path.resolve(path.dirname(statePath), briefOption) : null; + const state = await readJson(statePath); + const artDirectionMarkdown = briefPath ? await readFile(briefPath, "utf8") : null; + const report = await auditPromptBodies(state, { artDirectionMarkdown, artDirectionPath: briefPath }); + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +} diff --git a/scripts/bootstrap_art_direction_brief.mjs b/scripts/bootstrap_art_direction_brief.mjs index 9481b08..d8a601c 100644 --- a/scripts/bootstrap_art_direction_brief.mjs +++ b/scripts/bootstrap_art_direction_brief.mjs @@ -41,9 +41,79 @@ try { } const selectedPreset = state.config?.visualPreset; const lockedSelection = selectedPreset - ? `## 已选视觉范式(锁定输入)\n\n- 名称:${selectedPreset.name}${selectedPreset.positioning ? `\n- 定位:${selectedPreset.positioning}` : ""}\n- 提示词风格种子:${selectedPreset.stylePromptSeed}\n- 说明:这是用户在初始化 Dialog 中的一次性选择。保留其视觉范式意图与定位(游戏立绘向/影视写实向等不可混淆);不要把预览图、样例主体或外部知识库细节当作模型参考图。\n\n` - : "## 已选视觉范式(锁定输入)\n\n待完成初始化 Dialog 后自动写入。\n\n"; - await writeFile(outputPath, `# 项目创作方向\n\n> 由 AI 美术总监基于已锁定的故事资产圣经提出;用户确认后运行 record_art_direction_brief.mjs。\n> 不要复制外部美术知识库。请使用模型的自主设计能力,并遵守 references/creative-charter.md。\n> 最终 prompt 只使用正向描述;不要生成负向提示词、排除词清单或“避免……”补丁。\n> 本 skill 面向中文使用者:本总纲和后续资产提示词均以中文为主;专有名词可保留必要原文。\n> 本总纲全部内容是岗位四编写整段正文的**输入材料**,系统不做任何逐字拼接。\n> 白底、胸像、三视图、正背面、排版和画幅比例属于具体资产呈现配置,禁止写入本总纲各章节。\n\n${lockedSelection}## 创作命题\n\n待补充:用一段话说明本项目希望观众首先感受到的世界、情绪和设计张力。\n\n## 用户确认的方向\n\n待补充:记录用户选择的方向、参考意图和创作自由度。\n\n## 媒介与画质\n\n待补充:为项目的每个**媒介维度**分别给出"角色"与"场景与道具"两个风格词块(岗位四把本资产所属维度、所属类型的词块**原样**落进正文开头)。以已选视觉范式的风格种子为底稿,用短语铺陈流书写,格式如下:\n\n- **主维度·角色**:媒介 + 感性题材气质/调性(如"现代哥特""国风")+ 画质目标 + **人物防漂移钉子**——必须逐一钉死会漂移的自由度:皮肤细节度(低细节皮肤/超写实皮肤纹理)、毛孔与油光(无毛孔/无油光)、真人感(非真人感/超写实真人)、材质(哑光/实拍物性)、发丝(哑光分束发丝/发丝层次清晰)、五官夸张度(夸张五官比例/写实比例五官)、**头身比例**(写实七至八头身/头身比例略夸张——不声明会向 Q 版漂移)。\n- **主维度·场景与道具**:媒介 + 同一题材气质 + 材质光照与画质目标,**不含人物专属词**(肤质/毛孔/发丝/五官不出现在本块)。\n\n单一媒介剧本只写主维度两行;剧本存在贯穿复现的其他媒介形态(如反复使用的 Q 版形象、穿越进入的游戏/漫画世界)时,为该维度再加两行并注明适用范围。**禁止写进本节**:具体色名(绯红/灰绿/黑金等)、材质枚举清单(黑曜石、旧银、丝绒……)、确定性的细节纹样——这些属于"分类型美术语言",由岗位四按资产与阵营取用;**叙事概念与剧情结构词**("反套路""师徒故事""逆袭"等——生图模型无法执行剧情概念);版式与流程说明。\n\n## 人物群体呈现\n\n待补充:决定本项目人物的族裔与地域面貌呈现。剧本明确写出的族裔/地域出身照实沿用;圣经标注"未明确"的角色,默认按**剧本文化语境的主流面孔**统一呈现(西式命名与西式世界观 → 欧美面貌为主;东南亚背景 → 东南亚面貌为主),写一行理由,随本总纲整体确认。**除非剧本明确写了多元设定,不得以"国际化都市/开放港城"等语境推演为由做多族裔均衡分布**——本 skill 服务的短剧面向明确目标市场。不得从角色名字或剧本语言推断个别角色的族裔;不把族裔编码为善恶或权力等级;用户明确指定时以用户为准。\n\n## 画面内文字语言\n\n待补充:决定画面中出现的可读文字(招牌、标签、铭文、文件、菜单等)使用什么语言。剧本明确写了世界文字语言的照实沿用;未明确时按世界观决策(如西式世界观 → 画面文字为英文)并写一行理由,随本总纲整体确认。默认策略:证据类道具与场景标识(文件、标签、招牌、菜单、唱片等)写明语言并由岗位四给出内容原文;装饰类(徽章、戒指、纹章等)默认纹样化处理、不构成可读文字。\n\n## 分类型美术语言\n\n本节是岗位四编写提示词正文的输入材料,**不逐字拼接**;写设计语言与阵营/类型差异(色彩体系、材质世界、轮廓语言),不重复"媒介与画质"的句子。剧本存在多方势力时,在此为各阵营分别定义色彩与材质体系。\n\n### 角色\n\n待补充:角色造型语言、轮廓与比例倾向、服饰体系与识别锚点原则;人物面貌遵循"人物群体呈现"章节的决策。**辨识度层级**:辨识度是显式决策,手段服从剧本人设,目标是主角在成图资产库中一眼可定位——但可定位不等于华丽专属设计。英雄型/强人设主角用独占轮廓、专属色或贯穿符号;普通人代入型主角(废柴逆袭、日常职场等大众形象人设)保持平凡外形,用一两个克制的贯穿特征承载辨识(固定发型、一件常穿单品、稳定体态),并在本节写明"主角采用普通人设、辨识靠××"及理由,防止岗位四自行加英雄式设计。核心配角次之;功能角色与类别代表保持体系统一、明确让位,不塑造成抢注意力的独立造型。\n\n### 场景\n\n待补充:空间与建筑语言、材质层级、光线氛围原则。\n\n### 道具\n\n待补充:器物设计语言、材质与尺度原则。\n\n## 正向设计重点\n\n待补充:列出本项目最应被看见和保持一致的设计重点,不写排除词或反向清单。\n\n## 角色、场景与道具设计原则\n\n待补充:说明它们如何共享世界感、如何形成差异,以及哪些设计应保持稳定。\n\n## 待确认项\n\n待补充:列出不能由模型自行决定的分歧;若没有,请明确写“无”。\n`, "utf8"); + ? `## 已选视觉范式(锁定输入)\n\n- 名称:${selectedPreset.name}${selectedPreset.positioning ? `\n- 定位:${selectedPreset.positioning}` : ""}\n- 提示词风格种子:${selectedPreset.stylePromptSeed}\n\n` + : "## 已选视觉范式(锁定输入)\n\n待完成初始化后自动写入。\n\n"; + await writeFile(outputPath, `# 项目创作方向 + +> 基于已锁定故事圣经提出;用户确认后运行 record_art_direction_brief.mjs。 +> 使用模型的自主设计能力,遵守 skill 的 references/creative-charter.md。以中文为主,不复制外部知识库。 +> 本总纲是岗位四任务卡的来源,主代理只抽取与当前资产有关的内容,不把全文塞入任务卡。系统不逐字拼接。白底、胸像、多视图、排版和比例属于 assetPresentation,不写入本总纲。 + +${lockedSelection}## 创作命题 + +待补充:用一段话定义观众首先感受到的世界、情绪和设计张力。 + +## 用户确认的方向 + +待补充:记录用户选择、参考意图和允许模型自主决定的范围。 + +## 媒介与画质 + +待补充:每个媒介维度写两行纯渲染媒介核。这两行属于 F02 CONTRACT;岗位四会按所属维度和资产类型把整行内容原样放进正文开头,不得改写或删减。 + +- **主维度·角色**:媒介与渲染范式、真人感或非真人感边界、画质目标,以及人物防漂移钉子:皮肤细节、毛孔与油光、材质、发丝、五官夸张度、头身比例、体积与光照、跨资产身份一致性。 +- **主维度·场景与道具**:同一媒介与渲染范式下的材质、体积、光照、细节和画质目标;不写肤质、毛孔、发丝、五官等人物词。 + +多媒介项目为每个贯穿复现的维度增加同样两行并注明适用范围。本节不写项目题材或全局情绪结论(如“家庭压迫”“梦幻仙气”)、角色关系、剧情阶段、善恶评价、阵营专属颜色、具体服饰材料、局部场景气氛、版式或流程;这些内容不得借“气质”之名重新塞回媒介核。 + +## 世界语言与局部气质分流 + +待补充:说明跨资产稳定复现的时代、社会阶层、阵营、色彩、材质与空间语言,并说明情绪如何随资产和剧情功能变化。不得把单一项目情绪当成所有资产的固定前缀;例如公共空间可以表面体面而秩序压迫,私人回忆空间仍可温暖、柔软、安全。 + +岗位四的任务卡只接收当前资产所需的一句局部气质(localTone)。localTone 用于指导当前资产的氛围、姿态或光线,不属于 F02 CONTRACT,不得改写或替代正文开头的原样媒介核。 + +## 人物群体呈现 + +待补充:剧本明确事实照实沿用;未明确角色按剧本文化语境的主流面孔作项目级决定并写理由。除非剧本明确多元设定,不默认做多族裔均衡分布;不从名字推断个体,也不把族裔编码为善恶或权力等级。 + +## 画面内文字语言 + +待补充:决定可读文字使用的语言。证据类道具和场景标识由岗位四写明语言与内容原文;装饰文字默认纹样化不可读。 + +## 分类型美术语言 + +待补充:定义角色、场景、道具以及不同阵营的色彩体系、材质世界和轮廓语言;不重复媒介与画质原句。 + +### 角色 + +待补充:造型、比例、服饰和识别锚点。按人设声明主角辨识方式:英雄型可用独占特征;普通人设用少量稳定特征,不强加英雄化设计。核心配角次之,功能角色让位。 + +#### 核心角色差异矩阵(只覆盖高碰撞角色) + +待补充:只为同性别、相近年龄、同阵营或有亲缘关系且容易撞型的角色组,以及主角与核心反派、用户要求一眼区分的角色组建立矩阵。可按需要使用年龄层级、面部结构、眼眉与目光、妆发、体态轮廓、服装剪裁与材质、稳定识别锚等维度;不要求覆盖所有角色,也不要求逐格填满。 + +验收看结果:相邻核心角色至少有三项非颜色差异,且不共享“脸型 + 发型 + 姿态”组合。戏剧功能必须能被画面读出,但不把善恶自动编码成固定脸型或生理丑化;用户明确要求明显丑化时,再把要求翻译成可信的可见设计。 + +### 场景 + +待补充:空间、建筑、材质层级和光线氛围。 + +### 道具 + +待补充:器物结构、材质、尺度和识别原则。 + +## 正向设计重点 + +待补充:列出最应被看见并保持一致的设计重点,不写排除词补丁。 + +## 角色、场景与道具设计原则 + +待补充:说明三类资产如何共享世界感、形成差异并保持稳定。 + +## 待确认项 + +待补充:只列不能由模型自行决定且会改变项目方向的分歧;没有则写“无”。 +`, "utf8"); } const next = { diff --git a/scripts/bootstrap_story_bible.mjs b/scripts/bootstrap_story_bible.mjs index 4d0fd9a..25407a5 100644 --- a/scripts/bootstrap_story_bible.mjs +++ b/scripts/bootstrap_story_bible.mjs @@ -48,7 +48,38 @@ try { console.error("已锁定的故事资产圣经文件缺失;请从备份恢复,或新建派生项目。"); process.exit(1); } - await writeFile(outputPath, `# 故事资产圣经\n\n> 项目:${state.project.id}\n>\n> 用剧本统筹与资产研究员的视角阅读剧本。只整理剧情事实、角色身份、外观版本、场景、道具及其证据;不受画风、构图、模型和生成参数影响,不做美术设计。\n> 证据一律使用"集-场"编号指针(如 12-3),不引用台词全文。剧本使用 Scene/INT./EXT.、幕-场或其他非"集-场"结构时,先建立编号映射(如单部电影按 1-场号),并在"世界观与美术语境"章节记录换算规则。\n> 为每个角色、场景、道具条目分配稳定候选编号(角色 C-01、场景 L-01、道具 P-01 递增),供下游名册编制逐项对账。\n> 剧本可以是任何语言与地域(东南亚、欧美、拉美等均可);人名、地名等专有名词保留原文,其余描述以中文为主。\n## 世界观与美术语境\n\n待补充:时代、地域、社会规则、技术/奇幻设定,以及影响资产设计的视觉语境。若剧本明确写出世界内文字所用语言(招牌、文件、铭文等),记录一行"世界文字语言:……";剧本未写明时填"世界文字语言:未明确"(由创作方向层决策)。\n\n## 剧情梗概与时间线\n\n待补充:按章节或关键转折概括剧情,并标明时间、地点与身份变化。本章节供人工审查与全局理解,不进入下游编译输入。\n\n## 角色档案\n\n待补充。入册条件(任一命中即入册,与出场次数无关,宁多勿漏):有正式姓名或稳定称呼;有台词或连续对话;承担剧情功能;与主要角色存在固定关系。无名但需复用的功能性群体(普通弟子、士兵、宾客等)抽象为类别代表角色,不使用"甲乙丙丁"作为正式名称;剧情中成员固定的组合(三姐妹、四大护法等)必须拆成独立角色条目。\n\n每个角色记录:候选编号、canonical 名称、别名/称呼、**基础身份事实**(性别、年龄段、族裔或地域出身——只抄剧本明确写出的,剧本未写的一律填"未明确",不得从名字、语言或目标市场推断;未明确项由 AI 美术总监在创作方向中给出呈现决策)、稳定身份特征(跨全剧不变的外貌与识别锚点,含轮椅、义肢、非人形体等体态事实),以及外观版本列表。外观版本定义:需要一张独立角色参考图才能让后续多个镜头稳定复现的固定视觉形态——整套服装或装备体系变化、永久性重大身体变化(妖化、成年、进化后形态、固定新发型)、贯穿复现的媒介或画风形态变化(如反复使用的 Q 版形象、穿越异维度后的固定形态——记录时注明所属媒介维度)构成新版本;表情、姿势、动作、发怒哭泣、临时受伤、血迹污渍、战斗损耗、临时光效与一时性法术效果不属于外观版本,不记录。每个外观版本注明触发剧情、视觉变化点和全部出现集-场。\n\n## 场景与地点\n\n待补充:每个地点记录候选编号、canonical 名称、别名、用途与形态版本(固定空间结构变化或永久性改变构成新形态;光线、天气、临时破坏、人物活动不构成)。多次使用或承担多个镜头的次级地点必须入册;一闪而过的背景地点可不入册。每种形态注明全部出现集-场。\n\n## 道具与状态\n\n待补充:只列出需要视觉资产化的道具,记录候选编号、别名、持有者与状态变化,每个状态逐一列出全部出现集-场。角色拿起、放下或更换手持道具不构成角色外观版本。\n\n## 确定性处理结果\n\n待补充:集中处置同一实体的多种称呼、疑似重复项与指代不明项。每项必须落入以下之一并附一行理由:已归并为同一实体 / 已拆分为不同实体 / 按类别处理 / 不生成独立资产 / 剧本自身矛盾(保留告警)。不输出"等待人工确认"的悬置项。\n`, "utf8"); + await writeFile(outputPath, `# 故事资产圣经 + +> 项目:${state.project.id} +> +> 只整理剧本事实,不做美术设计。完整的岗位边界、外观版本和准入定义以 skill 的 references/subagent-contracts.md 为准。 +> 证据只写场景指针,不引用长台词。集-场以“12-3”表示;Scene/INT./EXT. 等格式先建立“1-场号”映射并在第一节说明。 +> 为候选分配稳定编号:角色 C-01、场景 L-01、道具 P-01。专有名词保留原文,其余以中文为主。 + +## 世界观与美术语境 + +待补充:时代、地域、社会规则、技术或奇幻设定,以及场景编号映射。记录“世界文字语言:明确值 / 未明确”。 + +## 剧情梗概与时间线 + +待补充:只概括会影响资产身份、时代、地点、外观版本或复用关系的关键转折,不复述逐集情节,不做主题阐释。本节只供全局理解,不进入下游 prompt。默认目标不超过 300 个汉字;复杂长剧最多 600 个汉字。这是软预算,不作脚本硬门禁;超过时须能说明新增文字保护了哪项资产判断,否则继续压缩。 + +## 角色档案 + +待补充:用短事实条目逐个记录候选编号、canonical 名称、别名、基础身份事实(性别 / 年龄段 / 族裔或地域出身;未写填“未明确”)、稳定身份特征、外观版本与全部证据。关系仅在影响资产准入、身份或固定视觉状态时记录,不写人物小传。类别代表和固定组合按准入规则处理。 + +## 场景与地点 + +待补充:用短事实条目逐个记录候选编号、canonical 名称、别名、资产化用途、固定形态版本与全部证据,不复述在此发生的逐场剧情。 + +## 道具与状态 + +待补充:用短事实条目记录需要资产化的道具,包含候选编号、别名、持有者、可复用状态和全部证据;不写与可见状态无关的来历叙述。 + +## 确定性处理结果 + +待补充:逐项处理别名、重复和指代问题,并写一行理由。处置只能是:归并 / 拆分 / 类别 / 不生成独立资产 / 剧本矛盾告警;不留下“等待人工确认”的悬置项。 +`, "utf8"); } const next = { diff --git a/scripts/lib/project.mjs b/scripts/lib/project.mjs index 28e7c8d..9df27e9 100644 --- a/scripts/lib/project.mjs +++ b/scripts/lib/project.mjs @@ -9,6 +9,8 @@ export const promptProfileAnchorVersion = 2; // v3(整段正文,新项目默认):无任何逐字拼接段;最终提示词 = 岗位四整段正文。 // 媒介/画质/维度差异全部由岗位四按总纲"媒介与画质"输入材料写入正文(多媒介剧本如 Q 版形象是常态,维度是资产事实而非系统机制轴)。 export const promptProfileBodyVersion = 3; +// v3 新项目的结构化画面文字策略。未带此标记的存量 v3 项目继续走旧词表兼容链路。 +export const structuredTextPolicyVersion = 1; export const assetPresentationVersion = 1; // 锚点长度约束(仅 v2):锚点是唯一逐字拼接段,必须短而干净。 export const styleAnchorMaxLength = 240; @@ -125,6 +127,14 @@ export function findDedicatedDeliveryLayoutTerms(value) { return dedicatedDeliveryLayoutPatterns.filter(([, pattern]) => pattern.test(text)).map(([label]) => label); } +const characterSpecificTerms = ["角色", "人物", "肤质", "发丝", "毛孔"]; + +export function findCharacterSpecificTerms(value, { includePores = false } = {}) { + const text = cleanSegment(value); + const terms = includePores ? characterSpecificTerms : characterSpecificTerms.slice(0, 4); + return terms.filter((term) => text.includes(term)); +} + export function sanitizeGlobalRenderLanguage(value) { const text = cleanSegment(value); if (!text) return ""; @@ -195,6 +205,7 @@ export function findAnchorStyleTerms(value) { // 词表故意保守(宁漏勿误伤);漏网项由一致性检查岗与对话审查兜底。 const inImageTextPattern = /铭文|招牌|标签|字样|刻字|题字|信笺|菜单|字迹/gu; const inImageTextResolutionPattern = /英文|拉丁文|法文|德文|西班牙文|葡萄牙文|日文|韩文|泰文|越南文|阿拉伯文|西里尔|中文|汉字|无可读文字|不可读|纹样化/u; +const readableTextReviewLanguagePattern = /中文|汉字|英文|拉丁文|法文|德文|西班牙文|日文|韩文/u; export function findUnspecifiedImageTextTerms(value) { const text = cleanSegment(value); @@ -204,6 +215,39 @@ export function findUnspecifiedImageTextTerms(value) { return inImageTextResolutionPattern.test(text) ? [] : hits; } +export function requiresStructuredTextPolicy(state) { + return state.config?.promptProfile?.version === promptProfileBodyVersion + && state.config.promptProfile.textPolicyVersion === structuredTextPolicyVersion; +} + +export function structuredTextPolicyReasons(state, record, body = record?.asset?.promptBody) { + if (!requiresStructuredTextPolicy(state)) return []; + const policy = record?.asset?.textPolicy; + const text = cleanSegment(body); + if (!policy || typeof policy !== "object" || Array.isArray(policy)) return ["缺少结构化 textPolicy"]; + if (!new Set(["none", "decorative", "readable"]).has(policy.mode)) { + return ["textPolicy.mode 必须为 none、decorative 或 readable"]; + } + if (policy.mode === "none") { + const terms = [...new Set(text.match(inImageTextPattern) ?? [])]; + return terms.length ? ["textPolicy.mode=none,但正文仍要求画面文字(" + terms.join("、") + ")"] : []; + } + if (policy.mode === "decorative") { + return /不可读|纹样化/u.test(text) ? [] : ["textPolicy.mode=decorative 时,正文必须明确文字不可读或纹样化"]; + } + const reasons = []; + const language = cleanSegment(policy.language); + const content = Array.isArray(policy.content) + ? policy.content.map(cleanSegment).filter(Boolean) + : []; + if (!language) reasons.push("textPolicy.mode=readable 时必须填写 language"); + if (!content.length) reasons.push("textPolicy.mode=readable 时必须填写非空 content 数组"); + if (language && !text.includes(language)) reasons.push("正文未写明 textPolicy.language:" + language); + const missingContent = content.filter((item) => !text.includes(item)); + if (missingContent.length) reasons.push("正文未逐字包含 textPolicy.content:" + missingContent.join("、")); + return reasons; +} + // 画幅比例一致性(v3):比例由呈现配置承载,正文可以省略;写了就必须与配置一致(防止误导模型或抄错呈现规格)。 export function findMismatchedRatioTerms(value, ratio) { const text = cleanSegment(value); @@ -223,15 +267,29 @@ export function missingPresentationRequirements(presentation, body) { .map((requirement) => requirement.label ?? requirement.match); } -// 呈现排除项:局部构图(上半身肖像等)的"不能有什么"清单——正文描述可见范围之外的内容 -// 会诱导生图模型拉远构图破坏版式。词表放在呈现配置里(exclusions),保守取词、宁漏勿误伤。 -export function foundPresentationExclusions(presentation, body) { - const text = cleanSegment(body); - const exclusions = presentation?.exclusions ?? []; - if (!text || !exclusions.length) return []; - return exclusions - .filter((exclusion) => exclusion?.match && new RegExp(exclusion.match, "u").test(text)) - .map((exclusion) => exclusion.label ?? exclusion.match); +// v2/v3 正文校验的单一事实出口。readiness、validator 与批量放行共享同一组发现, +// 各调用方只负责把发现翻译成适合自身场景的错误文案。 +export function promptBodyFindings(state, record) { + const body = cleanSegment(record?.asset?.promptBody); + const profile = resolvedPromptProfile(state); + const presentation = assetPresentation(state, record); + const anchored = profile.version === promptProfileAnchorVersion; + const bodyBased = profile.version === promptProfileBodyVersion; + return { + body, + profileVersion: profile.version, + missingRequirements: body ? missingPresentationRequirements(presentation, body) : [], + driftTerms: body && anchored ? findAnchorStyleTerms(body) : [], + unspecifiedText: body && bodyBased ? findUnspecifiedImageTextTerms(body) : [], + textPolicyReasons: bodyBased ? structuredTextPolicyReasons(state, record, body) : [], + mismatchedRatios: body && bodyBased ? findMismatchedRatioTerms(body, presentation?.ratio) : [], + nonChinese: Boolean(body && state.config?.promptLanguage === "zh-CN" && !isPrimarilyChinese(body)) + }; +} + +export function hasReadableImageText(value) { + const text = cleanSegment(value); + return Boolean(text.match(inImageTextPattern)?.length && readableTextReviewLanguagePattern.test(text)); } export function isPrimarilyChinese(value) { @@ -267,7 +325,8 @@ export function compilePrompt(state, record) { presentation: presentation ? { id: presentation.id ?? null, ratio: presentation.ratio ?? null, requirements: presentation.requirements ?? null } : null, - promptBody: asset.promptBody ?? null + promptBody: asset.promptBody ?? null, + textPolicy: requiresStructuredTextPolicy(state) ? asset.textPolicy ?? null : null }) }; } @@ -414,27 +473,22 @@ export function readiness(record, state) { if (!prompt.text) reasons.push("缺少可编译提示词"); const profile = resolvedPromptProfile(state); if (profile.version === promptProfileAnchorVersion || profile.version === promptProfileBodyVersion) { - const body = cleanSegment(record.asset.promptBody); + const findings = promptBodyFindings(state, record); + const body = findings.body; if (profile.version === promptProfileAnchorVersion && !profile.styleAnchor) reasons.push("缺少公共风格锚点"); if (!body) reasons.push("缺少岗位四提示词正文(promptBody)"); if (body) { - const presentation = assetPresentation(state, record); - const missingRequirements = missingPresentationRequirements(presentation, body); - if (missingRequirements.length) reasons.push("正文缺少必备呈现要素:" + missingRequirements.join("、")); - const exclusionHits = foundPresentationExclusions(presentation, body); - if (exclusionHits.length) reasons.push("正文包含所选呈现范围之外的内容(" + exclusionHits.join("、") + ");局部构图只写可见部分"); + if (findings.missingRequirements.length) reasons.push("正文缺少必备呈现要素:" + findings.missingRequirements.join("、")); if (profile.version === promptProfileAnchorVersion) { // 防漂移词表仅适用于 v2;v3 正文必须自带媒介与画质措辞。 - const driftTerms = findAnchorStyleTerms(body); - if (driftTerms.length) reasons.push("正文包含应由公共风格锚点承载的风格词(" + driftTerms.join("、") + "),请移除后重写"); + if (findings.driftTerms.length) reasons.push("正文包含应由公共风格锚点承载的风格词(" + findings.driftTerms.join("、") + "),请移除后重写"); } else { - const unspecifiedText = findUnspecifiedImageTextTerms(body); - if (unspecifiedText.length) { - reasons.push("正文要求画面出现可读文字(" + unspecifiedText.join("、") + ")但未指定文字语言;请写明语言与内容原文(如:标签为英文 \"Hawthorn\"),或改为纹样化不可读"); + if (findings.textPolicyReasons.length) reasons.push(...findings.textPolicyReasons); + if (findings.unspecifiedText.length) { + reasons.push("正文要求画面出现可读文字(" + findings.unspecifiedText.join("、") + ")但未指定文字语言;请写明语言与内容原文(如:标签为英文 \"Hawthorn\"),或改为纹样化不可读"); } - const mismatchedRatios = findMismatchedRatioTerms(body, assetPresentation(state, record)?.ratio); - if (mismatchedRatios.length) { - reasons.push("正文中的画幅比例(" + mismatchedRatios.join("、") + ")与本资产呈现配置不一致;比例由系统承载,正文可省略,写了必须一致"); + if (findings.mismatchedRatios.length) { + reasons.push("正文中的画幅比例(" + findings.mismatchedRatios.join("、") + ")与本资产呈现配置不一致;比例由系统承载,正文可省略,写了必须一致"); } } } diff --git a/scripts/map_awb_image_batch.mjs b/scripts/map_awb_image_batch.mjs new file mode 100644 index 0000000..bbc5ed4 --- /dev/null +++ b/scripts/map_awb_image_batch.mjs @@ -0,0 +1,179 @@ +#!/usr/bin/env node +import { randomUUID } from "node:crypto"; +import { mkdir, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { hashValue, readJson, schemaVersion, writeJsonAtomic } from "./lib/project.mjs"; + +function option(name, fallback = null) { + const position = process.argv.indexOf(name); + return position >= 0 ? process.argv[position + 1] : fallback; +} + +function nonEmptyString(value) { + return typeof value === "string" && Boolean(value.trim()); +} + +function plainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +async function writeTextAtomic(file, value) { + const target = path.resolve(file); + await mkdir(path.dirname(target), { recursive: true }); + const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`; + await writeFile(temporary, value, "utf8"); + await rename(temporary, target); +} + +const [batchFile] = process.argv.slice(2); +if (!batchFile || batchFile.startsWith("--")) { + console.error("用法: node map_awb_image_batch.mjs [--fixed-generate-num 1] [--output awb-image-input.jsonl] [--mapping awb-image-input.mapping.json]"); + process.exit(1); +} + +const inputFile = path.resolve(batchFile); +const projectRoot = path.dirname(inputFile); +const outputFile = path.resolve(option("--output", path.join(projectRoot, "awb-image-input.jsonl"))); +const mappingFile = path.resolve(option("--mapping", path.join(projectRoot, "awb-image-input.mapping.json"))); +const fixedGenerateNum = Number(option("--fixed-generate-num", "1")); + +if (new Set([inputFile, outputFile, mappingFile]).size !== 3) { + console.error("输入批次、AWB JSONL 和 mapping sidecar 必须是三个不同文件,禁止覆盖 canonical 批次。"); + process.exit(1); +} +if (!Number.isInteger(fixedGenerateNum) || fixedGenerateNum < 1) { + console.error("fixed-generate-num 必须为正整数"); + process.exit(1); +} + +const batch = await readJson(inputFile); +const errors = []; +if (batch.schemaVersion !== schemaVersion) errors.push("schemaVersion 必须为 " + schemaVersion); +for (const field of ["projectId", "batchId", "batchHash"]) { + if (!nonEmptyString(batch[field])) errors.push("批次缺少 " + field); +} +if (!Array.isArray(batch.items) || !batch.items.length) errors.push("批次 items 必须是非空数组"); +if (Array.isArray(batch.items) && nonEmptyString(batch.batchHash)) { + const expectedBatchHash = hashValue({ + projectId: batch.projectId, + sourceStateRevision: batch.sourceStateRevision, + styleHash: batch.styleHash, + scope: batch.scope, + image: batch.image, + items: batch.items + }); + if (expectedBatchHash !== batch.batchHash) errors.push("batchHash 与 canonical 批次内容不匹配"); +} + +const allowedImageFields = new Set(["ratio", "quality", "model_params"]); +if (batch.image != null && !plainObject(batch.image)) { + errors.push("batch.image 必须为对象"); +} else { + for (const field of Object.keys(batch.image ?? {})) { + if (!allowedImageFields.has(field)) errors.push("batch.image 含未支持字段:" + field); + } +} + +const seenIds = new Set(); +const seenGenerationKeys = new Set(); +const lines = []; +const mappings = []; + +for (const [index, item] of (batch.items ?? []).entries()) { + const label = `items[${index}]`; + if (!plainObject(item)) { + errors.push(label + " 必须为对象"); + continue; + } + for (const field of ["id", "generationKey", "promptHash", "prompt"]) { + if (!nonEmptyString(item[field])) errors.push(label + " 缺少 " + field); + } + if (nonEmptyString(item.id)) { + if (seenIds.has(item.id)) errors.push("item id 重复:" + item.id); + seenIds.add(item.id); + } + if (nonEmptyString(item.generationKey)) { + if (seenGenerationKeys.has(item.generationKey)) errors.push("generationKey 重复:" + item.generationKey); + seenGenerationKeys.add(item.generationKey); + } + if (nonEmptyString(item.prompt) && nonEmptyString(item.promptHash) && hashValue(item.prompt) !== item.promptHash) { + errors.push(label + " 的 promptHash 与 prompt 不匹配"); + } + if (!plainObject(item.image)) { + errors.push(label + ".image 必须为对象"); + continue; + } + for (const field of Object.keys(item.image)) { + if (!allowedImageFields.has(field)) errors.push(label + ".image 含未支持字段:" + field); + } + if (!nonEmptyString(item.image.ratio)) errors.push(label + ".image 缺少 ratio"); + if (!nonEmptyString(item.image.quality)) errors.push(label + ".image 缺少 quality"); + if (item.image.model_params != null && !plainObject(item.image.model_params)) { + errors.push(label + ".image.model_params 必须为对象"); + } + + let resources = null; + let referenceArtifactId = null; + if (item.reference != null) { + if (!plainObject(item.reference)) { + errors.push(label + ".reference 必须为对象或 null"); + } else { + referenceArtifactId = item.reference.artifactId; + const backendPath = item.reference.image?.backendPath; + if (!nonEmptyString(referenceArtifactId)) errors.push(label + ".reference 缺少 artifactId"); + if (!nonEmptyString(backendPath)) { + errors.push(label + ".reference 缺少稳定 backendPath;mapper 不使用短期签名 URL 或本地路径猜测供应商输入"); + } else { + resources = [{ type: "image", usage: "reference", source: { kind: "url", value: backendPath.trim() } }]; + } + } + } + + const line = { + prompt: item.prompt, + ratio: item.image.ratio, + quality: item.image.quality, + ...(resources ? { resources } : {}), + ...(item.image.model_params != null ? { model_params: item.image.model_params } : {}) + }; + const serialized = JSON.stringify(line); + lines.push(serialized); + mappings.push({ + lineNumber: index + 1, + itemId: item.id, + generationKey: item.generationKey, + promptHash: item.promptHash, + ratio: item.image.ratio, + quality: item.image.quality, + resolvedGenerateNum: fixedGenerateNum, + quantitySource: "model-fixed", + referenceArtifactId, + lineHash: hashValue(serialized) + }); +} + +if (errors.length) { + for (const error of errors) console.error("无法映射:" + error); + process.exit(1); +} + +const jsonl = lines.join("\n") + "\n"; +const mapping = { + schemaVersion, + provider: "lj-awb", + projectId: batch.projectId, + batchId: batch.batchId, + batchHash: batch.batchHash, + sourceStateRevision: batch.sourceStateRevision, + itemCount: mappings.length, + totalImageCount: mappings.length * fixedGenerateNum, + quantity: { source: "model-fixed", resolvedGenerateNum: fixedGenerateNum }, + jsonlHash: hashValue(jsonl), + lines: mappings +}; + +await writeTextAtomic(outputFile, jsonl); +await writeJsonAtomic(mappingFile, mapping); +console.log("已生成 AWB JSONL:" + outputFile); +console.log("已生成逐行映射:" + mappingFile); +console.log("itemCount=" + mapping.itemCount + " jsonlHash=" + mapping.jsonlHash); diff --git a/scripts/merge_agent_patches.mjs b/scripts/merge_agent_patches.mjs index 1025b58..0aa66af 100644 --- a/scripts/merge_agent_patches.mjs +++ b/scripts/merge_agent_patches.mjs @@ -87,8 +87,8 @@ for (const { file, patch } of patches) { continue; } const allowed = record.assetType === "character-state" - ? new Set(["name", "visualBrief", "deltaPrompt", "promptBody", "sourceRefs"]) - : new Set(["name", "description", "identityPrompt", "visualBrief", "deltaPrompt", "promptBody", "sourceRefs", "usages"]); + ? new Set(["name", "visualBrief", "deltaPrompt", "promptBody", "textPolicy", "sourceRefs"]) + : new Set(["name", "description", "identityPrompt", "visualBrief", "deltaPrompt", "promptBody", "textPolicy", "sourceRefs", "usages"]); for (const [field, value] of Object.entries(operation.changes)) { if (!allowed.has(field)) { conflicts.push(file + " 不允许更新 " + operation.id + "." + field); diff --git a/scripts/normalize_awb_image_results.mjs b/scripts/normalize_awb_image_results.mjs new file mode 100644 index 0000000..e4dabf4 --- /dev/null +++ b/scripts/normalize_awb_image_results.mjs @@ -0,0 +1,175 @@ +#!/usr/bin/env node +import path from "node:path"; +import { readJson, writeJsonAtomic } from "./lib/project.mjs"; + +function option(name, fallback = null) { + const position = process.argv.indexOf(name); + return position >= 0 ? process.argv[position + 1] : fallback; +} + +function nonEmptyString(value) { + return typeof value === "string" && Boolean(value.trim()); +} + +function plainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +const [batchFile, planFile, statusesFile] = process.argv.slice(2).filter((value) => !value.startsWith("--")); +if (!batchFile || !planFile || !statusesFile) { + console.error("用法: node normalize_awb_image_results.mjs [--output awb-results.json]"); + process.exit(1); +} + +const batchPath = path.resolve(batchFile); +const planPath = path.resolve(planFile); +const statusesPath = path.resolve(statusesFile); +const outputPath = path.resolve(option("--output", path.join(path.dirname(batchPath), "awb-results.json"))); +if (new Set([batchPath, planPath, statusesPath, outputPath]).size !== 4) { + console.error("输出文件不得覆盖 batch、execution plan 或终态状态快照。"); + process.exit(1); +} + +const [batch, plan, statusPayload] = await Promise.all([ + readJson(batchPath), + readJson(planPath), + readJson(statusesPath) +]); +const statusSource = plainObject(statusPayload.data) ? statusPayload.data : statusPayload; +const capturedAt = statusSource.checkedAt ?? statusSource.capturedAt ?? statusPayload.checkedAt ?? statusPayload.capturedAt ?? null; +const errors = []; +let statusRecords = null; +if (Array.isArray(statusSource.records)) { + statusRecords = statusSource.records; +} else if (Array.isArray(statusSource.statuses)) { + statusRecords = []; + for (const [index, wrapper] of statusSource.statuses.entries()) { + if (!plainObject(wrapper) || wrapper.status !== "success") { + errors.push(`statuses[${index}] wrapper status 不是 success:${wrapper?.status ?? "<缺失>"}`); + continue; + } + if (!plainObject(wrapper.data)) { + errors.push(`statuses[${index}] 缺少有效 data`); + continue; + } + statusRecords.push(wrapper.data); + } +} + +for (const field of ["projectId", "batchId", "batchHash", "sourceStateRevision"]) { + if (batch[field] !== plan[field]) errors.push("batch 与 execution plan 的 " + field + " 不匹配"); +} +if (!Array.isArray(batch.items) || !batch.items.length) errors.push("image-batch.items 必须为非空数组"); +if (!Array.isArray(plan.items) || !plan.items.length) errors.push("execution-plan.items 必须为非空数组"); +if (Array.isArray(batch.items) && Array.isArray(plan.items) && batch.items.length !== plan.items.length) { + errors.push("batch 与 execution plan 的 itemCount 不匹配"); +} +if (plan.itemCount != null && plan.itemCount !== plan.items?.length) errors.push("execution-plan.itemCount 与 items 数量不匹配"); +if (!plan.approval || plan.approval.executionPlanHash !== plan.executionPlanHash || plan.approval.batchHash !== batch.batchHash) { + errors.push("execution plan 尚未按当前 planHash/batchHash 获得确认"); +} +if (!Array.isArray(statusRecords)) errors.push("awb-statuses.json 必须包含 records 数组,或原始 capture 的 statuses[].data 数组"); + +const batchById = new Map(); +for (const item of batch.items ?? []) { + if (!nonEmptyString(item?.id)) { + errors.push("batch item 缺少 id"); + continue; + } + if (batchById.has(item.id)) errors.push("batch item id 重复:" + item.id); + batchById.set(item.id, item); +} + +const planByTaskId = new Map(); +const planById = new Map(); +for (const item of plan.items ?? []) { + if (!nonEmptyString(item?.id)) { + errors.push("plan item 缺少 id"); + continue; + } + if (planById.has(item.id)) errors.push("plan item id 重复:" + item.id); + planById.set(item.id, item); + const batchItem = batchById.get(item.id); + if (!batchItem) errors.push("plan item 不属于 batch:" + item.id); + else if (item.generationKey !== batchItem.generationKey) errors.push(item.id + " 的 generationKey 在 plan 与 batch 间不匹配"); + if (!nonEmptyString(item.attemptId)) errors.push(item.id + " 缺少 attemptId"); + if (!nonEmptyString(item.taskId)) errors.push(item.id + " 缺少 taskId"); + else if (planByTaskId.has(item.taskId)) errors.push("plan taskId 重复:" + item.taskId); + else planByTaskId.set(item.taskId, item); +} +for (const id of batchById.keys()) { + if (!planById.has(id)) errors.push("batch item 未出现在 execution plan:" + id); +} + +const statusByTaskId = new Map(); +for (const record of statusRecords ?? []) { + if (!plainObject(record) || !nonEmptyString(record.taskId)) { + errors.push("status record 缺少 taskId"); + continue; + } + if (statusByTaskId.has(record.taskId)) errors.push("status taskId 重复:" + record.taskId); + statusByTaskId.set(record.taskId, record); + if (!planByTaskId.has(record.taskId)) errors.push("status 包含计划外 taskId:" + record.taskId); + if (record.modelGroupCode != null && record.modelGroupCode !== plan.modelGroupCode) { + errors.push(record.taskId + " 的 modelGroupCode 与计划不匹配"); + } + if (record.projectGroupNo != null && record.projectGroupNo !== plan.projectGroupNo) { + errors.push(record.taskId + " 的 projectGroupNo 与计划不匹配"); + } +} +for (const taskId of planByTaskId.keys()) { + if (!statusByTaskId.has(taskId)) errors.push("缺少计划 taskId 的终态记录:" + taskId); +} + +const successStates = new Set(["SUCCESS", "COMPLETED", "DONE", "SUCCEEDED"]); +const failureStates = new Set(["FAILED", "FAIL", "ERROR", "CANCEL", "CANCELED", "CANCELLED", "TIMEOUT"]); +const normalizedItems = []; +for (const batchItem of batch.items ?? []) { + const planItem = planById.get(batchItem.id); + const record = planItem ? statusByTaskId.get(planItem.taskId) : null; + if (!planItem || !record) continue; + const taskStatus = String(record.taskStatus ?? "").toUpperCase(); + if (record.isTerminal !== true || (!successStates.has(taskStatus) && !failureStates.has(taskStatus))) { + errors.push(planItem.taskId + " 不是可归一化的终态:" + (record.taskStatus ?? "<缺失>")); + continue; + } + const completedAt = record.gmtModified ?? record.completedAt ?? capturedAt; + if (!nonEmptyString(completedAt)) { + errors.push(planItem.taskId + " 缺少确定性的完成/捕获时间(gmtModified、completedAt、checkedAt 或 capturedAt)"); + continue; + } + const succeeded = successStates.has(taskStatus); + if (succeeded && (!Array.isArray(record.resultUrls) || !nonEmptyString(record.resultUrls[0]))) { + errors.push(planItem.taskId + " 成功终态缺少 resultUrls[0]"); + continue; + } + normalizedItems.push({ + id: batchItem.id, + status: succeeded ? "succeeded" : "failed", + attemptId: planItem.attemptId, + taskId: planItem.taskId, + promptHash: batchItem.promptHash, + generationKey: batchItem.generationKey, + image: succeeded ? { + url: record.resultUrls[0], + ...(Array.isArray(record.originUrls) && nonEmptyString(record.originUrls[0]) ? { originUrl: record.originUrls[0] } : {}) + } : null, + error: succeeded ? null : (nonEmptyString(record.errorMessage) ? record.errorMessage : "任务终态 " + taskStatus), + completedAt + }); +} + +if (errors.length) { + for (const error of errors) console.error("无法归一化:" + error); + process.exit(1); +} + +const results = { + projectId: batch.projectId, + batchId: batch.batchId, + sourceStateRevision: batch.sourceStateRevision, + items: normalizedItems +}; +await writeJsonAtomic(outputPath, results); +console.log("已生成 AWB 归一化结果:" + outputPath); +console.log("succeeded=" + normalizedItems.filter((item) => item.status === "succeeded").length + " failed=" + normalizedItems.filter((item) => item.status === "failed").length); diff --git a/scripts/plan_prompt_shards.mjs b/scripts/plan_prompt_shards.mjs index 01c89df..7829755 100644 --- a/scripts/plan_prompt_shards.mjs +++ b/scripts/plan_prompt_shards.mjs @@ -1,9 +1,9 @@ #!/usr/bin/env node -// 岗位四并发分片清单:确定性生成,主代理照单逐片派子代理,禁止按资产类型三分。 +// 岗位四工作清单:确定性生成审计分片、建议派发包与时效基线;禁止按资产类型三分。 // 规则:每个角色一个分片(含其全部外观版本);场景、道具按名册顺序聚类,每片不超过 maxPerShard。 import path from "node:path"; import { pathToFileURL } from "node:url"; -import { readJson, writeJsonAtomic } from "./lib/project.mjs"; +import { requiresStructuredTextPolicy, structuredTextPolicyReasons, readJson, writeJsonAtomic } from "./lib/project.mjs"; export const defaultMaxPerShard = 4; @@ -18,10 +18,11 @@ export function planPromptShards(state, options = {}) { ? options.maxPerShard : defaultMaxPerShard; const missingOnly = Boolean(options.missingOnly); - const hasBody = (asset) => Boolean(String(asset.promptBody ?? "").trim()); + const hasPromptMaterial = (record) => Boolean(String(record.asset.promptBody ?? "").trim()) + && (!requiresStructuredTextPolicy(state) || structuredTextPolicyReasons(state, record).length === 0); const shards = []; for (const character of state.assets?.characters ?? []) { - const states = (character.states ?? []).filter((asset) => !missingOnly || !hasBody(asset)); + const states = (character.states ?? []).filter((asset) => !missingOnly || !hasPromptMaterial({ asset, character, assetType: "character-state" })); if (!states.length) continue; shards.push({ shardId: "shard-character-" + character.id, @@ -32,7 +33,7 @@ export function planPromptShards(state, options = {}) { }); } for (const [group, assetType] of [["locations", "location"], ["props", "prop"]]) { - const assets = (state.assets?.[group] ?? []).filter((asset) => !missingOnly || !hasBody(asset)); + const assets = (state.assets?.[group] ?? []).filter((asset) => !missingOnly || !hasPromptMaterial({ asset, character: null, assetType })); chunk(assets, maxPerShard).forEach((members, index) => { shards.push({ shardId: `shard-${assetType}-${String(index + 1).padStart(2, "0")}`, @@ -69,12 +70,23 @@ export function planPromptShards(state, options = {}) { current.assetCount += shard.assetIds.length; } bundles.forEach((bundle, index) => { bundle.bundleId = "bundle-" + String(index + 1).padStart(2, "0"); }); + const availableWorkerSlots = Number.isInteger(options.availableWorkerSlots) && options.availableWorkerSlots > 0 + ? options.availableWorkerSlots + : 3; + const recommendedConcurrency = Math.min(bundles.length, availableWorkerSlots); + const expectedWaves = recommendedConcurrency > 0 ? Math.ceil(bundles.length / recommendedConcurrency) : 0; return { generatedAt: new Date().toISOString(), projectId: state.project?.id ?? null, sourceStateRevision: state.revision ?? 0, maxPerShard, missingOnly, + scheduling: { + availableWorkerSlots, + recommendedConcurrency, + expectedWaves, + rule: "保持同一角色上下文连续并覆盖全部 shard;禁止按资产类型三分。bundle 是建议派发单位,可按复杂度调整。" + }, summary: { ...counts, total: shards.length, @@ -94,17 +106,18 @@ function option(name, fallback = null) { if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { const [stateFile] = process.argv.slice(2); if (!stateFile || stateFile.startsWith("--")) { - console.error("用法: node plan_prompt_shards.mjs [--max-per-shard 4] [--missing-only] [--output workflow/prompt-shards.json]"); + console.error("用法: node plan_prompt_shards.mjs [--max-per-shard 4] [--available-worker-slots 3] [--missing-only] [--output workflow/prompt-shards.json]"); process.exit(1); } const state = await readJson(stateFile); const plan = planPromptShards(state, { maxPerShard: Number(option("--max-per-shard", defaultMaxPerShard)), + availableWorkerSlots: Number(option("--available-worker-slots", 3)), missingOnly: process.argv.includes("--missing-only") }); const outputFile = path.resolve(option("--output", path.join(path.dirname(path.resolve(stateFile)), "workflow", "prompt-shards.json"))); await writeJsonAtomic(outputFile, plan); console.log("岗位四分片清单已生成:" + outputFile); console.log(`分片 ${plan.summary.total} 个(角色 ${plan.summary.character} / 场景 ${plan.summary.location} / 道具 ${plan.summary.prop}),覆盖资产 ${plan.summary.assetCount} 个,打包为 ${plan.summary.bundles} 个派发包。`); - console.log("派发单位是包:每包一个子代理,完成一包立即领取下一包;分片仍按 shardId 对账。禁止按资产类型三分,也禁止一片一派。"); + console.log(`建议并发 ${plan.scheduling.recommendedConcurrency},预期 ${plan.scheduling.expectedWaves} 波(可用 worker 槽位 ${plan.scheduling.availableWorkerSlots})。bundle 是建议派发单位,可按复杂度调整;分片仍按 shardId 硬对账。禁止按资产类型三分。`); } diff --git a/scripts/plan_work_dag.mjs b/scripts/plan_work_dag.mjs index 6c29fb0..f0ad153 100644 --- a/scripts/plan_work_dag.mjs +++ b/scripts/plan_work_dag.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import path from "node:path"; import { randomUUID } from "node:crypto"; -import { executionStatus, hasCompletedArtDirection, hasCompletedSetup, hasCompletedStoryBible, hashValue, promptProfileAnchorVersion, readJson, readiness, resolvedPromptProfile, schemaVersion, setup, styleHash, writeJsonAtomic } from "./lib/project.mjs"; +import { executionStatus, hasCompletedArtDirection, hasCompletedSetup, hasCompletedStoryBible, hashValue, promptBodyFindings, promptProfileAnchorVersion, readJson, readiness, requiresStructuredTextPolicy, resolvedPromptProfile, schemaVersion, setup, styleHash, writeJsonAtomic } from "./lib/project.mjs"; function option(name, fallback) { const position = process.argv.indexOf(name); @@ -111,13 +111,16 @@ for (const chunk of chunkIndex?.chunks ?? []) { }); } +const bodyProfile = resolvedPromptProfile(state).version >= promptProfileAnchorVersion; +const promptBodyComplete = (record) => Boolean(record.asset.promptBody?.trim()) + && (!requiresStructuredTextPolicy(state) || promptBodyFindings(state, record).textPolicyReasons.length === 0); + for (const character of state.assets?.characters ?? []) { const anchor = (character.states ?? []).find((asset) => asset.sequence === 0); if (!anchor) continue; const promptJobId = "prompt-" + character.id; - const anchoredProfile = resolvedPromptProfile(state).version >= promptProfileAnchorVersion; - const promptsComplete = anchoredProfile - ? (character.states ?? []).every((asset) => Boolean(asset.promptBody?.trim())) + const promptsComplete = bodyProfile + ? (character.states ?? []).every((asset) => promptBodyComplete({ asset, character, assetType: "character-state" })) : Boolean(character.identityPrompt?.trim()) && (character.states ?? []).every((asset) => Boolean((asset.deltaPrompt ?? asset.prompt)?.trim())); addJob({ id: promptJobId, @@ -168,8 +171,8 @@ for (const [assetType, group] of [["location", "locations"], ["prop", "props"]]) const records = (state.assets?.[group] ?? []).map((asset) => ({ asset, character: null, assetType })); if (!records.length) continue; const promptJobId = "prompt-" + group; - const promptsComplete = resolvedPromptProfile(state).version >= promptProfileAnchorVersion - ? records.every((record) => Boolean(record.asset.promptBody?.trim())) + const promptsComplete = bodyProfile + ? records.every(promptBodyComplete) : records.every((record) => Boolean((record.asset.deltaPrompt ?? record.asset.prompt)?.trim())); addJob({ id: promptJobId, diff --git a/scripts/prepare_awb_execution.mjs b/scripts/prepare_awb_execution.mjs index bf81c59..0d8e4da 100644 --- a/scripts/prepare_awb_execution.mjs +++ b/scripts/prepare_awb_execution.mjs @@ -9,7 +9,7 @@ function option(name, fallback = null) { const [batchFile] = process.argv.slice(2); if (!batchFile || batchFile.startsWith("--")) { - console.error("用法: node prepare_awb_execution.mjs --model-group-code --project-group-no --estimated-cost <积分> [--output execution-plan.json]"); + console.error("用法: node prepare_awb_execution.mjs --model-group-code --project-group-no --estimated-cost <积分> [--fixed-generate-num 1] [--concurrency 4] [--expected-waves N] [--retry-policy failed-only] [--output execution-plan.json]"); process.exit(1); } @@ -17,10 +17,26 @@ const batch = await readJson(batchFile); const modelGroupCode = option("--model-group-code"); const projectGroupNo = option("--project-group-no"); const estimatedCost = Number(option("--estimated-cost")); +const fixedGenerateNum = Number(option("--fixed-generate-num", "1")); +const concurrency = Number(option("--concurrency", "4")); +const retryPolicy = option("--retry-policy", "failed-only"); +const allowedRetryPolicies = new Set(["none", "failed-only", "failed-only-once"]); if (!modelGroupCode || !projectGroupNo || !Number.isFinite(estimatedCost) || estimatedCost < 0) { console.error("缺少有效的 model-group-code、project-group-no 或 estimated-cost"); process.exit(1); } +if (!Number.isInteger(concurrency) || concurrency < 1) { + console.error("concurrency 必须为正整数"); + process.exit(1); +} +if (!Number.isInteger(fixedGenerateNum) || fixedGenerateNum < 1) { + console.error("fixed-generate-num 必须为正整数"); + process.exit(1); +} +if (!allowedRetryPolicies.has(retryPolicy)) { + console.error("retry-policy 必须为 none、failed-only 或 failed-only-once"); + process.exit(1); +} const outputFile = path.resolve(option("--output", path.join(path.dirname(path.resolve(batchFile)), "execution-plan.json"))); const taskRecordFile = path.resolve(option("--task-record-file", path.join(path.dirname(outputFile), "awb-task-record.jsonl"))); @@ -37,6 +53,12 @@ const items = (batch.items ?? []).map((item) => ({ attemptId: null, taskId: null })); +const calculatedExpectedWaves = Math.ceil(items.length / concurrency); +const expectedWaves = Number(option("--expected-waves", String(calculatedExpectedWaves))); +if (!Number.isInteger(expectedWaves) || expectedWaves < 0 || expectedWaves !== calculatedExpectedWaves) { + console.error("expected-waves 必须等于 ceil(itemCount/concurrency)=" + calculatedExpectedWaves); + process.exit(1); +} const plan = { schemaVersion, projectId: batch.projectId, @@ -48,6 +70,11 @@ const plan = { projectGroupNo, estimatedCost, itemCount: items.length, + fixedGenerateNum, + totalImageCount: items.length * fixedGenerateNum, + concurrency, + expectedWaves, + retryPolicy, taskRecordFile, preparedAt: new Date().toISOString(), approval: null, @@ -58,9 +85,15 @@ plan.executionPlanHash = hashValue({ modelGroupCode, projectGroupNo, estimatedCost, + fixedGenerateNum, + totalImageCount: items.length * fixedGenerateNum, + concurrency, + expectedWaves, + retryPolicy, items: items.map((item) => item.executionKey) }); await writeJsonAtomic(outputFile, plan); console.log("AWB 执行计划已准备:" + outputFile); console.log("executionPlanHash=" + plan.executionPlanHash + " estimatedCost=" + estimatedCost); +console.log("totalImageCount=" + plan.totalImageCount + " concurrency=" + concurrency + " expectedWaves=" + expectedWaves + " retryPolicy=" + retryPolicy); console.log("正式提交前必须展示动态模型、参数、数量、积分和重试范围,并记录用户确认。"); diff --git a/scripts/record_art_direction_brief.mjs b/scripts/record_art_direction_brief.mjs index 5781169..dfc46ab 100644 --- a/scripts/record_art_direction_brief.mjs +++ b/scripts/record_art_direction_brief.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { readFile } from "node:fs/promises"; import path from "node:path"; -import { findDedicatedDeliveryLayoutTerms, hasCompletedSetup, hasCompletedStoryBible, hashValue, isPrimarilyChinese, nextRevision, promptProfileAnchorVersion, promptProfileBodyVersion, promptProfileVersion, readJson, schemaVersion, styleAnchorMaxLength, writeJsonAtomic } from "./lib/project.mjs"; +import { findCharacterSpecificTerms, findDedicatedDeliveryLayoutTerms, hasCompletedSetup, hasCompletedStoryBible, hashValue, isPrimarilyChinese, nextRevision, promptProfileAnchorVersion, promptProfileBodyVersion, promptProfileVersion, readJson, schemaVersion, structuredTextPolicyVersion, styleAnchorMaxLength, writeJsonAtomic } from "./lib/project.mjs"; function option(name, fallback) { const position = process.argv.indexOf(name); @@ -88,7 +88,7 @@ if (usesBody) { console.error("媒介与画质章节包含资产专属交付版式:" + dedicated.join("、") + "。版式要求属于 assetPresentation 需求规格,不进媒介块。"); process.exit(1); } - if (!characterBlock && /角色|人物|肤质|发丝|毛孔/.test(content)) { + if (!characterBlock && findCharacterSpecificTerms(content, { includePores: true }).length) { console.error("媒介与画质的场景/道具适用块包含人物专属措辞(角色/人物/肤质/发丝/毛孔)。人物防漂移钉子只写在\"××·角色\"块中。"); process.exit(1); } @@ -108,7 +108,7 @@ if (usesBody) { console.error("公共风格锚点包含资产专属交付版式:" + anchorDedicatedTerms.join("、") + "。版式要求属于 assetPresentation 需求规格,不进锚点。"); process.exit(1); } - if (/角色|人物|肤质|发丝/.test(anchor)) { + if (findCharacterSpecificTerms(anchor).length) { console.error("公共风格锚点包含角色专属措辞(角色/人物/肤质/发丝)。锚点被场景和道具共享,只写媒介、世界风格与渲染质量。"); process.exit(1); } @@ -164,7 +164,8 @@ state.config.styleProfile = { state.config.promptProfile = usesBody ? { // v3 整段正文:无任何逐字拼接段;媒介与画质留在总纲文档作为岗位四输入材料。 - version: promptProfileBodyVersion + version: promptProfileBodyVersion, + textPolicyVersion: structuredTextPolicyVersion } : usesAnchor ? { diff --git a/scripts/record_execution_approval.mjs b/scripts/record_execution_approval.mjs index 63fef26..43bca76 100644 --- a/scripts/record_execution_approval.mjs +++ b/scripts/record_execution_approval.mjs @@ -18,6 +18,12 @@ if (!confirmedHash || confirmedHash !== plan.executionPlanHash) { console.error("确认 hash 不匹配;模型、费用、范围或提示词可能已变化,请重新展示计划。"); process.exit(1); } +const requestedRetryPolicy = option("--retry-policy"); +const effectiveRetryPolicy = plan.retryPolicy ?? requestedRetryPolicy ?? "failed-only"; +if (plan.retryPolicy != null && requestedRetryPolicy != null && requestedRetryPolicy !== plan.retryPolicy) { + console.error("retry-policy 与 executionPlanHash 已锁定范围不匹配:计划为 " + plan.retryPolicy + ",请求为 " + requestedRetryPolicy); + process.exit(1); +} // --auto:消费用户的持久授权(config.authorizations.autoApproveBelow)。仅当预计消耗不超过阈值时可自动确认; // 快照与哈希照常留档。授权由 record_standing_authorizations.mjs 在用户明确同意后写入。 let approvedBy = "user"; @@ -44,10 +50,10 @@ plan.approval = { batchHash: plan.batchHash, approvedAt: new Date().toISOString(), approvedBy, - retryPolicy: option("--retry-policy", "failed-only"), + retryPolicy: effectiveRetryPolicy, approvalHash: hashValue({ executionPlanHash: plan.executionPlanHash, - retryPolicy: option("--retry-policy", "failed-only") + retryPolicy: effectiveRetryPolicy }) }; await writeJsonAtomic(planFile, plan); diff --git a/scripts/validate_project_state.mjs b/scripts/validate_project_state.mjs index 52b719d..424c79f 100644 --- a/scripts/validate_project_state.mjs +++ b/scripts/validate_project_state.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { artDirection, assetPresentation, assetPresentationScope, assetPresentationVersion, defaultState, executionStatus, findAnchorStyleTerms, findMismatchedRatioTerms, findUnspecifiedImageTextTerms, hasCompletedArtDirection, hasCompletedSetup, hasCompletedStoryBible, hashValue, imageLocator, imageSettingsForRecord, isPrimarilyChinese, listAssets, promptProfileAnchorVersion, promptProfileBodyVersion, promptProfileVersion, readJson, readiness, resolvedPromptProfile, schemaVersion, setup, storyBible, styleAnchorMaxLength } from "./lib/project.mjs"; +import { artDirection, assetPresentation, assetPresentationScope, assetPresentationVersion, defaultState, executionStatus, findCharacterSpecificTerms, hasCompletedArtDirection, hasCompletedSetup, hasCompletedStoryBible, hashValue, imageLocator, imageSettingsForRecord, isPrimarilyChinese, listAssets, promptBodyFindings, promptProfileAnchorVersion, promptProfileBodyVersion, promptProfileVersion, readJson, readiness, requiresStructuredTextPolicy, resolvedPromptProfile, schemaVersion, setup, storyBible, structuredTextPolicyVersion, styleAnchorMaxLength } from "./lib/project.mjs"; import { access, readFile } from "node:fs/promises"; import path from "node:path"; @@ -128,11 +128,14 @@ const promptProfile = resolvedPromptProfile(state); if (state.config?.promptProfile?.version === promptProfileBodyVersion) { // v3 整段正文:promptProfile 只承载版本标记,不得残留拼接字段。 assert(state.config.promptProfile.styleAnchor == null, "promptProfile v3 不承载 styleAnchor(无逐字拼接段)"); + if (state.config.promptProfile.textPolicyVersion != null) { + assert(state.config.promptProfile.textPolicyVersion === structuredTextPolicyVersion, "promptProfile.textPolicyVersion 必须为 " + structuredTextPolicyVersion); + } } else if (state.config?.promptProfile?.version === promptProfileAnchorVersion) { assert(typeof state.config.promptProfile.styleAnchor === "string" && state.config.promptProfile.styleAnchor.trim(), "promptProfile.styleAnchor 必须为非空字符串"); assert(promptProfile.styleAnchor.length <= styleAnchorMaxLength, "公共风格锚点超过 " + styleAnchorMaxLength + " 字上限"); assert(promptProfile.removedDedicatedTerms.length === 0, "公共风格锚点不得包含资产专属交付版式:" + promptProfile.removedDedicatedTerms.join("、")); - assert(!/角色|人物|肤质|发丝/.test(promptProfile.styleAnchor), "公共风格锚点不得包含角色专属措辞(被场景和道具共享)"); + assert(findCharacterSpecificTerms(promptProfile.styleAnchor).length === 0, "公共风格锚点不得包含角色专属措辞(被场景和道具共享)"); if (state.config?.promptLanguage === "zh-CN" && promptProfile.styleAnchor) { assert(isPrimarilyChinese(promptProfile.styleAnchor), "公共风格锚点必须以中文为主"); } @@ -185,15 +188,16 @@ for (const record of listAssets(state)) { if (bodyProfile) { // v2/v3:提示词正文是发给模型的正文;deltaPrompt/identityPrompt 退为岗位四的输入材料,不再强制。 if (asset.promptBody?.trim()) { - if (state.config?.promptLanguage === "zh-CN") assert(isPrimarilyChinese(asset.promptBody), asset.id + " 的 promptBody 必须以中文为主"); + const findings = promptBodyFindings(state, record); + assert(!findings.nonChinese, asset.id + " 的 promptBody 必须以中文为主"); if (anchoredProfile) { - const drift = findAnchorStyleTerms(asset.promptBody); - assert(drift.length === 0, asset.id + " 的 promptBody 包含应由公共风格锚点承载的风格词:" + drift.join("、")); + assert(findings.driftTerms.length === 0, asset.id + " 的 promptBody 包含应由公共风格锚点承载的风格词:" + findings.driftTerms.join("、")); } else { - const unspecifiedText = findUnspecifiedImageTextTerms(asset.promptBody); - assert(unspecifiedText.length === 0, asset.id + " 的 promptBody 要求画面出现可读文字(" + unspecifiedText.join("、") + ")但未指定文字语言或声明不可读"); - const mismatchedRatios = findMismatchedRatioTerms(asset.promptBody, assetPresentation(state, record)?.ratio); - assert(mismatchedRatios.length === 0, asset.id + " 的 promptBody 中的画幅比例(" + mismatchedRatios.join("、") + ")与呈现配置不一致"); + if (requiresStructuredTextPolicy(state)) { + assert(findings.textPolicyReasons.length === 0, asset.id + " 的 textPolicy 无效:" + findings.textPolicyReasons.join("、")); + } + assert(findings.unspecifiedText.length === 0, asset.id + " 的 promptBody 要求画面出现可读文字(" + findings.unspecifiedText.join("、") + ")但未指定文字语言或声明不可读"); + assert(findings.mismatchedRatios.length === 0, asset.id + " 的 promptBody 中的画幅比例(" + findings.mismatchedRatios.join("、") + ")与呈现配置不一致"); } } } else if (state.config?.promptLanguage === "zh-CN") { diff --git a/scripts/workspace_server.mjs b/scripts/workspace_server.mjs index f5b5fce..2ce5ff4 100644 --- a/scripts/workspace_server.mjs +++ b/scripts/workspace_server.mjs @@ -7,7 +7,7 @@ import { createServer } from "node:http"; import { readFile, realpath, stat } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { hashValue, readJson, schemaVersion, writeJsonAtomic } from "./lib/project.mjs"; +import { hashValue, readJson, resolvedPromptProfile, schemaVersion, writeJsonAtomic } from "./lib/project.mjs"; function option(name, fallback) { const position = process.argv.indexOf(name); @@ -198,6 +198,26 @@ async function optionalJson(file) { } } +// Viewer 只需要兼容解析后的只读投影。旧 render 的版式污染由后端唯一解析器过滤, +// 避免浏览器维护另一份正则;canonical /api/project-state 仍原样提供。 +function projectStateForViewer(state) { + const rawProfile = state.config?.promptProfile ?? {}; + const versioned = [1, 2, 3].includes(rawProfile.version); + if (versioned) return state; + const profile = resolvedPromptProfile(state); + return { + ...state, + config: { + ...(state.config ?? {}), + promptProfile: { + version: 1, + commonVisualLanguage: profile.commonVisualLanguage, + globalRenderLanguage: profile.globalRenderLanguage + } + } + }; +} + async function serveFile(response, canonicalRoot, requested) { const file = await readableFileInside(canonicalRoot, requested); const content = await readFile(file); @@ -227,6 +247,16 @@ const server = createServer(async (request, response) => { return send(response, 200, content, "application/json; charset=utf-8", { ETag: etag, "Cache-Control": "no-cache" }); } + if (request.method === "GET" && url.pathname === "/api/view-state") { + const content = JSON.stringify(projectStateForViewer(await readJson(statePath))); + const etag = etagFor(content); + if (etagMatches(request, etag)) { + response.writeHead(304, { ETag: etag, "Cache-Control": "no-cache" }); + return response.end(); + } + return send(response, 200, content, "application/json; charset=utf-8", { ETag: etag, "Cache-Control": "no-cache" }); + } + if (request.method === "GET" && url.pathname === "/api/setup") { const latest = await readJson(statePath); const decisions = await optionalJson(await setupPathFor(latest, true)); diff --git a/tests/authoring-templates.test.mjs b/tests/authoring-templates.test.mjs new file mode 100644 index 0000000..3eab45b --- /dev/null +++ b/tests/authoring-templates.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { hashValue } from "../scripts/lib/project.mjs"; + +const execFileAsync = promisify(execFile); +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +async function withProject(run) { + const directory = await mkdtemp(path.join(os.tmpdir(), "comic-assets-authoring-template-")); + const source = "第1集第1场:测试。"; + const state = { + schemaVersion: 3, + revision: 1, + project: { id: "authoring-template-test", sourceScript: "剧本.md", sourceHash: hashValue(source) }, + config: { + visualPreset: { + id: "live-action", + name: "真人通用", + positioning: "超写实真人向", + stylePromptSeed: "商业摄影级真人实拍质感。" + } + }, + workflow: { appliedOps: [] }, + assets: { characters: [], locations: [], props: [] } + }; + await writeFile(path.join(directory, "剧本.md"), source, "utf8"); + const statePath = path.join(directory, "project-state.json"); + await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); + try { + await run({ directory, statePath }); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} + +test("故事圣经模板把梗概设为软预算并压缩成资产事实", async () => { + await withProject(async ({ directory, statePath }) => { + await execFileAsync(process.execPath, [path.join(root, "scripts/bootstrap_story_bible.mjs"), statePath]); + const markdown = await readFile(path.join(directory, "story-asset-bible.md"), "utf8"); + assert.match(markdown, /默认目标不超过 300 个汉字/); + assert.match(markdown, /复杂长剧最多 600 个汉字/); + assert.match(markdown, /软预算,不作脚本硬门禁/); + assert.match(markdown, /不复述逐集情节/); + assert.match(markdown, /不写人物小传/); + }); +}); + +test("美术总纲模板保留 F02 并把局部气质与角色差异移出媒介核", async () => { + await withProject(async ({ directory, statePath }) => { + await execFileAsync(process.execPath, [path.join(root, "scripts/bootstrap_art_direction_brief.mjs"), statePath]); + const markdown = await readFile(path.join(directory, "art-direction-brief.md"), "utf8"); + assert.match(markdown, /F02 CONTRACT/); + assert.match(markdown, /不写项目题材或全局情绪结论/); + assert.match(markdown, /^## 世界语言与局部气质分流$/m); + assert.match(markdown, /当前资产所需的一句局部气质(localTone)/); + assert.match(markdown, /^#### 核心角色差异矩阵(只覆盖高碰撞角色)$/m); + assert.match(markdown, /至少有三项非颜色差异/); + }); +}); diff --git a/tests/authorizations.test.mjs b/tests/authorizations.test.mjs index e7e6c1c..d6d188a 100644 --- a/tests/authorizations.test.mjs +++ b/tests/authorizations.test.mjs @@ -19,7 +19,7 @@ function baseState() { config: { promptLanguage: "zh-CN", styleProfile: { id: "s", name: "测试", contentHash: "h" }, - promptProfile: { version: 3 }, + promptProfile: { version: 3, textPolicyVersion: 1 }, image: { quality: "high" }, assetPresentation: { version: 1, @@ -43,6 +43,7 @@ function baseState() { sequence: 0, status: "needs_review", review: { promptStatus: "needs_review" }, + textPolicy: { mode: "none" }, promptBody: mediaSentence + "白色无缝背景,东亚面貌成年男性,写实七至八头身,素色长袍。" }] }], @@ -52,6 +53,7 @@ function baseState() { name: "带字标签道具", status: "needs_review", review: { promptStatus: "needs_review" }, + textPolicy: { mode: "readable", language: "英文", content: ["Daffodil"] }, promptBody: mediaSentence + "单一道具主体为洗旧纸袋,标签为英文 \"Daffodil\",封口结构清晰。" }] } diff --git a/tests/awb-batch-mapper.test.mjs b/tests/awb-batch-mapper.test.mjs new file mode 100644 index 0000000..cd93f1a --- /dev/null +++ b/tests/awb-batch-mapper.test.mjs @@ -0,0 +1,146 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { hashValue } from "../scripts/lib/project.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const script = path.join(root, "scripts/map_awb_image_batch.mjs"); + +function item(id, prompt, image, reference = null) { + return { + id, + prompt, + promptHash: hashValue(prompt), + generationKey: hashValue({ id, prompt, image }), + image, + reference + }; +} + +function batch(items) { + const core = { + projectId: "project-awb-mapper-test", + sourceStateRevision: 7, + styleHash: "style-awb-mapper-test", + scope: "all", + image: { quality: "2k" }, + items + }; + return { + schemaVersion: 3, + batchId: "batch-awb-mapper-test", + batchHash: hashValue(core), + ...core + }; +} + +async function fixture(t, value) { + const directory = await mkdtemp(path.join(tmpdir(), "comic-awb-map-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const input = path.join(directory, "image-batch.json"); + const output = path.join(directory, "awb-image-input.jsonl"); + const mapping = path.join(directory, "awb-image-input.mapping.json"); + await writeFile(input, JSON.stringify(value, null, 2)); + const run = () => spawnSync(process.execPath, [script, input, "--output", output, "--mapping", mapping], { encoding: "utf8" }); + return { input, output, mapping, run }; +} + +test("确定性映射 canonical batch,并保留逐行审计绑定", async (t) => { + const first = item("asset-a", "第一条中文提示词。", { ratio: "3:4", quality: "2k" }); + const second = item( + "asset-b", + "第二条中文提示词。", + { ratio: "4:3", quality: "2k", model_params: { seed: 17 } }, + { artifactId: "artifact-anchor", image: { backendPath: "material/reference.png" } } + ); + const files = await fixture(t, batch([first, second])); + + const result = files.run(); + assert.equal(result.status, 0, result.stderr); + const jsonl = await readFile(files.output, "utf8"); + const lines = jsonl.trimEnd().split("\n").map(JSON.parse); + assert.deepEqual(lines[0], { prompt: first.prompt, ratio: "3:4", quality: "2k" }); + assert.deepEqual(lines[1], { + prompt: second.prompt, + ratio: "4:3", + quality: "2k", + resources: [{ type: "image", usage: "reference", source: { kind: "url", value: "material/reference.png" } }], + model_params: { seed: 17 } + }); + + const sidecar = JSON.parse(await readFile(files.mapping, "utf8")); + assert.equal(sidecar.itemCount, 2); + assert.equal(sidecar.totalImageCount, 2); + assert.deepEqual(sidecar.quantity, { source: "model-fixed", resolvedGenerateNum: 1 }); + assert.equal(sidecar.jsonlHash, hashValue(jsonl)); + assert.deepEqual( + sidecar.lines.map(({ lineNumber, itemId, generationKey, promptHash, ratio, quality, resolvedGenerateNum, quantitySource, referenceArtifactId }) => ({ + lineNumber, itemId, generationKey, promptHash, ratio, quality, resolvedGenerateNum, quantitySource, referenceArtifactId + })), + [ + { lineNumber: 1, itemId: first.id, generationKey: first.generationKey, promptHash: first.promptHash, ratio: "3:4", quality: "2k", resolvedGenerateNum: 1, quantitySource: "model-fixed", referenceArtifactId: null }, + { lineNumber: 2, itemId: second.id, generationKey: second.generationKey, promptHash: second.promptHash, ratio: "4:3", quality: "2k", resolvedGenerateNum: 1, quantitySource: "model-fixed", referenceArtifactId: "artifact-anchor" } + ] + ); + + const firstMapping = await readFile(files.mapping, "utf8"); + assert.equal(files.run().status, 0); + assert.equal(await readFile(files.output, "utf8"), jsonl); + assert.equal(await readFile(files.mapping, "utf8"), firstMapping, "同一 canonical batch 必须得到逐字相同的输出与 sidecar"); +}); + +test("拒绝重复 id、重复 generationKey 与 promptHash 不匹配", async (t) => { + const first = item("asset-a", "第一条中文提示词。", { ratio: "3:4", quality: "2k" }); + const second = { ...item("asset-a", "第二条中文提示词。", { ratio: "4:3", quality: "2k" }), generationKey: first.generationKey, promptHash: "wrong" }; + const files = await fixture(t, batch([first, second])); + const result = files.run(); + assert.equal(result.status, 1); + assert.match(result.stderr, /item id 重复:asset-a/); + assert.match(result.stderr, /generationKey 重复/); + assert.match(result.stderr, /promptHash 与 prompt 不匹配/); +}); + +test("拒绝缺少 ratio/quality、未知图片字段和不稳定参考定位", async (t) => { + const invalid = item( + "asset-invalid", + "中文提示词。", + { quality: "", unsupported: true }, + { artifactId: "artifact-anchor", image: { url: "https://signed.example/image.png" } } + ); + const value = batch([invalid]); + value.image.unsupported = true; + const files = await fixture(t, value); + const result = files.run(); + assert.equal(result.status, 1); + assert.match(result.stderr, /batch\.image 含未支持字段:unsupported/); + assert.match(result.stderr, /image 含未支持字段:unsupported/); + assert.match(result.stderr, /image 缺少 ratio/); + assert.match(result.stderr, /image 缺少 quality/); + assert.match(result.stderr, /缺少稳定 backendPath/); +}); + +test("拒绝 batchHash 与 canonical 内容不匹配", async (t) => { + const value = batch([item("asset-a", "中文提示词。", { ratio: "3:4", quality: "2k" })]); + value.items[0].prompt = "锁定后被修改的提示词。"; + value.items[0].promptHash = hashValue(value.items[0].prompt); + const files = await fixture(t, value); + const result = files.run(); + assert.equal(result.status, 1); + assert.match(result.stderr, /batchHash 与 canonical 批次内容不匹配/); +}); + +test("拒绝覆盖 canonical 输入或让 JSONL 与 sidecar 指向同一文件", async (t) => { + const value = batch([item("asset-a", "中文提示词。", { ratio: "3:4", quality: "2k" })]); + const files = await fixture(t, value); + const overwriteInput = spawnSync(process.execPath, [script, files.input, "--output", files.input], { encoding: "utf8" }); + assert.equal(overwriteInput.status, 1); + assert.match(overwriteInput.stderr, /禁止覆盖 canonical 批次/); + + const sameOutputs = spawnSync(process.execPath, [script, files.input, "--output", files.output, "--mapping", files.output], { encoding: "utf8" }); + assert.equal(sameOutputs.status, 1); + assert.match(sameOutputs.stderr, /必须是三个不同文件/); +}); diff --git a/tests/awb-result-normalizer.test.mjs b/tests/awb-result-normalizer.test.mjs new file mode 100644 index 0000000..35331a1 --- /dev/null +++ b/tests/awb-result-normalizer.test.mjs @@ -0,0 +1,191 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { hashValue } from "../scripts/lib/project.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const script = path.join(root, "scripts/normalize_awb_image_results.mjs"); + +function fixtureData() { + const items = ["asset-a", "asset-b"].map((id) => ({ + id, + promptHash: hashValue("prompt-" + id), + generationKey: hashValue("generation-" + id) + })); + const batch = { + projectId: "project-normalize-test", + batchId: "batch-normalize-test", + batchHash: hashValue(items), + sourceStateRevision: 5, + items + }; + const executionPlanHash = hashValue({ batchHash: batch.batchHash, test: true }); + const plan = { + projectId: batch.projectId, + batchId: batch.batchId, + batchHash: batch.batchHash, + sourceStateRevision: batch.sourceStateRevision, + modelGroupCode: "model-test", + projectGroupNo: "group-test", + itemCount: 2, + executionPlanHash, + approval: { executionPlanHash, batchHash: batch.batchHash }, + items: [ + { id: "asset-a", generationKey: items[0].generationKey, attemptId: "attempt-a", taskId: "task-a" }, + { id: "asset-b", generationKey: items[1].generationKey, attemptId: "attempt-b", taskId: "task-b" } + ] + }; + const signedUrl = "https://example.test/image-a.png?sign=abc%2F123&expires=999"; + const statuses = { + checkedAt: "2026-07-20T10:00:00.000Z", + records: [ + { + taskId: "task-b", + taskStatus: "FAILED", + isTerminal: true, + errorMessage: "provider rejected", + modelGroupCode: "model-test", + projectGroupNo: "group-test" + }, + { + taskId: "task-a", + taskStatus: "SUCCESS", + isTerminal: true, + resultUrls: [signedUrl], + originUrls: ["https://origin.test/image-a.png"], + modelGroupCode: "model-test", + projectGroupNo: "group-test" + } + ] + }; + return { batch, plan, statuses, signedUrl }; +} + +async function fixture(t, mutate = () => {}) { + const directory = await mkdtemp(path.join(tmpdir(), "comic-awb-normalize-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const data = fixtureData(); + mutate(data); + const batchFile = path.join(directory, "image-batch.json"); + const planFile = path.join(directory, "execution-plan.json"); + const statusesFile = path.join(directory, "awb-statuses.json"); + const outputFile = path.join(directory, "awb-results.json"); + await Promise.all([ + writeFile(batchFile, JSON.stringify(data.batch, null, 2)), + writeFile(planFile, JSON.stringify(data.plan, null, 2)), + writeFile(statusesFile, JSON.stringify(data.statuses, null, 2)) + ]); + const run = () => spawnSync(process.execPath, [script, batchFile, planFile, statusesFile, "--output", outputFile], { encoding: "utf8" }); + return { ...data, outputFile, run }; +} + +test("按 taskId 而非记录顺序归一化,并原样保留带签名 resultUrls[0]", async (t) => { + const files = await fixture(t); + const result = files.run(); + assert.equal(result.status, 0, result.stderr); + const normalized = JSON.parse(await readFile(files.outputFile, "utf8")); + assert.equal(normalized.projectId, files.batch.projectId); + assert.deepEqual(normalized.items.map((item) => item.id), ["asset-a", "asset-b"], "输出顺序必须跟随 canonical batch"); + assert.equal(normalized.items[0].taskId, "task-a"); + assert.equal(normalized.items[0].status, "succeeded"); + assert.equal(normalized.items[0].image.url, files.signedUrl); + assert.equal(normalized.items[0].completedAt, files.statuses.checkedAt); + assert.equal(normalized.items[1].taskId, "task-b"); + assert.equal(normalized.items[1].status, "failed"); + assert.equal(normalized.items[1].image, null); + assert.equal(normalized.items[1].error, "provider rejected"); + + const firstOutput = await readFile(files.outputFile, "utf8"); + assert.equal(files.run().status, 0); + assert.equal(await readFile(files.outputFile, "utf8"), firstOutput, "相同输入必须产生逐字相同结果"); +}); + +test("兼容原始 task status wrapper capture 形态", async (t) => { + const files = await fixture(t, ({ statuses }) => { + statuses.capturedAt = statuses.checkedAt; + delete statuses.checkedAt; + statuses.statuses = statuses.records.map((data, index) => ({ + status: "success", + data, + meta: { inputIndex: index } + })); + delete statuses.records; + }); + const result = files.run(); + assert.equal(result.status, 0, result.stderr); + const normalized = JSON.parse(await readFile(files.outputFile, "utf8")); + assert.deepEqual(normalized.items.map((item) => item.id), ["asset-a", "asset-b"]); + assert.equal(normalized.items[0].image.url, files.signedUrl); + assert.equal(normalized.items[0].completedAt, files.statuses.capturedAt); +}); + +test("原始 capture 的 wrapper status 非 success 时拒绝", async (t) => { + const files = await fixture(t, ({ statuses }) => { + statuses.statuses = statuses.records.map((data, index) => ({ + status: index === 0 ? "error" : "success", + data, + meta: { inputIndex: index } + })); + delete statuses.records; + }); + const result = files.run(); + assert.equal(result.status, 1); + assert.match(result.stderr, /wrapper status 不是 success:error/); +}); + +test("拒绝非终态与成功但缺少 resultUrls[0]", async (t) => { + const pending = await fixture(t, ({ statuses }) => { + statuses.records[0].taskStatus = "PROCESSING"; + statuses.records[0].isTerminal = false; + statuses.records[1].resultUrls = []; + }); + const result = pending.run(); + assert.equal(result.status, 1); + assert.match(result.stderr, /不是可归一化的终态/); + assert.match(result.stderr, /成功终态缺少 resultUrls\[0\]/); +}); + +test("拒绝重复、缺失或计划外 taskId", async (t) => { + const files = await fixture(t, ({ statuses }) => { + statuses.records.push({ ...statuses.records[0] }); + statuses.records = statuses.records.filter((record) => record.taskId !== "task-a"); + statuses.records.push({ taskId: "task-extra", taskStatus: "FAILED", isTerminal: true }); + }); + const result = files.run(); + assert.equal(result.status, 1); + assert.match(result.stderr, /status taskId 重复:task-b/); + assert.match(result.stderr, /status 包含计划外 taskId:task-extra/); + assert.match(result.stderr, /缺少计划 taskId 的终态记录:task-a/); +}); + +test("拒绝 batch/plan 绑定断裂和未确认计划", async (t) => { + const files = await fixture(t, ({ plan }) => { + plan.batchHash = "wrong-batch-hash"; + plan.approval = null; + }); + const result = files.run(); + assert.equal(result.status, 1); + assert.match(result.stderr, /batch 与 execution plan 的 batchHash 不匹配/); + assert.match(result.stderr, /execution plan 尚未按当前 planHash\/batchHash 获得确认/); +}); + +test("拒绝覆盖任一输入文件", async (t) => { + const directory = await mkdtemp(path.join(tmpdir(), "comic-awb-normalize-overwrite-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const data = fixtureData(); + const batchFile = path.join(directory, "image-batch.json"); + const planFile = path.join(directory, "execution-plan.json"); + const statusesFile = path.join(directory, "awb-statuses.json"); + await Promise.all([ + writeFile(batchFile, JSON.stringify(data.batch)), + writeFile(planFile, JSON.stringify(data.plan)), + writeFile(statusesFile, JSON.stringify(data.statuses)) + ]); + const result = spawnSync(process.execPath, [script, batchFile, planFile, statusesFile, "--output", statusesFile], { encoding: "utf8" }); + assert.equal(result.status, 1); + assert.match(result.stderr, /输出文件不得覆盖/); +}); diff --git a/tests/execution-plan.test.mjs b/tests/execution-plan.test.mjs new file mode 100644 index 0000000..9b20ff4 --- /dev/null +++ b/tests/execution-plan.test.mjs @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { hashValue } from "../scripts/lib/project.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const prepareScript = path.join(root, "scripts/prepare_awb_execution.mjs"); +const approveScript = path.join(root, "scripts/record_execution_approval.mjs"); + +function batch(itemCount = 8) { + const items = Array.from({ length: itemCount }, (_, index) => ({ + id: `asset-${index + 1}`, + generationKey: hashValue({ index }), + image: { ratio: index < 4 ? "3:4" : "4:3", quality: "2k" } + })); + return { + schemaVersion: 3, + projectId: "project-execution-plan-test", + batchId: "batch-execution-plan-test", + batchHash: hashValue(items), + sourceStateRevision: 3, + items + }; +} + +async function fixture(t, itemCount = 8) { + const directory = await mkdtemp(path.join(tmpdir(), "comic-execution-plan-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const batchFile = path.join(directory, "image-batch.json"); + const planFile = path.join(directory, "execution-plan.json"); + await writeFile(batchFile, JSON.stringify(batch(itemCount), null, 2)); + const prepare = (...extra) => spawnSync(process.execPath, [ + prepareScript, + batchFile, + "--model-group-code", "gpt-image-test", + "--project-group-no", "7001", + "--estimated-cost", "80", + "--output", planFile, + ...extra + ], { encoding: "utf8" }); + return { batchFile, planFile, prepare }; +} + +test("执行计划默认固化并发、预期波数和重试策略并纳入 hash", async (t) => { + const files = await fixture(t, 9); + const first = files.prepare(); + assert.equal(first.status, 0, first.stderr); + let plan = JSON.parse(await readFile(files.planFile, "utf8")); + assert.equal(plan.concurrency, 4); + assert.equal(plan.expectedWaves, 3); + assert.equal(plan.retryPolicy, "failed-only"); + assert.equal(plan.fixedGenerateNum, 1); + assert.equal(plan.totalImageCount, 9); + const defaultHash = plan.executionPlanHash; + + const explicit = files.prepare("--concurrency", "3", "--expected-waves", "3", "--retry-policy", "failed-only-once"); + assert.equal(explicit.status, 0, explicit.stderr); + plan = JSON.parse(await readFile(files.planFile, "utf8")); + assert.equal(plan.concurrency, 3); + assert.equal(plan.expectedWaves, 3); + assert.equal(plan.retryPolicy, "failed-only-once"); + assert.notEqual(plan.executionPlanHash, defaultHash, "调度或重试范围变化必须产生新 executionPlanHash"); + + const sameAgain = files.prepare("--concurrency", "3", "--expected-waves", "3", "--retry-policy", "failed-only-once"); + assert.equal(sameAgain.status, 0, sameAgain.stderr); + assert.equal(JSON.parse(await readFile(files.planFile, "utf8")).executionPlanHash, plan.executionPlanHash); +}); + +test("执行计划拒绝无效并发、不一致波数和未知重试策略", async (t) => { + const files = await fixture(t, 8); + const badConcurrency = files.prepare("--concurrency", "0"); + assert.equal(badConcurrency.status, 1); + assert.match(badConcurrency.stderr, /concurrency 必须为正整数/); + + const badQuantity = files.prepare("--fixed-generate-num", "0"); + assert.equal(badQuantity.status, 1); + assert.match(badQuantity.stderr, /fixed-generate-num 必须为正整数/); + + const badWaves = files.prepare("--concurrency", "3", "--expected-waves", "2"); + assert.equal(badWaves.status, 1); + assert.match(badWaves.stderr, /expected-waves 必须等于/); + + const badRetry = files.prepare("--retry-policy", "retry-everything"); + assert.equal(badRetry.status, 1); + assert.match(badRetry.stderr, /retry-policy 必须为/); +}); + +test("确认默认消费计划内 retryPolicy,禁止用命令行覆盖已哈希范围", async (t) => { + const files = await fixture(t, 8); + assert.equal(files.prepare("--retry-policy", "failed-only-once").status, 0); + let plan = JSON.parse(await readFile(files.planFile, "utf8")); + + const mismatch = spawnSync(process.execPath, [ + approveScript, + files.planFile, + "--confirm-plan-hash", plan.executionPlanHash, + "--retry-policy", "none" + ], { encoding: "utf8" }); + assert.equal(mismatch.status, 1); + assert.match(mismatch.stderr, /与 executionPlanHash 已锁定范围不匹配/); + + const approved = spawnSync(process.execPath, [ + approveScript, + files.planFile, + "--confirm-plan-hash", plan.executionPlanHash + ], { encoding: "utf8" }); + assert.equal(approved.status, 0, approved.stderr); + plan = JSON.parse(await readFile(files.planFile, "utf8")); + assert.equal(plan.approval.retryPolicy, "failed-only-once"); +}); + +test("旧 execution plan 缺少 retryPolicy 时保持原 CLI 兼容", async (t) => { + const directory = await mkdtemp(path.join(tmpdir(), "comic-legacy-plan-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const planFile = path.join(directory, "execution-plan.json"); + await writeFile(planFile, JSON.stringify({ executionPlanHash: "legacy-hash", batchHash: "legacy-batch", estimatedCost: 8 }, null, 2)); + const approved = spawnSync(process.execPath, [ + approveScript, + planFile, + "--confirm-plan-hash", "legacy-hash", + "--retry-policy", "failed-only-once" + ], { encoding: "utf8" }); + assert.equal(approved.status, 0, approved.stderr); + const plan = JSON.parse(await readFile(planFile, "utf8")); + assert.equal(plan.approval.retryPolicy, "failed-only-once"); +}); diff --git a/tests/prompt-audit.test.mjs b/tests/prompt-audit.test.mjs new file mode 100644 index 0000000..86ccb1a --- /dev/null +++ b/tests/prompt-audit.test.mjs @@ -0,0 +1,222 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { auditPromptBodies } from "../scripts/audit_prompt_bodies.mjs"; + +const execFileAsync = promisify(execFile); +const characterMedia = "三维角色媒介核,完整体积与材质转折,跨资产身份一致。"; +const environmentMedia = "三维环境与器物媒介核,物理光照稳定,材质层级清楚。"; +const characterPresentation = "白色无缝背景的角色资产设定板,正面胸像特写、正面全身图与三分之四背面全身图清楚展示,人物比例与服装一致。"; +const locationPresentation = "单一场景环境设定图,宽幅构图,空间结构清楚。"; +const propPresentation = "单一道具设定图,主体结构与材质清楚。"; + +function sampleState() { + const sharedAppearance = "成年东亚女性,柔和椭圆脸与内收下颌,黑色齐肩长发,温润眉眼,体态纤细,姿态略内收,妆容自然,圆领针织上衣。"; + return { + schemaVersion: 3, + revision: 8, + project: { id: "prompt-audit-test" }, + config: { + promptProfile: { version: 3, textPolicyVersion: 1 }, + assetPresentation: { + version: 1, + characterDefault: { id: "character-board", ratio: "4:3", prompt: characterPresentation, requirements: [] }, + characterState: { id: "character-board-state", ratio: "4:3", prompt: characterPresentation, requirements: [] }, + location: { id: "location", ratio: "16:9", prompt: locationPresentation, requirements: [] }, + prop: { id: "prop", ratio: "4:3", prompt: propPresentation, requirements: [] } + } + }, + assets: { + characters: [ + { + id: "character-a", + name: "甲", + states: [{ + id: "character-a-default", + name: "默认", + sequence: 0, + promptBody: `${characterMedia}${sharedAppearance}她是姐姐,封印解除后双眼复明。正面全身图穿黑色长袍、银色腰带;背面全身图穿黑色长袍、银色腰带。${characterPresentation}`, + textPolicy: { mode: "none" } + }] + }, + { + id: "character-b", + name: "乙", + states: [{ + id: "character-b-default", + name: "默认", + sequence: 0, + promptBody: `${characterMedia}${sharedAppearance}${characterPresentation}`, + textPolicy: { mode: "decorative" } + }] + } + ], + locations: [{ + id: "location-a", + name: "长场景", + promptBody: `${environmentMedia}${"风化黑石与低压空间层次".repeat(30)}。${locationPresentation}`, + textPolicy: { mode: "readable", language: "简体中文", content: ["出口"] } + }], + props: [{ + id: "prop-a", + name: "木牌", + promptBody: `${environmentMedia}旧木牌,简体中文“出口”。${propPresentation}`, + textPolicy: { mode: "readable", language: "简体中文", content: ["出口"] } + }, { + id: "prop-empty", + name: "无正文" + }] + } + }; +} + +const brief = `# 项目创作方向 + +## 媒介与画质 + +- **主维度·角色**:${characterMedia} +- **主维度·场景与道具**:${environmentMedia} + +## 人物群体呈现 +`; + +test("静态审计报告覆盖 F02、噪声、软预算、角色碰撞与 textPolicy 分布", async () => { + const report = await auditPromptBodies(sampleState(), { artDirectionMarkdown: brief, artDirectionPath: "/tmp/art-direction-brief.md" }); + + assert.equal(report.assetsWithPromptBody.total, 4); + assert.equal(report.assetsWithPromptBody.assetsWithoutPromptBody, 1); + assert.equal(report.f02.status, "supported"); + assert.equal(report.f02.byAssetType["character-state"].exactPrefixMatches, 2); + assert.equal(report.f02.byAssetType.location.exactPrefixMatches, 1); + assert.equal(report.f02.byAssetType.prop.exactPrefixMatches, 1); + + assert.deepEqual(report.observations.relationshipTerms.assets[0].hits.map((hit) => hit.match), ["姐姐", "是姐姐"]); + assert.ok(report.observations.narrativeTerms.assets[0].hits.some((hit) => hit.match === "解除后")); + assert.deepEqual(report.observations.perViewRepetition.assets[0].repeatedTokens.sort(), ["腰带", "银色", "长袍", "黑色"].sort()); + + assert.equal(report.variableBodyLength.supportedAssetCount, 4); + assert.equal(report.variableBodyLength.unsupportedAssetCount, 0); + assert.deepEqual(report.variableBodyLength.overSoftBudget.map((item) => item.assetId), ["location-a"]); + assert.equal(report.variableBodyLength.overSoftBudget[0].presentationRemoval, "exact_configured_prompt"); + + assert.equal(report.characterCollision.pairCount, 1); + assert.equal(report.characterCollision.pairs[0].reason, "face_and_hair_lexical_overlap"); + assert.equal(report.characterCollision.pairs[0].left.characterId, "character-a"); + assert.equal(report.characterCollision.pairs[0].right.characterId, "character-b"); + + assert.deepEqual(report.textPolicy.distribution, { none: 1, decorative: 1, readable: 2, missing: 0, invalid: 0 }); + assert.equal(report.textPolicy.structuredPolicyRequired, true); + assert.match(report.auditSemantics, /不改变项目/); +}); + +test("没有总纲时 F02 与可变正文长度明确 unsupported,不猜媒介块", async () => { + const report = await auditPromptBodies(sampleState()); + assert.equal(report.f02.status, "unsupported"); + assert.equal(report.f02.source.reason, "art_direction_brief_not_provided"); + assert.equal(report.f02.byAssetType["character-state"].checked, 0); + assert.equal(report.variableBodyLength.supportedAssetCount, 0); + assert.equal(report.variableBodyLength.unsupportedAssetCount, 4); + assert.ok(report.variableBodyLength.unsupported.every((item) => item.reason === "art_direction_brief_not_provided")); +}); + +test("同一资产类型存在多个媒介候选时标记 unsupported", async () => { + const ambiguous = brief.replace( + `- **主维度·角色**:${characterMedia}`, + `- **主维度·角色**:${characterMedia}\n- **回忆维度·角色**:另一段角色媒介核。` + ); + const report = await auditPromptBodies(sampleState(), { artDirectionMarkdown: ambiguous }); + assert.equal(report.f02.byAssetType["character-state"].status, "unsupported"); + assert.equal(report.f02.byAssetType["character-state"].reason, "multiple_media_blocks_for_asset_type"); + assert.equal(report.f02.byAssetType.location.status, "supported"); +}); + +test("逗号分隔的逐视图重复与长期经历叙事都能被观测", async () => { + const state = sampleState(); + state.assets.characters[0].states[0].promptBody = `${characterMedia}经历长期囚禁,身形消瘦。正面全身图穿黑色长袍,背面全身图穿黑色长袍。${characterPresentation}`; + const report = await auditPromptBodies(state, { artDirectionMarkdown: brief }); + + assert.ok(report.observations.narrativeTerms.assets[0].hits.some((hit) => hit.match === "经历长期囚禁")); + assert.ok(report.observations.perViewRepetition.assets[0].repeatedTokens.includes("长袍")); +}); + +test("readable 缺少 language 或有效 content 时计为 invalid", async () => { + const state = sampleState(); + state.assets.props[0].textPolicy = { mode: "readable" }; + state.assets.locations[0].textPolicy = { mode: "readable", language: "简体中文", content: [""] }; + const report = await auditPromptBodies(state, { artDirectionMarkdown: brief }); + + assert.deepEqual(report.textPolicy.distribution, { none: 1, decorative: 1, readable: 0, missing: 0, invalid: 2 }); +}); + +test("没有该类型 Prompt 时 F02 标记 not_applicable 且不影响总体状态", async () => { + const state = sampleState(); + state.assets.locations = []; + state.assets.props = []; + const report = await auditPromptBodies(state, { artDirectionMarkdown: brief }); + + assert.equal(report.f02.status, "supported"); + assert.equal(report.f02.byAssetType.location.status, "not_applicable"); + assert.equal(report.f02.byAssetType.location.checked, 0); + assert.equal(report.f02.byAssetType.prop.status, "not_applicable"); +}); + +test("碰撞详情默认最多返回 100 对并保留候选总数", async () => { + const state = sampleState(); + const template = state.assets.characters[0].states[0]; + state.assets.characters = Array.from({ length: 16 }, (_, index) => ({ + id: `character-${index}`, + name: `角色${index}`, + states: [{ ...template, id: `character-${index}-default` }] + })); + const report = await auditPromptBodies(state, { artDirectionMarkdown: brief }); + + assert.equal(report.characterCollision.pairCount, 120); + assert.equal(report.characterCollision.candidatePairCount, 120); + assert.equal(report.characterCollision.returnedPairCount, 100); + assert.equal(report.characterCollision.pairs.length, 100); + assert.equal(report.characterCollision.truncated, true); +}); + +test("CLI 只读输入文件并向 stdout 输出 JSON", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "comic-assets-prompt-audit-")); + try { + const statePath = path.join(directory, "project-state.json"); + const briefPath = path.join(directory, "art-direction-brief.md"); + const stateText = `${JSON.stringify(sampleState(), null, 2)}\n`; + await writeFile(statePath, stateText, "utf8"); + await writeFile(briefPath, brief, "utf8"); + const filesBefore = await readdir(directory); + + const scriptPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../scripts/audit_prompt_bodies.mjs"); + const { stdout, stderr } = await execFileAsync(process.execPath, [scriptPath, statePath, "--art-direction-brief", "art-direction-brief.md"]); + const parsed = JSON.parse(stdout); + + assert.equal(stderr, ""); + assert.equal(parsed.project.id, "prompt-audit-test"); + assert.equal(await readFile(statePath, "utf8"), stateText); + assert.deepEqual(await readdir(directory), filesBefore); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("CLI 的 --art-direction-brief 缺少值时明确报错", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "comic-assets-prompt-audit-cli-")); + try { + const statePath = path.join(directory, "project-state.json"); + await writeFile(statePath, `${JSON.stringify(sampleState(), null, 2)}\n`, "utf8"); + const scriptPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../scripts/audit_prompt_bodies.mjs"); + + await assert.rejects( + execFileAsync(process.execPath, [scriptPath, statePath, "--art-direction-brief"]), + (error) => error.code === 1 && /--art-direction-brief 缺少值/u.test(error.stderr) + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/tests/prompt-body-v3.test.mjs b/tests/prompt-body-v3.test.mjs index 7dd8a02..dfb307b 100644 --- a/tests/prompt-body-v3.test.mjs +++ b/tests/prompt-body-v3.test.mjs @@ -124,6 +124,78 @@ test("v3 画面内文字:要求可读文字但未指定语言时拒绝 ready", assert.equal(ornamental.ready, true, "声明不可读必须通过:" + ornamental.reasons.join("、")); }); +test("新 v3 结构化 textPolicy 与旧 G03 词表并行生效", () => { + const state = bodyState(); + state.config.promptProfile.textPolicyVersion = 1; + const character = recordById(state, "state-evelyn-default"); + character.asset.textPolicy = { mode: "none" }; + assert.equal(readiness(character, state).ready, true); + + const prop = recordById(state, "prop-seed-packet"); + const missing = readiness(prop, state); + assert.ok(missing.reasons.includes("缺少结构化 textPolicy"), missing.reasons.join("、")); + + prop.asset.textPolicy = { mode: "decorative" }; + assert.equal(readiness(prop, state).ready, true); + + prop.asset.promptBody = mediaSentence + "单一道具主体为洗旧厚纸袋,标签为英文 Daffodil / Hawthorn,封口结构清晰。"; + prop.asset.textPolicy = { mode: "readable", language: "英文", content: ["Daffodil / Hawthorn"] }; + assert.equal(readiness(prop, state).ready, true); + + prop.asset.textPolicy.content = ["Daffodil", "Hawthorn", "Nightshade"]; + const mismatch = readiness(prop, state); + assert.ok(mismatch.reasons.some((reason) => reason.includes("Nightshade")), mismatch.reasons.join("、")); + + prop.asset.textPolicy = { mode: "none" }; + const falseNone = readiness(prop, state); + assert.ok(falseNone.reasons.some((reason) => reason.includes("mode=none")), falseNone.reasons.join("、")); +}); + +test("validate_project_state 对新 v3 textPolicy 执行结构校验", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "comic-v3-text-policy-")); + try { + const script = "第1集第1场:测试。"; + const bible = "# 故事资产圣经\n"; + const brief = "# 项目创作方向\n"; + const state = bodyState(); + state.config.promptProfile.textPolicyVersion = 1; + state.project.sourceScript = "剧本.md"; + state.project.sourceHash = hashValue(script); + state.workflow.storyBible = { + path: "story-asset-bible.md", + sourceHash: state.project.sourceHash, + contentHash: hashValue(bible), + status: "complete" + }; + state.workflow.artDirection = { + path: "art-direction-brief.md", + sourceHash: state.project.sourceHash, + storyBibleHash: hashValue(bible), + setupHash: null, + contentHash: hashValue(brief), + status: "complete" + }; + state.assets.characters[0].states[0].textPolicy = { mode: "none" }; + state.assets.props[0].textPolicy = { mode: "decorative" }; + await writeFile(path.join(dir, "剧本.md"), script); + await writeFile(path.join(dir, "story-asset-bible.md"), bible); + await writeFile(path.join(dir, "art-direction-brief.md"), brief); + const stateFile = path.join(dir, "project-state.json"); + await writeFile(stateFile, JSON.stringify(state, null, 2)); + + const passed = spawnSync("node", [path.join(root, "scripts/validate_project_state.mjs"), stateFile], { encoding: "utf8" }); + assert.equal(passed.status, 0, passed.stderr); + + delete state.assets.props[0].textPolicy; + await writeFile(stateFile, JSON.stringify(state, null, 2)); + const rejected = spawnSync("node", [path.join(root, "scripts/validate_project_state.mjs"), stateFile], { encoding: "utf8" }); + assert.equal(rejected.status, 1); + assert.match(rejected.stderr, /textPolicy/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + test("findUnspecifiedImageTextTerms:词表保守,无文字要求时不误伤", () => { assert.deepEqual(findUnspecifiedImageTextTerms("黑曜石戒圈托起旧银印台,宝石与纹样清晰。"), []); assert.deepEqual(findUnspecifiedImageTextTerms("戒面内圈铭文清楚呈现。"), ["铭文"]); @@ -230,7 +302,7 @@ test("record v3:角色块可含人物钉子,场景道具块含人物钉子 } }); -test("局部构图排除项:上半身肖像正文写鞋靴被拒绝,裁剪后放行", () => { +test("局部构图不再用鞋靴词表机械拒绝,由语义 rubric 负责可见范围", () => { const state = bodyState(); state.config.assetPresentation.characterDefault = { id: "upper-body", @@ -245,9 +317,8 @@ test("局部构图排除项:上半身肖像正文写鞋靴被拒绝,裁剪 }; const record = recordById(state, "state-evelyn-default"); record.asset.promptBody = mediaSentence + "白色无缝背景的角色上半身肖像,欧美面貌成年女性,深色长发,黑金礼服上身与蕾丝领口,黑色皮靴。"; - const rejected = readiness(record, state); - assert.equal(rejected.ready, false); - assert.ok(rejected.reasons.some((reason) => reason.includes("呈现范围之外")), "应报排除项命中:" + rejected.reasons.join("、")); + const notMechanicallyRejected = readiness(record, state); + assert.deepEqual(notMechanicallyRejected.reasons, [], "鞋靴词不应触发机械门禁:" + notMechanicallyRejected.reasons.join("、")); record.asset.promptBody = mediaSentence + "白色无缝背景的角色上半身肖像,欧美面貌成年女性,深色长发,黑金礼服上身与蕾丝领口。"; const passed = readiness(record, state); @@ -343,6 +414,7 @@ ${withTextSection ? ` assert.equal(passed.status, 0, passed.stderr); const recorded = JSON.parse(await readFile(stateFile, "utf8")); assert.equal(recorded.config.promptProfile.version, 3); + assert.equal(recorded.config.promptProfile.textPolicyVersion, 1); assert.equal(Object.hasOwn(recorded.config.promptProfile, "styleAnchor"), false, "v3 不承载 styleAnchor"); } finally { await rm(dir, { recursive: true, force: true }); diff --git a/tests/prompt-shards.test.mjs b/tests/prompt-shards.test.mjs index 7a93471..168de63 100644 --- a/tests/prompt-shards.test.mjs +++ b/tests/prompt-shards.test.mjs @@ -63,6 +63,8 @@ test("打包:多外观角色独占一包,普通分片按资产数聚合", () assert.ok(bundle.solo || bundle.assetCount <= cap, bundle.bundleId + " 超出打包上限"); } assert.equal(plan.summary.bundles, plan.bundles.length); + assert.equal(plan.scheduling.recommendedConcurrency, 3); + assert.equal(plan.scheduling.expectedWaves, Math.ceil(plan.bundles.length / 3)); }); test("missingOnly 只保留缺正文的资产并丢弃空分片", () => { @@ -72,3 +74,17 @@ test("missingOnly 只保留缺正文的资产并丢弃空分片", () => { const propShard = plan.shards.find((shard) => shard.assetType === "prop"); assert.deepEqual(propShard.assetIds, ["prop-2", "prop-3", "prop-4"]); }); + +test("新 v3 missingOnly 把缺失或无效 textPolicy 视为待写工作", () => { + const state = sampleState(); + state.config = { promptProfile: { version: 3, textPolicyVersion: 1 } }; + state.assets.characters[0].states[0].textPolicy = { mode: "none" }; + state.assets.props[0].textPolicy = { mode: "none" }; + const plan = planPromptShards(state, { maxPerShard: 4, missingOnly: true }); + assert.deepEqual(plan.shards.find((shard) => shard.characterId === "character-a").assetIds, ["character-a-armor"]); + assert.deepEqual(plan.shards.find((shard) => shard.assetType === "prop").assetIds, ["prop-2", "prop-3", "prop-4"]); + + delete state.assets.characters[0].states[0].textPolicy; + const missingPolicy = planPromptShards(state, { maxPerShard: 4, missingOnly: true }); + assert.deepEqual(missingPolicy.shards.find((shard) => shard.characterId === "character-a").assetIds, ["character-a-default", "character-a-armor"]); +}); diff --git a/tests/style-presets.test.mjs b/tests/style-presets.test.mjs index eafd1f6..d8cc12f 100644 --- a/tests/style-presets.test.mjs +++ b/tests/style-presets.test.mjs @@ -21,3 +21,22 @@ test("预设种子卫生:无版式词(人物钉子豁免)、无比例数 assert.ok(!/二次元/.test(preset.stylePromptSeed) || preset.family !== "CG 人设", preset.id + " 的 CG 种子不应含“二次元”(曾造成用户媒介预期错配)"); } }); + +test("特写正背面布局锁定信息覆盖并允许体态适配姿态", async () => { + const manifest = JSON.parse(await readFile(path.join(root, "assets/setup-app/style-presets.json"), "utf8")); + const layout = manifest.layouts.find((item) => item.id === "closeup-front-back"); + + assert.ok(layout, "缺少 closeup-front-back 布局"); + assert.equal(layout.ratio, "4:3"); + for (const requirement of layout.requirements) { + assert.match(layout.prompt, new RegExp(requirement.match), requirement.label + " 未被布局提示词覆盖"); + } + assert.match(layout.prompt, /占主要视觉面积的正面胸像特写/); + assert.match(layout.prompt, /背面全身图或三分之四背面全身图/); + assert.match(layout.prompt, /体态事实优先/); + assert.match(layout.prompt, /不遮挡人物轮廓和服装前后结构/); + assert.match(layout.prompt, /非对称重心/); + assert.match(layout.prompt, /符合角色身份的姿态/); + assert.match(layout.prompt, /不强制统一直立、放松或端庄站姿/); + assert.doesNotMatch(layout.prompt, /表情平静|平视端庄|排版紧密/); +}); From 79946e3659ea426052d08de554b6a170756a2ad1 Mon Sep 17 00:00:00 2001 From: Shanzoon <82895720+Shanzoon@users.noreply.github.com> Date: Mon, 20 Jul 2026 03:09:15 +0800 Subject: [PATCH 08/11] =?UTF-8?q?C5=20=E4=BB=98=E8=B4=B9=E8=A7=86=E8=A7=89?= =?UTF-8?q?=E7=9F=A9=E9=98=B5=E5=9B=9E=E6=94=B6:localTone=20=E8=BE=B9?= =?UTF-8?q?=E7=95=8C=E3=80=81=E6=88=90=E5=9B=BE=E9=AA=8C=E6=94=B6=E4=B8=8E?= =?UTF-8?q?=E9=A2=84=E8=AE=BE=E4=BF=AE=E6=AD=A3(2026-07-20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cg-xianxia 种子移除梦幻仙气/唯美古风,manifest v4 仅影响新 setup - localTone 增加媒介/时代地域/世界类型/资产事实边界,阴冷地宫保留为短反例,无新词表门禁 - 核心角色增加脱离配对缩略图单看验收;成图 rubric 检查 localTone 越界/独立可读/姿态信息覆盖 - 表达型角色板释放非对称重心;严格正侧背布局保留生产角度;M08 增强 CG 核未进入预设 - 派生项目更换视觉预设或 F02 时必须重建总纲,旧 F02 不复制为新 CONTRACT Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 10 +++++++++ SKILL.md | 2 ++ assets/setup-app/style-presets.json | 8 +++---- references/creative-charter.md | 4 ++-- references/prompt-authoring.md | 11 ++++++++-- references/subagent-contracts.md | 2 +- scripts/bootstrap_art_direction_brief.mjs | 4 ++-- tests/authoring-templates.test.mjs | 22 ++++++++++++++++++- tests/style-presets.test.mjs | 26 ++++++++++++++++++++++- 9 files changed, 76 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 623bf26..e8a38da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,16 @@ - 可逆动作直接执行并复述;锁定、超出持久授权的扣费和其他不可逆动作需要确认。高后果创作项必须用成图后果句显式呈现。 - 运行时按阶段读取 references;生产不变量、岗位准入、Prompt 契约、旧版本兼容和供应商边界各有唯一权威文件。 +## 2026-07-20 — C5 付费视觉矩阵回收:局部气质边界与成图验收 + +- **真实 A/B 证据**:隔离派生项目使用 GPT Image 2 普通低价 2K 生成 8 个 B 候选,8/8 成功、无重试、实际 16 点。最强正证据来自核心角色差异矩阵和信息覆盖式自然站姿;事件历史改为当前可见状态后未损失金瞳信息。 +- **localTone 防越权**:F02 原样 CONTRACT 不变;localTone 新增媒介、时代地域、世界类型和资产事实边界,不得借气氛发明新的文化母题、建筑类型或正在发生的事件。阴冷地宫滑向西式恶鬼祭坛被保留为短反例,但不增加词表或 readiness 门禁。 +- **预设上游修正**:`cg-xianxia` 种子移除“梦幻仙气、唯美古风”,保留国风仙侠三维游戏 CG 的媒介定位;manifest 升至 v4,仅影响新 setup,锁定项目不迁移。 +- **派生边界**:以新规则更换视觉预设或 F02 时,旧总纲只作回归证据;派生项目必须用自己的 setup 与故事事实重新确认总纲,不能把旧 F02 污染复制成新的 CONTRACT。 +- **成图 rubric**:核心角色增加“脱离配对、缩略图单看仍可读”的验收;角色戏剧功能不得固化为通用反派脸、低机位或配饰配方。代表图同时检查 localTone 越界、角色独立可读和姿态信息覆盖;视觉判断不进入自动 readiness。 +- **presentation**:表达型正背面角色板释放非对称重心与角色化姿态;严格正侧背布局继续保留明确生产角度。M08 的增强 CG 核只改善部分表面质量,未进入任何视觉预设,待跨角色复测。 +- **兼容性**:不改 schema、prompt 编译、审查门禁或存量项目;新规则只进入新模板、新 setup 与创作审查。验证见本条落地后的全量测试、`git diff --check` 和 skill quick validation。 + ## 2026-07-19 — v3.1 视觉质量与注意力预算第一批 - **F02 边界收窄,契约不放松**:总纲模板把“媒介与画质”限定为渲染范式、材质/体积/光照、人物防漂移钉子与跨资产一致性;项目题材、全局情绪、角色关系、善恶评价和局部场景气氛不得进入媒介核。岗位四仍须把对应 `维度 × 资产类型` 的 F02 整行逐字放在正文开头。 diff --git a/SKILL.md b/SKILL.md index 5a723a9..a841005 100644 --- a/SKILL.md +++ b/SKILL.md @@ -66,6 +66,8 @@ node "/scripts/record_story_bible.mjs" "<项目目录>/project-stat 名册必须对每个候选和每个外观版本给出唯一处置;未生成的版本进入 `downgraded` 并说明理由。总纲确认后运行: +派生项目若用于更换视觉预设、F02 或其他创作方向,旧总纲只作回归证据;用新项目的 setup 和故事事实重新提出、确认并锁定总纲,不把旧 F02 复制成新项目 CONTRACT。旧项目及其已生成资产保持只读。 + ```bash node "/scripts/record_art_direction_brief.mjs" "<项目目录>/project-state.json" ``` diff --git a/assets/setup-app/style-presets.json b/assets/setup-app/style-presets.json index be52da9..edbe3f1 100644 --- a/assets/setup-app/style-presets.json +++ b/assets/setup-app/style-presets.json @@ -1,5 +1,5 @@ { - "version": 3, + "version": 4, "styles": [ { "id": "script", @@ -97,7 +97,7 @@ "name": "古风仙侠 CG", "family": "CG 人设", "positioning": "国风游戏立绘向", - "stylePromptSeed": "古风仙侠三维游戏角色美术,梦幻仙气,唯美古风,国风游戏 CG 宣传图质感,非真人感,低细节皮肤,无毛孔,哑光分束发丝,柔和漫射光,高清细节。", + "stylePromptSeed": "古风仙侠三维游戏角色美术,国风游戏 CG 宣传图质感,非真人感,低细节皮肤,无毛孔,哑光分束发丝,柔和漫射光,服装层次与轮廓清晰,高清细节。", "thumbnail": "style-thumbnails/cg-xianxia.jpg", "examples": [ "style-examples/cg-xianxia/1.jpg" @@ -170,7 +170,7 @@ "id": "closeup-turnaround", "name": "特写 + 正侧背三视图", "ratio": "16:9", - "prompt": "白色无缝背景的角色资产设定板,画面左侧为占主要视觉面积的正面胸像特写,表情平静、平视端庄;画面右侧并列同一角色的正面、侧面与背面完整全身图,人物比例、面貌、发型和服装结构严格一致。", + "prompt": "白色无缝背景的角色资产设定板,画面左侧为占主要视觉面积的正面胸像特写,清楚呈现面孔、发型与符合角色身份的稳定气质;画面右侧并列同一角色严格正面、侧面与背面的完整全身图,人物比例、面貌、发型和服装结构严格一致。", "image": null, "requirements": [ { @@ -199,7 +199,7 @@ "id": "expression-front-back", "name": "情绪表情 + 正背面设定", "ratio": "16:9", - "prompt": "白色无缝背景的多视角角色资产设定板,画面中部为占比最大的正面胸像特写,表情平静中性、平视端庄;左侧自上而下展示三张不同角度与表情的极近面部特写;右侧为同一角色的正面全身站立图与回首背面全身图,排版紧密,人物比例与服装细节一致。", + "prompt": "白色无缝背景的多视角角色资产设定板,画面中部为占主要视觉面积的正面胸像特写,清楚呈现符合角色身份的稳定气质;左侧自上而下展示三张不同角度与表情的极近面部特写;右侧完整呈现同一角色的正面全身图与背面全身图或三分之四背面全身图。体态事实优先;在不遮挡人物轮廓、服装前后结构和表情信息的前提下,允许非对称重心、肩胯变化、自然手势或符合角色身份的姿态,同时保持人物比例、面貌、发型和服装细节一致。", "image": "layout-examples/expression-front-back.jpg", "requirements": [ { diff --git a/references/creative-charter.md b/references/creative-charter.md index 33320d2..22c21d4 100644 --- a/references/creative-charter.md +++ b/references/creative-charter.md @@ -15,9 +15,9 @@ - 不把创作推断伪装成剧情事实。别名、重复项和指代冲突要确定性处置并留理由;只有会改变项目方向的分歧才升级给用户。 - 用轮廓、比例、色彩、材质、空间和重复视觉锚点建立差异,不靠细碎堆砌。角色、场景和道具应属于同一叙事世界。 - 默认资产优先稳定、清晰和复用;剧情阶段变化只进入对应外观版本或镜头,不污染默认锚点。 -- 项目题材和总情绪不是所有资产的固定前缀。媒介核只锁定渲染事实;温暖、压迫、荒凉、诡异等局部气质应随当前资产的叙事功能分流,不能用一句全局口号覆盖相反情境。 +- 项目题材和总情绪不是所有资产的固定前缀。媒介核只锁定渲染事实;温暖、压迫、荒凉、诡异等局部气质应随当前资产的叙事功能分流,不能用一句全局口号覆盖相反情境。localTone 只能在已确认的媒介、时代地域和世界类型内调节当前可见氛围,不得借机发明另一套文化母题、建筑类型或事件场面。 - 辨识度服从人设:英雄型主角可使用独占轮廓、专属色或贯穿符号;普通人代入型主角保持大众形象,只用少量稳定特征定位。功能角色明确让位。 -- 核心角色的戏剧功能必须通过年龄层级、轮廓、眼神、妆发、姿态、服装与材质等可见选择被读出,但不自动把善恶绑定到固定面孔类型或生理丑化。高碰撞角色应先做轻量并排差异设计,不靠岗位四临时堆形容词补救。 +- 核心角色的戏剧功能必须通过年龄层级、轮廓、眼神、妆发、姿态、服装与材质等可见选择被读出,并在脱离配对、缩略图单看时仍然成立;但不自动把善恶绑定到固定面孔类型或生理丑化。高碰撞角色应先做轻量并排差异设计,不靠岗位四临时堆形容词补救,也不把某组目光、机位或配饰固化成通用反派配方。 - 不根据名字或剧本语言推断个别角色的族裔。剧本未明确时,在“人物群体呈现”中作项目级决定:默认按剧本文化语境的主流面孔统一呈现;除非剧本明确写了多元设定,不把多族裔均衡分布当商业默认。不把族裔编码为善恶或权力等级。 - 在“画面内文字语言”中决定可读文字策略。证据类文字写明语言和内容原文;装饰文字默认纹样化、不可读。不得把选择留给生图模型默认值。 diff --git a/references/prompt-authoring.md b/references/prompt-authoring.md index 8d35a9c..2b9f392 100644 --- a/references/prompt-authoring.md +++ b/references/prompt-authoring.md @@ -10,7 +10,7 @@ ## 不可协商的正文契约 -- **F02 CONTRACT 必须逐字一致**:正文开头原样使用总纲中“所属维度 × 本资产类型”的完整媒介与画质词块,不改写、不删钉子、不混抄另一类型。这是跨资产风格一致的文字锚。localTone 只能补充当前资产的可见气质,不能改写或替代媒介核。 +- **F02 CONTRACT 必须逐字一致**:正文开头原样使用总纲中“所属维度 × 本资产类型”的完整媒介与画质词块,不改写、不删钉子、不混抄另一类型。这是跨资产风格一致的文字锚。localTone 只能在已锁定媒介、时代地域、世界类型和资产事实之内补充当前资产的可见气质,不能改写或替代媒介核,也不能借气氛发明新的文化母题、建筑类型或正在发生的事件。 - 服从圣经事实和已确认方向。基础身份事实明确的照实写;“未明确”项按项目级“人物群体呈现”决定,缺少决定时回报主代理,不自行发明。 - 语义覆盖当前呈现 requirements;体态事实优先于通用姿态。比例数字可以省略,写出时必须与配置 ratio 完全一致。 - 只描述所选构图可见范围;上半身资产不展开画外下装、鞋靴和裙摆。这里由 Agent 的语义自检负责,不用汉语词表猜测越界。 @@ -39,6 +39,8 @@ - “性格恶毒、带绿茶气质”应改为精心维持的温柔表情、审视性目光、完整妆发和控制感姿态。 - “经历长期囚禁”只有落实为消瘦、旧伤、磨损或凌乱等当前可见事实时才保留。 +事件或历史被删除后,身份、服装、装备和状态等已确认事实仍须独立满足;减负不是弱化事实。局部气质也必须支付像素租金:例如阴冷的国风仙侠地宫可以调节黑石、铁链、火光和空间压迫,但不能凭一句 localTone 自行长出西式恶鬼祭坛或正在囚禁的仪式。 + ### 软信息预算 以下只用于审查和压缩,不作字符数硬门禁: @@ -53,7 +55,11 @@ ### 核心角色组对审 -高碰撞核心角色不能只逐条自检。同一角色组完成后并排检查:年龄层级是否可读;最相似的两人是否共享脸型、发型和姿态;主角、反派和功能角色的视觉层级是否符合总纲;差异是否只靠换颜色。验收依据相关差异矩阵行,不新增反派、脸型或气质词表门禁。 +高碰撞核心角色不能只逐条自检。同一角色组完成后并排检查:年龄层级是否可读;最相似的两人是否共享脸型、发型和姿态;主角、反派和功能角色的视觉层级是否符合总纲;差异是否只靠换颜色。再把每个核心角色脱离配对,以缩略图单看,确认其戏剧功能仍能从可见选择中读出。验收依据相关差异矩阵行,不新增反派、脸型或气质词表门禁,也不把低机位、冷笑或夸张配饰固化成通用反派配方。 + +### 成图验收 + +Prompt 审查通过不等于视觉通过。代表图至少检查三件事:localTone 是否越过媒介与世界边界;核心角色在缩略图单看时是否仍有独立戏剧功能;姿态自由是否仍交付 requirements 指定的面孔、轮廓、服装前后结构和背部信息。背部信息可读优先于机械纯背面;只有呈现配置明确要求严格正侧背时,才锁定转台式角度。材质更复杂、低机位更强或装饰更多不自动算质量提升。 ## 推荐 recipe(非格式门禁) @@ -87,5 +93,6 @@ - **规格**:requirements 是否齐全、可见范围是否匹配、比例是否一致、文字语言和内容是否明确? - **锚点价值**:默认外观是否清晰、稳定,适合作为后续版本的唯一参考? - **组间差异**:高碰撞角色并排时是否满足相关矩阵,且至少有三项非颜色差异? +- **视觉副作用**:缩略图单看是否成立?localTone 是否改写了世界类型?姿态释放是否丢失 requirements 要求的信息? 确认后的正文经 review patch 写入;批准前始终是候选。工作台展示、批次和供应商提交必须使用同一物化文本。 diff --git a/references/subagent-contracts.md b/references/subagent-contracts.md index 02d8e07..3446fa8 100644 --- a/references/subagent-contracts.md +++ b/references/subagent-contracts.md @@ -47,7 +47,7 @@ 先运行 `plan_prompt_shards.mjs --missing-only`。shard 是审计和覆盖单位;bundle、建议并发和预期波数是 planner 提供的调度基线,不是唯一合法分配。保持同一角色全部版本在同一上下文,主代理可按复杂度和可用槽位调整派发;禁止按资产类型只开三个大任务。完成后按 shardId 对账,并回报实际波数、耗时与计划偏差。 -任务卡必须自包含:名册条目、相关圣经事实、对应媒介与资产类型词块、相关核心角色差异矩阵行、当前资产的一句 localTone、呈现 requirements 与可见范围、文字策略。没有相关矩阵行时省略,不为功能角色强造差异;不要让 worker 回读大文件。 +任务卡必须自包含:名册条目、相关圣经事实、对应媒介与资产类型词块、相关核心角色差异矩阵行、当前资产确有需要时的一句 localTone、呈现 requirements 与可见范围、文字策略。localTone 必须来自已确认世界语言且不改写媒介、时代地域或世界类型;没有相关矩阵行时省略,不为功能角色强造差异;不要让 worker 回读大文件。 ## Sidecar 与 checkpoint diff --git a/scripts/bootstrap_art_direction_brief.mjs b/scripts/bootstrap_art_direction_brief.mjs index d8a601c..3bf2055 100644 --- a/scripts/bootstrap_art_direction_brief.mjs +++ b/scripts/bootstrap_art_direction_brief.mjs @@ -70,7 +70,7 @@ ${lockedSelection}## 创作命题 待补充:说明跨资产稳定复现的时代、社会阶层、阵营、色彩、材质与空间语言,并说明情绪如何随资产和剧情功能变化。不得把单一项目情绪当成所有资产的固定前缀;例如公共空间可以表面体面而秩序压迫,私人回忆空间仍可温暖、柔软、安全。 -岗位四的任务卡只接收当前资产所需的一句局部气质(localTone)。localTone 用于指导当前资产的氛围、姿态或光线,不属于 F02 CONTRACT,不得改写或替代正文开头的原样媒介核。 +岗位四的任务卡只在当前资产确有需要时接收一句局部气质(localTone)。localTone 用于指导当前资产的氛围、姿态或光线,不属于 F02 CONTRACT;它必须服从已确认的媒介、时代地域、世界类型与资产事实,不得改写或替代正文开头的原样媒介核,也不得发明新的文化母题、建筑类型或正在发生的事件。例如阴冷的国风仙侠地宫可用黑石、铁链、稀薄火光和压迫纵深表达,但不能仅凭 localTone 变成西式恶鬼祭坛或正在囚禁的仪式。 ## 人物群体呈现 @@ -92,7 +92,7 @@ ${lockedSelection}## 创作命题 待补充:只为同性别、相近年龄、同阵营或有亲缘关系且容易撞型的角色组,以及主角与核心反派、用户要求一眼区分的角色组建立矩阵。可按需要使用年龄层级、面部结构、眼眉与目光、妆发、体态轮廓、服装剪裁与材质、稳定识别锚等维度;不要求覆盖所有角色,也不要求逐格填满。 -验收看结果:相邻核心角色至少有三项非颜色差异,且不共享“脸型 + 发型 + 姿态”组合。戏剧功能必须能被画面读出,但不把善恶自动编码成固定脸型或生理丑化;用户明确要求明显丑化时,再把要求翻译成可信的可见设计。 +验收看结果:相邻核心角色至少有三项非颜色差异,且不共享“脸型 + 发型 + 姿态”组合。戏剧功能既要在组对时成立,也要在角色脱离配对、缩略图单看时从可见设计中读出;但不把善恶自动编码成固定脸型或生理丑化,也不固化通用反派表情或机位配方。用户明确要求明显丑化时,再把要求翻译成可信的可见设计。 ### 场景 diff --git a/tests/authoring-templates.test.mjs b/tests/authoring-templates.test.mjs index 3eab45b..8e7cf07 100644 --- a/tests/authoring-templates.test.mjs +++ b/tests/authoring-templates.test.mjs @@ -59,8 +59,28 @@ test("美术总纲模板保留 F02 并把局部气质与角色差异移出媒介 assert.match(markdown, /F02 CONTRACT/); assert.match(markdown, /不写项目题材或全局情绪结论/); assert.match(markdown, /^## 世界语言与局部气质分流$/m); - assert.match(markdown, /当前资产所需的一句局部气质(localTone)/); + assert.match(markdown, /当前资产确有需要时接收一句局部气质(localTone)/); + assert.match(markdown, /服从已确认的媒介、时代地域、世界类型与资产事实/); + assert.match(markdown, /不能仅凭 localTone 变成西式恶鬼祭坛或正在囚禁的仪式/); assert.match(markdown, /^#### 核心角色差异矩阵(只覆盖高碰撞角色)$/m); assert.match(markdown, /至少有三项非颜色差异/); + assert.match(markdown, /脱离配对、缩略图单看/); }); }); + +test("Prompt rubric 保留自由写法并要求检查视觉副作用", async () => { + const markdown = await readFile(path.join(root, "references/prompt-authoring.md"), "utf8"); + assert.match(markdown, /正文可以自由组织,只对结果负责/); + assert.match(markdown, /^### 成图验收$/m); + assert.match(markdown, /localTone 是否越过媒介与世界边界/); + assert.match(markdown, /缩略图单看/); + assert.match(markdown, /背部信息可读优先于机械纯背面/); + assert.match(markdown, /材质更复杂、低机位更强或装饰更多不自动算质量提升/); +}); + +test("派生项目重建创作方向,不继承旧 F02 污染", async () => { + const markdown = await readFile(path.join(root, "SKILL.md"), "utf8"); + assert.match(markdown, /旧总纲只作回归证据/); + assert.match(markdown, /不把旧 F02 复制成新项目 CONTRACT/); + assert.match(markdown, /旧项目及其已生成资产保持只读/); +}); diff --git a/tests/style-presets.test.mjs b/tests/style-presets.test.mjs index d8cc12f..fbe5657 100644 --- a/tests/style-presets.test.mjs +++ b/tests/style-presets.test.mjs @@ -10,7 +10,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); test("预设种子卫生:无版式词(人物钉子豁免)、无比例数字、均带定位标签", async () => { const manifest = JSON.parse(await readFile(path.join(root, "assets/setup-app/style-presets.json"), "utf8")); - assert.equal(manifest.version, 3); + assert.equal(manifest.version, 4); for (const preset of manifest.styles) { assert.ok(typeof preset.positioning === "string" && preset.positioning.trim(), preset.id + " 缺少 positioning 定位标签"); if (!preset.stylePromptSeed) continue; @@ -22,6 +22,15 @@ test("预设种子卫生:无版式词(人物钉子豁免)、无比例数 } }); +test("古风仙侠预设只保留媒介定位,不把梦幻唯美固定到所有资产", async () => { + const manifest = JSON.parse(await readFile(path.join(root, "assets/setup-app/style-presets.json"), "utf8")); + const preset = manifest.styles.find((item) => item.id === "cg-xianxia"); + assert.ok(preset, "缺少 cg-xianxia 预设"); + assert.match(preset.stylePromptSeed, /古风仙侠三维游戏角色美术/); + assert.match(preset.stylePromptSeed, /国风游戏 CG 宣传图质感/); + assert.doesNotMatch(preset.stylePromptSeed, /梦幻|仙气|唯美/); +}); + test("特写正背面布局锁定信息覆盖并允许体态适配姿态", async () => { const manifest = JSON.parse(await readFile(path.join(root, "assets/setup-app/style-presets.json"), "utf8")); const layout = manifest.layouts.find((item) => item.id === "closeup-front-back"); @@ -40,3 +49,18 @@ test("特写正背面布局锁定信息覆盖并允许体态适配姿态", async assert.match(layout.prompt, /不强制统一直立、放松或端庄站姿/); assert.doesNotMatch(layout.prompt, /表情平静|平视端庄|排版紧密/); }); + +test("表达型角色板释放姿态,严格三视图仍保留生产角度", async () => { + const manifest = JSON.parse(await readFile(path.join(root, "assets/setup-app/style-presets.json"), "utf8")); + const expression = manifest.layouts.find((item) => item.id === "expression-front-back"); + const closeupTurnaround = manifest.layouts.find((item) => item.id === "closeup-turnaround"); + const turnaround = manifest.layouts.find((item) => item.id === "turnaround"); + + assert.match(expression.prompt, /体态事实优先/); + assert.match(expression.prompt, /非对称重心/); + assert.match(expression.prompt, /背面全身图或三分之四背面全身图/); + assert.doesNotMatch(expression.prompt, /平静中性|平视端庄|排版紧密/); + + assert.match(closeupTurnaround.prompt, /严格正面、侧面与背面/); + assert.match(turnaround.prompt, /正面、侧面与背面完整全身站立图/); +}); From dc3d14abb99bafd5ee9b0105672ccd416c9246a5 Mon Sep 17 00:00:00 2001 From: Shanzoon <82895720+Shanzoon@users.noreply.github.com> Date: Mon, 20 Jul 2026 03:24:00 +0800 Subject: [PATCH 09/11] =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E5=9B=9E=E6=94=B6:=E5=8F=82=E8=80=83?= =?UTF-8?q?=E5=9B=BE=E9=93=BE=E8=B7=AF=E4=BF=AE=E5=A4=8D=E3=80=81=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E5=A4=B1=E6=95=88=E6=9C=BA=E6=A2=B0=E5=8C=96=E4=B8=8E?= =?UTF-8?q?=20F02=20=E9=97=A8=E7=A6=81(2026-07-20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1:normalize 从签名 URL 提取 material/ 对象路径落为 backendPath,修复后续外观版本 map 阶段必然阻塞的断链;reconcile 幂等短路比较图片定位、无变化不写盘 - P1:merge/apply 改写已批准正文自动回 needs_review,兑现'输入变化后旧审查失效';generatedAgainst 只随决定盖章;merge 容器白名单收紧、roster 按 opId 幂等 - 用户拍板:F02 逐字开头进 validate 与批量放行机械门禁,取证确定才拦,unsupported 一律放行 - P2:--auto 持久授权项目绑定、chunk 边界感知匹配、陈旧 baseStateRevision 警告、'世界语言与局部气质分流'入必填(record 幂等提前保护存量)、v1 工作台展示同文 - 测试:新增 execution-chain.test.mjs 10 用例覆盖执行后半程零测试带,全量 77/77 - 降噪:localTone 措辞对齐、SKILL 段落归位、转义注释还原、EISDIR 友好报错、CHANGELOG 补记 map/normalize Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 17 +- SKILL.md | 4 +- references/creative-charter.md | 2 +- references/invariants.md | 2 + references/prompt-authoring.md | 2 +- references/subagent-contracts.md | 2 +- scripts/apply_review_decisions.mjs | 22 +- scripts/approve_prompt_batch.mjs | 19 + scripts/audit_prompt_bodies.mjs | 5 +- scripts/lib/project.mjs | 4 +- scripts/merge_agent_patches.mjs | 23 +- scripts/normalize_awb_image_results.mjs | 26 +- scripts/plan_work_dag.mjs | 3 +- scripts/prepare_awb_execution.mjs | 2 +- scripts/reconcile_awb_results.mjs | 10 +- scripts/record_art_direction_brief.mjs | 17 +- scripts/record_execution_approval.mjs | 4 + scripts/record_story_bible.mjs | 6 +- scripts/validate_project_state.mjs | 22 ++ scripts/workspace_server.mjs | 5 +- tests/execution-chain.test.mjs | 472 ++++++++++++++++++++++++ tests/prompt-body-v3.test.mjs | 8 + 22 files changed, 648 insertions(+), 29 deletions(-) create mode 100644 tests/execution-chain.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index e8a38da..55cad25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,26 @@ - 新项目默认 promptProfile v3:最终 prompt 等于 `promptBody`,无隐藏拼接;v1/v2 只在恢复存量项目时按条件加载兼容说明。 - 新建 v3 项目同时启用 `textPolicyVersion: 1`;每个正文声明 none / decorative / readable。无标记的存量 v3 项目继续兼容旧链路,G03 词表暂时保留。 - 五阶段为 setup → 故事资产圣经 → 创作方向与正式名册 → Prompt / 审查 → 增量生成 / 收割;圣经和总纲锁定后在原项目不可改。 -- 项目状态、审查、执行与供应商事实分层保存;canonical 只经官方脚本写入,执行确认与 checkpoint 由 hash 保护。 +- 项目状态、审查、执行与供应商事实分层保存;canonical 只经官方脚本写入,执行确认与 checkpoint 由 hash 保护。已批准的正文被官方脚本改写时审查自动回 needs_review;v3 下 F02 逐字开头在总纲可确定取证时由 validate 与批量放行机械校验。 - 四类资产使用各自 presentation 与 ratio;角色后续版本只引用本角色锁定的默认 artifact。 - 可逆动作直接执行并复述;锁定、超出持久授权的扣费和其他不可逆动作需要确认。高后果创作项必须用成图后果句显式呈现。 - 运行时按阶段读取 references;生产不变量、岗位准入、Prompt 契约、旧版本兼容和供应商边界各有唯一权威文件。 +## 2026-07-20 — 独立架构审查回收:参考图链路修复、审查失效机械化与 F02 门禁 + +背景:C5 安装后的独立只读审查(含两个隔离子代理复核)发现执行后半程存在断链与零测试带;用户拍板 F02 进机械门禁、表达型角色板保留姿态放宽待成图验收、历史批次分次提交。 + +- **参考图 backendPath 断链修复(P1)**:`normalize_awb_image_results.mjs` 现从签名 URL 提取 COS 对象路径(`material/...`)落为 `backendPath`——此前全链路无任何写入方,而 `map_awb_image_batch.mjs` 硬性要求它,后续外观版本的真实生图必然在 map 阶段阻塞。reconcile 的幂等短路同步改为比较图片定位,重归一化可补齐 backendPath;完全无变化时不再空写状态文件。新增"锁定默认态 → 建批 → map"端到端测试封住该链路。 +- **审查失效机械化(P1,兑现 project-schema 既有承诺)**:merge patch 或 review decision 改写已 approved 资产的正文/文字策略且未显式给出新 status 时,审查自动回 needs_review,不静默保留旧批准;配合批量放行授权仍是零人工闭环。`generatedAgainst` 改为只随本次决定盖章,不再无条件覆盖全部资产。merge 白名单收紧:角色容器不再接受 promptBody/textPolicy/deltaPrompt;roster 重合并按 opId 幂等,不空转推进 revision。 +- **F02 CONTRACT 机械门禁(用户拍板)**:v3 项目总纲"媒介与画质"可确定性解析且当前资产类型恰有唯一媒介块时,`validate_project_state.mjs` 与 `approve_prompt_batch.mjs` 机械比对正文逐字开头,不一致即拒并回流岗位四;解析不确定(unsupported/多候选)一律放行,宁漏勿误伤。拒绝消费者是子代理,不占用户时间。 +- **授权与调度小修**:`record_execution_approval.mjs --auto` 校验 plan 与 state 的 projectId,持久授权不得跨项目使用;`--confirm-plan-hash` 失配拒绝补测试。plan_work_dag 的 chunk 完成检测改为边界感知匹配。apply_review_decisions 对陈旧 baseStateRevision 输出警告。 +- **总纲必填章节**:"世界语言与局部气质分流"(localTone 与差异矩阵的语义来源)加入 v3 锁定必填清单;record 的幂等检查提前到内容校验之前,存量锁定项目重复 record 不受新校验规则影响。 +- **展示同文**:工作台 `/api/view-state` 对显式 v1 项目同样投影过滤后的全局渲染语言,修复"工作台展示 ≠ 后端编译"的 v1 分叉。 +- **测试**:新增 `tests/execution-chain.test.mjs`(10 用例)覆盖此前的零测试带——checkpoint 状态机、planHash 失配、reconcile 防降回/跨批次保护、审查失效、changes_requested 不覆盖、readiness 后续版本链四个负向分支、F02 门禁、backendPath 端到端。全量 77/77。 +- **记录补欠**:`map_awb_image_batch.mjs` 与 `normalize_awb_image_results.mjs` 为 C4 执行窗口新增的确定性映射/归一化工具,此前缺 CHANGELOG 条目,本条补记;行为契约见 `references/provider-policy.md`。 +- **降噪**:localTone 边界措辞在 creative-charter / subagent-contracts 与权威表述对齐(补齐"资产事实"要素);SKILL.md 派生项目段落移出命令块之间;lib 中被转义的中文注释还原;record_story_bible 对缺失 sourceScript 给出明确错误。 +- **兼容性**:F02 门禁仅 v3 且取证确定才拦,v1/v2 与无总纲场景不受影响;审查失效只在"批准后被官方脚本改写"时触发;新必填章节只影响新锁定总纲。表达型角色板的姿态放宽按用户拍板保留,待下一批代表图按成图验收 rubric 验证。 + ## 2026-07-20 — C5 付费视觉矩阵回收:局部气质边界与成图验收 - **真实 A/B 证据**:隔离派生项目使用 GPT Image 2 普通低价 2K 生成 8 个 B 候选,8/8 成功、无重试、实际 16 点。最强正证据来自核心角色差异矩阵和信息覆盖式自然站姿;事件历史改为当前可见状态后未损失金瞳信息。 diff --git a/SKILL.md b/SKILL.md index a841005..57eb519 100644 --- a/SKILL.md +++ b/SKILL.md @@ -66,12 +66,12 @@ node "/scripts/record_story_bible.mjs" "<项目目录>/project-stat 名册必须对每个候选和每个外观版本给出唯一处置;未生成的版本进入 `downgraded` 并说明理由。总纲确认后运行: -派生项目若用于更换视觉预设、F02 或其他创作方向,旧总纲只作回归证据;用新项目的 setup 和故事事实重新提出、确认并锁定总纲,不把旧 F02 复制成新项目 CONTRACT。旧项目及其已生成资产保持只读。 - ```bash node "/scripts/record_art_direction_brief.mjs" "<项目目录>/project-state.json" ``` +派生项目若用于更换视觉预设、F02 或其他创作方向,旧总纲只作回归证据;用新项目的 setup 和故事事实重新提出、确认并锁定总纲,不把旧 F02 复制成新项目 CONTRACT。旧项目及其已生成资产保持只读。 + ### 4. Prompt 与审查 创作方向锁定且名册合并后,岗位四按 [references/prompt-authoring.md](references/prompt-authoring.md) 编写中文 `promptBody` 并同步声明结构化 `textPolicy`。新项目最终 prompt 就是该正文;处理 v1/v2 或无文字策略标记的存量项目时才读 [references/legacy-profiles.md](references/legacy-profiles.md)。 diff --git a/references/creative-charter.md b/references/creative-charter.md index 22c21d4..463adfc 100644 --- a/references/creative-charter.md +++ b/references/creative-charter.md @@ -15,7 +15,7 @@ - 不把创作推断伪装成剧情事实。别名、重复项和指代冲突要确定性处置并留理由;只有会改变项目方向的分歧才升级给用户。 - 用轮廓、比例、色彩、材质、空间和重复视觉锚点建立差异,不靠细碎堆砌。角色、场景和道具应属于同一叙事世界。 - 默认资产优先稳定、清晰和复用;剧情阶段变化只进入对应外观版本或镜头,不污染默认锚点。 -- 项目题材和总情绪不是所有资产的固定前缀。媒介核只锁定渲染事实;温暖、压迫、荒凉、诡异等局部气质应随当前资产的叙事功能分流,不能用一句全局口号覆盖相反情境。localTone 只能在已确认的媒介、时代地域和世界类型内调节当前可见氛围,不得借机发明另一套文化母题、建筑类型或事件场面。 +- 项目题材和总情绪不是所有资产的固定前缀。媒介核只锁定渲染事实;温暖、压迫、荒凉、诡异等局部气质应随当前资产的叙事功能分流,不能用一句全局口号覆盖相反情境。localTone 只能在已确认的媒介、时代地域、世界类型和资产事实内调节当前可见氛围,不得借机发明另一套文化母题、建筑类型或事件场面。 - 辨识度服从人设:英雄型主角可使用独占轮廓、专属色或贯穿符号;普通人代入型主角保持大众形象,只用少量稳定特征定位。功能角色明确让位。 - 核心角色的戏剧功能必须通过年龄层级、轮廓、眼神、妆发、姿态、服装与材质等可见选择被读出,并在脱离配对、缩略图单看时仍然成立;但不自动把善恶绑定到固定面孔类型或生理丑化。高碰撞角色应先做轻量并排差异设计,不靠岗位四临时堆形容词补救,也不把某组目光、机位或配饰固化成通用反派配方。 - 不根据名字或剧本语言推断个别角色的族裔。剧本未明确时,在“人物群体呈现”中作项目级决定:默认按剧本文化语境的主流面孔统一呈现;除非剧本明确写了多元设定,不把多族裔均衡分布当商业默认。不把族裔编码为善恶或权力等级。 diff --git a/references/invariants.md b/references/invariants.md index 433927f..e4f3af5 100644 --- a/references/invariants.md +++ b/references/invariants.md @@ -23,6 +23,8 @@ - 工作台展示、用户审查、批次保存与供应商提交必须使用同一份物化 prompt;审查后不得静默改写。 - 审查轴与供应商执行轴分开保存。`approved` 不等于 `ready`;旧 review patch 不得覆盖 submitted 或 succeeded。 - `changes_requested` 不得被批量放行覆盖。 +- 已 approved 的正文或文字策略被官方脚本改写时,审查自动回 needs_review,不静默保留旧批准。 +- v3 下总纲媒介块可确定性取证且唯一时,F02 逐字开头由 validate 与批量放行机械校验;解析不确定时放行,宁漏勿误伤。 ## 资产与生成依赖 diff --git a/references/prompt-authoring.md b/references/prompt-authoring.md index 2b9f392..c828e6a 100644 --- a/references/prompt-authoring.md +++ b/references/prompt-authoring.md @@ -10,7 +10,7 @@ ## 不可协商的正文契约 -- **F02 CONTRACT 必须逐字一致**:正文开头原样使用总纲中“所属维度 × 本资产类型”的完整媒介与画质词块,不改写、不删钉子、不混抄另一类型。这是跨资产风格一致的文字锚。localTone 只能在已锁定媒介、时代地域、世界类型和资产事实之内补充当前资产的可见气质,不能改写或替代媒介核,也不能借气氛发明新的文化母题、建筑类型或正在发生的事件。 +- **F02 CONTRACT 必须逐字一致**:正文开头原样使用总纲中“所属维度 × 本资产类型”的完整媒介与画质词块,不改写、不删钉子、不混抄另一类型。这是跨资产风格一致的文字锚;总纲媒介块可确定性取证且唯一时,validate 与批量放行会机械比对正文开头,不一致即拒绝回流重写。localTone 只能在已锁定媒介、时代地域、世界类型和资产事实之内补充当前资产的可见气质,不能改写或替代媒介核,也不能借气氛发明新的文化母题、建筑类型或正在发生的事件。 - 服从圣经事实和已确认方向。基础身份事实明确的照实写;“未明确”项按项目级“人物群体呈现”决定,缺少决定时回报主代理,不自行发明。 - 语义覆盖当前呈现 requirements;体态事实优先于通用姿态。比例数字可以省略,写出时必须与配置 ratio 完全一致。 - 只描述所选构图可见范围;上半身资产不展开画外下装、鞋靴和裙摆。这里由 Agent 的语义自检负责,不用汉语词表猜测越界。 diff --git a/references/subagent-contracts.md b/references/subagent-contracts.md index 3446fa8..774c6e6 100644 --- a/references/subagent-contracts.md +++ b/references/subagent-contracts.md @@ -47,7 +47,7 @@ 先运行 `plan_prompt_shards.mjs --missing-only`。shard 是审计和覆盖单位;bundle、建议并发和预期波数是 planner 提供的调度基线,不是唯一合法分配。保持同一角色全部版本在同一上下文,主代理可按复杂度和可用槽位调整派发;禁止按资产类型只开三个大任务。完成后按 shardId 对账,并回报实际波数、耗时与计划偏差。 -任务卡必须自包含:名册条目、相关圣经事实、对应媒介与资产类型词块、相关核心角色差异矩阵行、当前资产确有需要时的一句 localTone、呈现 requirements 与可见范围、文字策略。localTone 必须来自已确认世界语言且不改写媒介、时代地域或世界类型;没有相关矩阵行时省略,不为功能角色强造差异;不要让 worker 回读大文件。 +任务卡必须自包含:名册条目、相关圣经事实、对应媒介与资产类型词块、相关核心角色差异矩阵行、当前资产确有需要时的一句 localTone、呈现 requirements 与可见范围、文字策略。localTone 必须来自总纲"世界语言与局部气质分流"且不改写媒介、时代地域、世界类型或资产事实;没有相关矩阵行时省略,不为功能角色强造差异;不要让 worker 回读大文件。 ## Sidecar 与 checkpoint diff --git a/scripts/apply_review_decisions.mjs b/scripts/apply_review_decisions.mjs index 0324f21..4299ffa 100644 --- a/scripts/apply_review_decisions.mjs +++ b/scripts/apply_review_decisions.mjs @@ -49,6 +49,11 @@ if (document.projectId !== state.project?.id) { console.error("审查决定 projectId 不匹配:" + (document.projectId ?? "<缺失>") + " != " + (state.project?.id ?? "<缺失>")); process.exit(1); } +// 决定文件基于的 revision 落后于当前状态时提醒(不硬拦:每资产校验与执行轴保护仍然生效, +// 但决定可能基于已被 merge/reconcile 改变的内容,建议核对后重新生成审查包)。 +if (Number.isInteger(document.baseStateRevision) && document.baseStateRevision !== (state.revision ?? 0)) { + console.warn("警告:审查决定基于 revision " + document.baseStateRevision + ",当前项目 revision=" + (state.revision ?? 0) + ";请确认决定仍针对当前内容。"); +} const records = listAssets(state); const byId = new Map(records.map((record) => [record.asset.id, record])); @@ -104,6 +109,12 @@ for (const [id, decision] of Object.entries(document.decisions ?? {})) { if (decision.imageUrl != null) asset.image = decision.imageUrl.trim() ? { ...(asset.image ?? {}), url: decision.imageUrl.trim() } : null; asset.review = { ...(asset.review ?? {}) }; if (decision.note != null) asset.review.note = decision.note; + // 输入变化后旧审查失效:决定改写了正文/文字策略却未显式给出新 status 时, + // 已 approved 的资产自动回 needs_review,不静默保留旧批准。 + const changedPromptInputs = ["promptBody", "textPolicy", "deltaPrompt", "prompt"].some((field) => decision[field] != null); + if (changedPromptInputs && decision.status == null && reviewStatus(asset) === "approved") { + asset.review.promptStatus = "needs_review"; + } if (decision.status != null) asset.review.promptStatus = decision.status; if (decision.imageStatus != null) asset.review.imageStatus = decision.imageStatus; if (decision.lockedReference === false) asset.referenceAnchor = null; @@ -115,6 +126,12 @@ for (const [id, decision] of Object.entries(document.decisions ?? {})) { }; asset.review.imageStatus = "approved"; } + // 只为本次决定触及的资产盖章:记录该决定针对的编译输入,供审计对照。 + const compiledForDecision = compilePrompt(state, record); + asset.generatedAgainst = { + styleHash: styleHash(state), + promptSourceHash: compiledForDecision.sourceHash + }; applied += 1; } @@ -130,11 +147,6 @@ for (const record of records) { } else { asset.status = reviewStatus(asset); } - const compiled = compilePrompt(state, record); - asset.generatedAgainst = { - styleHash: styleHash(state), - promptSourceHash: compiled.sourceHash - }; } const revision = applied ? nextRevision(state) : state.revision ?? 0; diff --git a/scripts/approve_prompt_batch.mjs b/scripts/approve_prompt_batch.mjs index a32d9ca..8437557 100644 --- a/scripts/approve_prompt_batch.mjs +++ b/scripts/approve_prompt_batch.mjs @@ -4,6 +4,8 @@ // 质量自检通过的正文批量写入 review-decisions.json 置 approved;带可读文字的资产默认留人审; // changes_requested(人工打回)永不覆盖。写入后仍走 apply_review_decisions.mjs 统一应用。 import path from "node:path"; +import { readFile } from "node:fs/promises"; +import { mediaCandidatesForType, parseMediaBlocks } from "./audit_prompt_bodies.mjs"; import { hasReadableImageText, listAssets, @@ -51,6 +53,17 @@ if (document.projectId !== state.project?.id) { process.exit(1); } +// F02 CONTRACT 机械门禁(仅 v3,与 validate 同一规则):总纲媒介块可确定性取证且唯一时, +// 正文开头必须逐字一致;解析不确定一律放行,宁漏勿误伤。 +let f02Blocks = null; +const directionPath = state.workflow?.artDirection?.path; +if (profile.version === promptProfileBodyVersion && state.workflow?.artDirection?.status === "complete" && typeof directionPath === "string" && directionPath.trim()) { + try { + const parsed = parseMediaBlocks(await readFile(path.resolve(path.dirname(path.resolve(stateFile)), directionPath), "utf8")); + if (parsed.status === "supported") f02Blocks = parsed; + } catch {} +} + function promptQualityReasons(record) { const reasons = []; const findings = promptBodyFindings(state, record); @@ -66,6 +79,12 @@ function promptQualityReasons(record) { if (findings.textPolicyReasons.length) reasons.push(...findings.textPolicyReasons); if (findings.unspecifiedText.length) reasons.push("画面文字未指定语言:" + findings.unspecifiedText.join("、")); if (findings.mismatchedRatios.length) reasons.push("比例数字与配置不一致:" + findings.mismatchedRatios.join("、")); + if (f02Blocks) { + const f02Candidates = mediaCandidatesForType(f02Blocks, record.assetType); + if (f02Candidates.length === 1 && !String(record.asset.promptBody ?? "").trim().startsWith(f02Candidates[0])) { + reasons.push("正文未以总纲对应媒介块逐字开头(F02 CONTRACT)"); + } + } } if (findings.nonChinese) reasons.push("正文必须以中文为主"); return reasons; diff --git a/scripts/audit_prompt_bodies.mjs b/scripts/audit_prompt_bodies.mjs index 0b3c637..e8959a1 100644 --- a/scripts/audit_prompt_bodies.mjs +++ b/scripts/audit_prompt_bodies.mjs @@ -107,7 +107,8 @@ function repeatedViewObservation(body) { return { viewClauseCount: viewClauses.length, repeatedTokens: tokens }; } -function parseMediaBlocks(markdown) { +// 供 validate 与批量放行的 F02 门禁复用:解析结果为 unsupported 时调用方一律放行(宁漏勿误伤)。 +export function parseMediaBlocks(markdown) { const text = String(markdown ?? ""); const start = text.search(/^##\s+媒介与画质\s*$/mu); if (start < 0) return { status: "unsupported", reason: "media_section_not_found", rows: [] }; @@ -124,7 +125,7 @@ function parseMediaBlocks(markdown) { return { status: "supported", reason: null, rows }; } -function mediaCandidatesForType(parsed, assetType) { +export function mediaCandidatesForType(parsed, assetType) { if (parsed.status !== "supported") return []; const labelMatches = (label) => { if (assetType === "character-state") return /角色/u.test(label) && !/场景|道具/u.test(label); diff --git a/scripts/lib/project.mjs b/scripts/lib/project.mjs index 9df27e9..ce3e4d2 100644 --- a/scripts/lib/project.mjs +++ b/scripts/lib/project.mjs @@ -296,8 +296,8 @@ export function isPrimarilyChinese(value) { const text = cleanSegment(value); if (!text) return false; const hanCount = (text.match(/[\u3400-\u9fff]/g) ?? []).length; - // \u62c9\u4e01\u6587\u6309"\u8bcd"\u8ba1\u6570\u800c\u975e\u6309\u5b57\u6bcd\u8ba1\u6570\uff1b\u9996\u5b57\u6bcd\u5927\u5199\u7684\u8bcd\u6309\u4e13\u6709\u540d\u8bcd\u8c41\u514d\u2014\u2014 - // \u6d77\u5916\u5267\u672c\u7684\u4eba\u540d\u3001\u5730\u540d\uff08Mar\u00eda Fern\u00e1ndez\u3001Johnson\uff09\u53ef\u4fdd\u7559\u539f\u6587\u800c\u4e0d\u6324\u5360\u4e2d\u6587\u5360\u6bd4\u3002 + // 拉丁文按"词"计数而非按字母计数;首字母大写的词按专有名词豁免—— + // 海外剧本的人名、地名(María Fernández、Johnson)可保留原文而不挤占中文占比。 const latinWords = text.match(/[A-Za-z\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u00ff][A-Za-z\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u00ff'\u2019-]*/g) ?? []; const commonLatinWords = latinWords.filter((word) => !/^[A-Z\u00c0-\u00d6\u00d8-\u00de]/.test(word)); return hanCount >= 2 && hanCount >= commonLatinWords.length; diff --git a/scripts/merge_agent_patches.mjs b/scripts/merge_agent_patches.mjs index 0aa66af..0699562 100644 --- a/scripts/merge_agent_patches.mjs +++ b/scripts/merge_agent_patches.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { readFile } from "node:fs/promises"; import path from "node:path"; -import { listAssets, nextRevision, readJson, schemaVersion, writeJsonAtomic } from "./lib/project.mjs"; +import { listAssets, nextRevision, readJson, reviewStatus, schemaVersion, writeJsonAtomic } from "./lib/project.mjs"; const rosterDispositions = new Set(["included", "merged", "excluded", "category", "split"]); @@ -32,6 +32,9 @@ if (!stateFile || !patchFiles.length) { const state = await readJson(stateFile); state.workflow = { appliedOps: [], ...(state.workflow ?? {}) }; const applied = new Set(state.workflow.appliedOps ?? []); +// 幂等基线:本次运行开始前已应用的 opId。operations 与 roster 两个循环都以它判断跳过, +// 避免 operations 循环先标记 opId 导致同一 patch 的 roster 在首次合并被误跳、重复合并时空转 bump revision。 +const alreadyApplied = new Set(applied); const existing = () => new Map([ ...(state.assets?.characters ?? []).map((asset) => [asset.id, { asset, character: null, assetType: "character" }]), ...listAssets(state).map((record) => [record.asset.id, record]) @@ -63,7 +66,7 @@ if (conflicts.length) { let operationCount = 0; for (const { file, patch } of patches) { - if (applied.has(patch.opId)) continue; + if (alreadyApplied.has(patch.opId)) continue; for (const operation of patch.operations) { const records = existing(); if (operation.op === "add") { @@ -86,15 +89,27 @@ for (const { file, patch } of patches) { conflicts.push(file + " 包含无效 update operation:" + (operation.id ?? "<缺失>")); continue; } + // 角色容器只承载身份材料;正文与文字策略属于具体资产(state/location/prop)。 const allowed = record.assetType === "character-state" ? new Set(["name", "visualBrief", "deltaPrompt", "promptBody", "textPolicy", "sourceRefs"]) - : new Set(["name", "description", "identityPrompt", "visualBrief", "deltaPrompt", "promptBody", "textPolicy", "sourceRefs", "usages"]); + : record.assetType === "character" + ? new Set(["name", "description", "identityPrompt", "visualBrief", "sourceRefs"]) + : new Set(["name", "description", "identityPrompt", "visualBrief", "deltaPrompt", "promptBody", "textPolicy", "sourceRefs", "usages"]); + const reviewInvalidatingFields = new Set(["promptBody", "textPolicy", "deltaPrompt"]); + let invalidatesReview = false; for (const [field, value] of Object.entries(operation.changes)) { if (!allowed.has(field)) { conflicts.push(file + " 不允许更新 " + operation.id + "." + field); continue; } record.asset[field] = value; + if (reviewInvalidatingFields.has(field)) invalidatesReview = true; + } + // 输入变化后旧审查失效:已 approved 的资产被改写正文或文字策略时,审查自动回 needs_review, + // 走正常审查/批量放行链路重新批准;不静默保留旧批准。 + if (invalidatesReview && record.assetType !== "character" && reviewStatus(record.asset) === "approved") { + record.asset.review = { ...(record.asset.review ?? {}), promptStatus: "needs_review" }; + if (["approved", "ready"].includes(record.asset.status)) record.asset.status = "needs_review"; } operationCount += 1; continue; @@ -111,7 +126,7 @@ for (const { file, patch } of patches) { const rosterEntries = new Map((state.workflow.roster?.entries ?? []).map((entry) => [entry.candidateId, entry])); let rosterUpdated = false; for (const { file, patch } of patches) { - if (patch.roster == null) continue; + if (patch.roster == null || alreadyApplied.has(patch.opId)) continue; if (!Array.isArray(patch.roster)) { conflicts.push(file + " roster 必须为数组"); continue; diff --git a/scripts/normalize_awb_image_results.mjs b/scripts/normalize_awb_image_results.mjs index e4dabf4..fc24d83 100644 --- a/scripts/normalize_awb_image_results.mjs +++ b/scripts/normalize_awb_image_results.mjs @@ -15,6 +15,29 @@ function plainObject(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } +// AWB 终态只返回带签名的 https URL;其对象路径(material/...)即 lj-awb 的稳定 backendPath, +// 是唯一可跨签名周期复用的图片定位——下游参考图 mapper 只接受它,签名 URL 会过期。 +function stableBackendPath(record) { + const candidates = [ + ...(Array.isArray(record.resultUrls) ? record.resultUrls : []), + ...(Array.isArray(record.originUrls) ? record.originUrls : []) + ]; + for (const value of candidates) { + if (!nonEmptyString(value)) continue; + let pathname; + try { + pathname = new URL(value).pathname.replace(/^\/+/, ""); + } catch { + continue; + } + try { + pathname = decodeURIComponent(pathname); + } catch {} + if (pathname.startsWith("material/")) return pathname; + } + return null; +} + const [batchFile, planFile, statusesFile] = process.argv.slice(2).filter((value) => !value.startsWith("--")); if (!batchFile || !planFile || !statusesFile) { console.error("用法: node normalize_awb_image_results.mjs [--output awb-results.json]"); @@ -152,7 +175,8 @@ for (const batchItem of batch.items ?? []) { generationKey: batchItem.generationKey, image: succeeded ? { url: record.resultUrls[0], - ...(Array.isArray(record.originUrls) && nonEmptyString(record.originUrls[0]) ? { originUrl: record.originUrls[0] } : {}) + ...(Array.isArray(record.originUrls) && nonEmptyString(record.originUrls[0]) ? { originUrl: record.originUrls[0] } : {}), + ...(stableBackendPath(record) ? { backendPath: stableBackendPath(record) } : {}) } : null, error: succeeded ? null : (nonEmptyString(record.errorMessage) ? record.errorMessage : "任务终态 " + taskStatus), completedAt diff --git a/scripts/plan_work_dag.mjs b/scripts/plan_work_dag.mjs index f0ad153..52845c4 100644 --- a/scripts/plan_work_dag.mjs +++ b/scripts/plan_work_dag.mjs @@ -105,7 +105,8 @@ for (const chunk of chunkIndex?.chunks ?? []) { type: "script-extraction", dependsOn: ["compile-assets"], itemIds: [chunk.id], - status: serializedAssets.includes(chunk.id) ? "completed" : "ready", + // 边界感知匹配:chunk id 后紧跟字母数字(如 chunk-1 命中 chunk-10)不算引用;其余匹配行为不变。 + status: new RegExp(chunk.id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "(?![A-Za-z0-9])").test(serializedAssets) ? "completed" : "ready", blockedReasons: [], inputHash: chunk.contentHash }); diff --git a/scripts/prepare_awb_execution.mjs b/scripts/prepare_awb_execution.mjs index 0d8e4da..8ecb31e 100644 --- a/scripts/prepare_awb_execution.mjs +++ b/scripts/prepare_awb_execution.mjs @@ -9,7 +9,7 @@ function option(name, fallback = null) { const [batchFile] = process.argv.slice(2); if (!batchFile || batchFile.startsWith("--")) { - console.error("用法: node prepare_awb_execution.mjs --model-group-code --project-group-no --estimated-cost <积分> [--fixed-generate-num 1] [--concurrency 4] [--expected-waves N] [--retry-policy failed-only] [--output execution-plan.json]"); + console.error("用法: node prepare_awb_execution.mjs --model-group-code --project-group-no --estimated-cost <积分> [--fixed-generate-num 1] [--concurrency 4] [--expected-waves N] [--retry-policy failed-only] [--task-record-file path] [--output execution-plan.json]"); process.exit(1); } diff --git a/scripts/reconcile_awb_results.mjs b/scripts/reconcile_awb_results.mjs index c213f50..1984372 100644 --- a/scripts/reconcile_awb_results.mjs +++ b/scripts/reconcile_awb_results.mjs @@ -63,7 +63,9 @@ for (const result of results.items) { const same = asset.execution?.batchId === batch.batchId && asset.execution?.attemptId === result.attemptId && asset.execution?.taskId === result.taskId - && asset.execution?.status === result.status; + && asset.execution?.status === result.status + // 同一 attempt 重新归一化可能带来新签名 URL 或补齐 backendPath,不能因状态相同而丢弃。 + && (result.status !== "succeeded" || (asset.image?.url === result.image?.url && asset.image?.backendPath === result.image?.backendPath)); if (same) continue; const artifact = result.status === "succeeded" ? { ...result.image, @@ -124,7 +126,9 @@ for (const record of records) { } } -if (changed || unlocked.length) nextRevision(state); -await writeJsonAtomic(stateFile, state); +if (changed || unlocked.length) { + nextRevision(state); + await writeJsonAtomic(stateFile, state); +} console.log("已合并 " + changed + " 条新结果;" + unlocked.length + " 个资产已逐角色解锁。"); if (unlocked.length) console.log("unlocked=" + unlocked.join(",")); diff --git a/scripts/record_art_direction_brief.mjs b/scripts/record_art_direction_brief.mjs index dfc46ab..2c35ebe 100644 --- a/scripts/record_art_direction_brief.mjs +++ b/scripts/record_art_direction_brief.mjs @@ -29,7 +29,8 @@ if (!stateFile || stateFile.startsWith("--")) { // v3(整段正文)模板含"媒介与画质"章节(无逐字拼接段);v2(锚定式)模板含"公共风格锚点";旧模板(公共视觉语言/全局渲染语言)继续按 v1 处理。 const bodyBased = (markdown) => /^##\s+媒介与画质\s*$/m.test(markdown); const anchorBased = (markdown) => /^##\s+公共风格锚点\s*$/m.test(markdown); -const requiredHeadingsV3 = ["创作命题", "用户确认的方向", "媒介与画质", "人物群体呈现", "画面内文字语言", "分类型美术语言", "正向设计重点", "角色、场景与道具设计原则", "待确认项"]; +// "世界语言与局部气质分流"是 localTone 与差异矩阵语义的来源章节,锁定前必须完成(2026-07-19 起)。 +const requiredHeadingsV3 = ["创作命题", "用户确认的方向", "媒介与画质", "世界语言与局部气质分流", "人物群体呈现", "画面内文字语言", "分类型美术语言", "正向设计重点", "角色、场景与道具设计原则", "待确认项"]; const requiredHeadingsV2 = ["创作命题", "用户确认的方向", "公共风格锚点", "人物群体呈现", "分类型美术语言", "正向设计重点", "角色、场景与道具设计原则", "待确认项"]; const requiredHeadingsV1 = ["创作命题", "用户确认的方向", "公共视觉语言", "全局渲染语言", "正向设计重点", "角色、场景与道具设计原则", "待确认项"]; const statePath = path.resolve(stateFile); @@ -56,6 +57,20 @@ if (briefRelativePath.startsWith("..") || path.isAbsolute(briefRelativePath)) { process.exit(1); } const brief = await readFile(briefPath, "utf8"); +// 幂等提前:已锁定且内容与绑定完全未变时直接返回,不再重跑内容校验—— +// 必填章节等校验规则会随版本演进,不应影响存量锁定项目的重复 record。 +{ + const current = state.workflow?.artDirection; + if (current?.status === "complete" + && current.path === briefRelativePath + && current.sourceHash === state.project.sourceHash + && current.storyBibleHash === state.workflow?.storyBible?.contentHash + && current.setupHash === (state.workflow?.setup?.contentHash ?? null) + && current.contentHash === hashValue(brief)) { + console.log("项目创作方向已锁定且内容未变;无需重复确认。"); + process.exit(0); + } +} const usesBody = bodyBased(brief); const usesAnchor = !usesBody && anchorBased(brief); const requiredHeadings = usesBody ? requiredHeadingsV3 : usesAnchor ? requiredHeadingsV2 : requiredHeadingsV1; diff --git a/scripts/record_execution_approval.mjs b/scripts/record_execution_approval.mjs index 43bca76..6df54a2 100644 --- a/scripts/record_execution_approval.mjs +++ b/scripts/record_execution_approval.mjs @@ -34,6 +34,10 @@ if (process.argv.includes("--auto")) { process.exit(1); } const state = await readJson(stateFile); + if (plan.projectId != null && plan.projectId !== state.project?.id) { + console.error("--state 的项目(" + (state.project?.id ?? "<缺失>") + ")与执行计划的项目(" + plan.projectId + ")不一致;持久授权不得跨项目使用。"); + process.exit(1); + } const threshold = state.config?.authorizations?.autoApproveBelow; if (!Number.isFinite(threshold)) { console.error("项目未记录扣费免确认授权;请向用户展示计划并等待确认,或在用户授权后运行 record_standing_authorizations.mjs。"); diff --git a/scripts/record_story_bible.mjs b/scripts/record_story_bible.mjs index 0981e15..df0ab13 100644 --- a/scripts/record_story_bible.mjs +++ b/scripts/record_story_bible.mjs @@ -39,9 +39,13 @@ if (bibleRelativePath.startsWith("..") || path.isAbsolute(bibleRelativePath)) { process.exit(1); } +if (typeof state.project?.sourceScript !== "string" || !state.project.sourceScript.trim()) { + console.error("project.sourceScript 缺失,无法校验原剧本绑定。"); + process.exit(1); +} const [bible, source] = await Promise.all([ readFile(biblePath, "utf8"), - readFile(path.resolve(projectRoot, state.project?.sourceScript ?? ""), "utf8") + readFile(path.resolve(projectRoot, state.project.sourceScript), "utf8") ]); const sourceHash = hashValue(source); if (sourceHash !== state.project?.sourceHash) { diff --git a/scripts/validate_project_state.mjs b/scripts/validate_project_state.mjs index 424c79f..403339d 100644 --- a/scripts/validate_project_state.mjs +++ b/scripts/validate_project_state.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import { artDirection, assetPresentation, assetPresentationScope, assetPresentationVersion, defaultState, executionStatus, findCharacterSpecificTerms, hasCompletedArtDirection, hasCompletedSetup, hasCompletedStoryBible, hashValue, imageLocator, imageSettingsForRecord, isPrimarilyChinese, listAssets, promptBodyFindings, promptProfileAnchorVersion, promptProfileBodyVersion, promptProfileVersion, readJson, readiness, requiresStructuredTextPolicy, resolvedPromptProfile, schemaVersion, setup, storyBible, structuredTextPolicyVersion, styleAnchorMaxLength } from "./lib/project.mjs"; +import { mediaCandidatesForType, parseMediaBlocks } from "./audit_prompt_bodies.mjs"; import { access, readFile } from "node:fs/promises"; import path from "node:path"; @@ -154,6 +155,21 @@ if (state.config?.promptLanguage === "zh-CN") { const anchoredProfile = promptProfile.version === promptProfileAnchorVersion; const bodyProfile = anchoredProfile || promptProfile.version === promptProfileBodyVersion; +// F02 CONTRACT 机械门禁(仅 v3):总纲已锁定且"媒介与画质"能被确定性解析、且当前资产类型 +// 恰有唯一媒介块时,正文开头必须逐字一致;解析不确定(unsupported/多候选)一律放行,宁漏勿误伤。 +let f02Blocks = null; +if (promptProfile.version === promptProfileBodyVersion && direction?.status === "complete" && hasCompletedArtDirection(state) + && typeof direction.path === "string" && direction.path.trim()) { + const f02BriefPath = path.resolve(path.dirname(path.resolve(stateFile)), direction.path); + const f02Relative = path.relative(path.dirname(path.resolve(stateFile)), f02BriefPath); + if (!f02Relative.startsWith("..") && !path.isAbsolute(f02Relative)) { + try { + const parsed = parseMediaBlocks(await readFile(f02BriefPath, "utf8")); + if (parsed.status === "supported") f02Blocks = parsed; + } catch {} + } +} + for (const character of state.assets?.characters ?? []) { registerId(character.id, "角色"); assert(typeof character.name === "string" && character.name.trim(), "角色 " + character.id + " 缺少 name"); @@ -198,6 +214,12 @@ for (const record of listAssets(state)) { } assert(findings.unspecifiedText.length === 0, asset.id + " 的 promptBody 要求画面出现可读文字(" + findings.unspecifiedText.join("、") + ")但未指定文字语言或声明不可读"); assert(findings.mismatchedRatios.length === 0, asset.id + " 的 promptBody 中的画幅比例(" + findings.mismatchedRatios.join("、") + ")与呈现配置不一致"); + if (f02Blocks) { + const f02Candidates = mediaCandidatesForType(f02Blocks, record.assetType); + if (f02Candidates.length === 1) { + assert(asset.promptBody.trim().startsWith(f02Candidates[0]), asset.id + " 的 promptBody 未以总纲对应媒介块逐字开头(F02 CONTRACT);请原样抄写\"" + f02Candidates[0].slice(0, 24) + "…\"后重写"); + } + } } } } else if (state.config?.promptLanguage === "zh-CN") { diff --git a/scripts/workspace_server.mjs b/scripts/workspace_server.mjs index 2ce5ff4..fb77497 100644 --- a/scripts/workspace_server.mjs +++ b/scripts/workspace_server.mjs @@ -200,16 +200,17 @@ async function optionalJson(file) { // Viewer 只需要兼容解析后的只读投影。旧 render 的版式污染由后端唯一解析器过滤, // 避免浏览器维护另一份正则;canonical /api/project-state 仍原样提供。 +// 显式 version 1 的项目同样投影过滤后的全局渲染语言,保证工作台展示与后端编译同文。 function projectStateForViewer(state) { const rawProfile = state.config?.promptProfile ?? {}; - const versioned = [1, 2, 3].includes(rawProfile.version); - if (versioned) return state; + if ([2, 3].includes(rawProfile.version)) return state; const profile = resolvedPromptProfile(state); return { ...state, config: { ...(state.config ?? {}), promptProfile: { + ...(rawProfile.version === 1 ? rawProfile : {}), version: 1, commonVisualLanguage: profile.commonVisualLanguage, globalRenderLanguage: profile.globalRenderLanguage diff --git a/tests/execution-chain.test.mjs b/tests/execution-chain.test.mjs new file mode 100644 index 0000000..a047565 --- /dev/null +++ b/tests/execution-chain.test.mjs @@ -0,0 +1,472 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +import { compilePrompt, hashValue, listAssets, readiness } from "../scripts/lib/project.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const run = (script, args) => spawnSync("node", [path.join(root, "scripts", script), ...args], { encoding: "utf8" }); + +const characterMedia = "国风仙侠三维游戏角色美术,非真人感,低细节皮肤,无毛孔,哑光分束发丝,柔和漫射光,高清细节。"; +const scenePropMedia = "国风仙侠三维游戏场景美术,黑石与木质材质体积清晰,稀薄冷光,高清细节。"; + +function chainState() { + return { + schemaVersion: 3, + revision: 4, + project: { id: "project-chain-test", title: "执行链测试", sourceScript: "剧本.md", sourceHash: "hash-script" }, + config: { + promptLanguage: "zh-CN", + styleProfile: { id: "s", name: "测试", contentHash: "hash-style" }, + promptProfile: { version: 3, textPolicyVersion: 1 }, + image: { quality: "2k" }, + assetPresentation: { + version: 1, + characterDefault: { id: "l", name: "布局", ratio: "4:3", prompt: "设定板", requirements: [] }, + characterState: { id: "ls", name: "状态", ratio: "4:3", prompt: "状态图", requirements: [] }, + location: { id: "loc", name: "场景", ratio: "16:9", prompt: "场景图", requirements: [] }, + prop: { id: "p", name: "道具", ratio: "4:3", prompt: "道具图", requirements: [] } + } + }, + workflow: { + storyBible: { path: "story-asset-bible.md", sourceHash: "hash-script", contentHash: "hash-bible", status: "complete" }, + artDirection: { path: "art-direction-brief.md", sourceHash: "hash-script", storyBibleHash: "hash-bible", setupHash: null, contentHash: "hash-dir", status: "complete" }, + appliedOps: [] + }, + assets: { + characters: [{ + id: "character-a", + name: "阿一", + states: [ + { + id: "character-a-default", + name: "默认外观", + sequence: 0, + status: "succeeded", + review: { promptStatus: "approved" }, + textPolicy: { mode: "none" }, + promptBody: characterMedia + "白衣长发修士,清瘦挺拔,纯白背景。", + execution: { status: "succeeded", batchId: "batch-old", attemptId: "attempt-0", taskId: "task-0" }, + referenceAnchor: { artifactId: "artifact-anchor" }, + image: { artifactId: "artifact-anchor", url: "https://cos.example.com/material/image-draw/anchor.png?sign=abc", backendPath: "material/image-draw/anchor.png" }, + artifacts: [] + }, + { + id: "character-a-battle", + name: "战损外观", + sequence: 1, + status: "ready", + review: { promptStatus: "approved" }, + referenceStateId: "character-a-default", + textPolicy: { mode: "none" }, + promptBody: characterMedia + "同一修士的战损外观,衣袍破损,纯白背景。" + } + ] + }], + locations: [], + props: [] + } + }; +} + +async function writeChainProject(dir, state) { + await writeFile(path.join(dir, "剧本.md"), "剧本原文\n"); + await writeFile(path.join(dir, "story-asset-bible.md"), "# 故事资产圣经\n"); + const bibleHash = hashValue(await readFile(path.join(dir, "story-asset-bible.md"), "utf8")); + state.workflow.storyBible.contentHash = bibleHash; + state.workflow.artDirection.storyBibleHash = bibleHash; + const stateFile = path.join(dir, "project-state.json"); + await writeFile(stateFile, JSON.stringify(state, null, 2)); + return stateFile; +} + +test("参考图链路端到端:锁定默认态的 backendPath 进入批次并映射为 AWB 参考资源", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "comic-refchain-")); + try { + const state = chainState(); + const stateFile = await writeChainProject(dir, state); + const batchFile = path.join(dir, "image-batch.json"); + const built = run("build_image_batch.mjs", [stateFile, "--scope", "character:character-a", "--output", batchFile]); + assert.equal(built.status, 0, built.stderr); + const batch = JSON.parse(await readFile(batchFile, "utf8")); + const item = batch.items.find((entry) => entry.id === "character-a-battle"); + assert.ok(item, "后续外观版本应入批"); + assert.equal(item.reference?.image?.backendPath, "material/image-draw/anchor.png", "批次必须携带锚点的稳定 backendPath"); + + const jsonlFile = path.join(dir, "awb-input.jsonl"); + const mappingFile = path.join(dir, "awb-input.mapping.json"); + const mapped = run("map_awb_image_batch.mjs", [batchFile, "--fixed-generate-num", "1", "--output", jsonlFile, "--mapping", mappingFile]); + assert.equal(mapped.status, 0, mapped.stderr); + const line = JSON.parse((await readFile(jsonlFile, "utf8")).trim().split("\n")[0]); + assert.equal(line.resources?.[0]?.source?.value, "material/image-draw/anchor.png", "AWB 参考资源必须使用稳定 backendPath"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("归一化从签名 URL 提取稳定 backendPath", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "comic-normalize-")); + try { + const batch = { + projectId: "project-chain-test", + batchId: "batch-n1", + batchHash: "hash-batch", + sourceStateRevision: 4, + items: [{ id: "character-a-default", promptHash: "hash-p", generationKey: "key-1" }] + }; + const plan = { + projectId: "project-chain-test", + batchId: "batch-n1", + batchHash: "hash-batch", + sourceStateRevision: 4, + executionPlanHash: "hash-plan", + itemCount: 1, + approval: { executionPlanHash: "hash-plan", batchHash: "hash-batch" }, + items: [{ id: "character-a-default", generationKey: "key-1", attemptId: "attempt-1", taskId: "task-1" }] + }; + const statuses = { + checkedAt: "2026-07-20T00:00:00.000Z", + records: [{ + taskId: "task-1", + taskStatus: "SUCCESS", + isTerminal: true, + gmtModified: "2026-07-20T00:01:00.000Z", + resultUrls: ["https://cos.example.com/material/image-draw/result-1.png?sign=zzz"] + }] + }; + const batchFile = path.join(dir, "image-batch.json"); + const planFile = path.join(dir, "execution-plan.json"); + const statusesFile = path.join(dir, "awb-statuses.json"); + const outputFile = path.join(dir, "awb-results.json"); + await writeFile(batchFile, JSON.stringify(batch)); + await writeFile(planFile, JSON.stringify(plan)); + await writeFile(statusesFile, JSON.stringify(statuses)); + const normalized = run("normalize_awb_image_results.mjs", [batchFile, planFile, statusesFile, "--output", outputFile]); + assert.equal(normalized.status, 0, normalized.stderr); + const results = JSON.parse(await readFile(outputFile, "utf8")); + assert.equal(results.items[0].image.url, "https://cos.example.com/material/image-draw/result-1.png?sign=zzz"); + assert.equal(results.items[0].image.backendPath, "material/image-draw/result-1.png"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("checkpoint 状态机:非法迁移拒绝、submitting 需确认与 attempt、submitted 需 taskId", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "comic-checkpoint-")); + try { + const planFile = path.join(dir, "execution-plan.json"); + const writePlan = (status, approval = { executionPlanHash: "hash-plan" }) => writeFile(planFile, JSON.stringify({ + executionPlanHash: "hash-plan", + approval, + items: [{ id: "item-1", status }] + })); + + await writePlan("prepared"); + const illegal = run("update_execution_checkpoint.mjs", [planFile, "--item", "item-1", "--status", "submitted"]); + assert.equal(illegal.status, 1); + assert.match(illegal.stderr, /非法 checkpoint 迁移/); + + await writePlan("prepared", { executionPlanHash: "hash-stale" }); + const unapproved = run("update_execution_checkpoint.mjs", [planFile, "--item", "item-1", "--status", "submitting", "--attempt-id", "attempt-1"]); + assert.equal(unapproved.status, 1); + assert.match(unapproved.stderr, /尚未获得当前 hash 的用户确认/); + + await writePlan("prepared"); + const noAttempt = run("update_execution_checkpoint.mjs", [planFile, "--item", "item-1", "--status", "submitting"]); + assert.equal(noAttempt.status, 1); + assert.match(noAttempt.stderr, /必须提供 attempt-id/); + + const submitting = run("update_execution_checkpoint.mjs", [planFile, "--item", "item-1", "--status", "submitting", "--attempt-id", "attempt-1"]); + assert.equal(submitting.status, 0, submitting.stderr); + assert.equal(JSON.parse(await readFile(planFile, "utf8")).items[0].taskId, null, "进入 submitting 必须清空旧 taskId"); + + const noTask = run("update_execution_checkpoint.mjs", [planFile, "--item", "item-1", "--status", "submitted"]); + assert.equal(noTask.status, 1); + assert.match(noTask.stderr, /必须绑定 task-id/); + + const submitted = run("update_execution_checkpoint.mjs", [planFile, "--item", "item-1", "--status", "submitted", "--task-id", "task-9"]); + assert.equal(submitted.status, 0, submitted.stderr); + + await writePlan("succeeded"); + const resubmit = run("update_execution_checkpoint.mjs", [planFile, "--item", "item-1", "--status", "submitting", "--attempt-id", "attempt-2"]); + assert.equal(resubmit.status, 1, "succeeded 永不自动重提"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("执行确认:planHash 失配拒绝;--auto 拒绝跨项目使用持久授权", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "comic-approval-")); + try { + const planFile = path.join(dir, "execution-plan.json"); + await writeFile(planFile, JSON.stringify({ executionPlanHash: "hash-plan", batchHash: "hash-batch", estimatedCost: 10 })); + const mismatch = run("record_execution_approval.mjs", [planFile, "--confirm-plan-hash", "hash-outdated"]); + assert.equal(mismatch.status, 1); + assert.match(mismatch.stderr, /确认 hash 不匹配/); + + const stateFile = path.join(dir, "project-state.json"); + const state = chainState(); + state.config.authorizations = { autoApproveBelow: 100 }; + await writeFile(stateFile, JSON.stringify(state)); + await writeFile(planFile, JSON.stringify({ projectId: "project-someone-else", executionPlanHash: "hash-plan", batchHash: "hash-batch", estimatedCost: 10 })); + const crossProject = run("record_execution_approval.mjs", [planFile, "--auto", "--state", stateFile, "--confirm-plan-hash", "hash-plan"]); + assert.equal(crossProject.status, 1); + assert.match(crossProject.stderr, /持久授权不得跨项目使用/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("reconcile:succeeded 不降级、跨批次 submitted 不覆盖、重归一化补齐 backendPath 且无变化不写盘", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "comic-reconcile-")); + try { + const state = chainState(); + const stateFile = path.join(dir, "project-state.json"); + await writeFile(stateFile, JSON.stringify(state, null, 2)); + const records = listAssets(state); + const battle = records.find((record) => record.asset.id === "character-a-battle"); + const anchor = records.find((record) => record.asset.id === "character-a-default"); + const battleHash = hashValue(compilePrompt(state, battle).text); + const anchorHash = hashValue(compilePrompt(state, anchor).text); + const batchFile = path.join(dir, "image-batch.json"); + const resultsFile = path.join(dir, "awb-results.json"); + const writeBatch = (items) => writeFile(batchFile, JSON.stringify({ + projectId: "project-chain-test", batchId: "batch-new", batchHash: "hash-batch-new", sourceStateRevision: 4, items + })); + const writeResults = (items) => writeFile(resultsFile, JSON.stringify({ + projectId: "project-chain-test", batchId: "batch-new", sourceStateRevision: 4, items + })); + + // 已 succeeded 的默认态收到 failed 结果:拒绝回退。 + await writeBatch([{ id: "character-a-default", promptHash: anchorHash, generationKey: "key-a" }]); + await writeResults([{ id: "character-a-default", status: "failed", attemptId: "attempt-x", taskId: "task-x", promptHash: anchorHash, generationKey: "key-a", image: null, error: "boom", completedAt: "2026-07-20T00:00:00.000Z" }]); + const downgrade = run("reconcile_awb_results.mjs", [stateFile, resultsFile, "--batch", batchFile]); + assert.equal(downgrade.status, 1); + assert.match(downgrade.stderr, /已成功,拒绝回退/); + + // 绑定其他未完成批次的资产:拒绝覆盖。 + const submittedState = chainState(); + submittedState.assets.characters[0].states[1].status = "submitted"; + submittedState.assets.characters[0].states[1].execution = { status: "submitted", batchId: "batch-other", attemptId: "attempt-o", taskId: "task-o" }; + await writeFile(stateFile, JSON.stringify(submittedState, null, 2)); + await writeBatch([{ id: "character-a-battle", promptHash: battleHash, generationKey: "key-b" }]); + await writeResults([{ id: "character-a-battle", status: "failed", attemptId: "attempt-y", taskId: "task-y", promptHash: battleHash, generationKey: "key-b", image: null, error: "boom", completedAt: "2026-07-20T00:00:00.000Z" }]); + const crossBatch = run("reconcile_awb_results.mjs", [stateFile, resultsFile, "--batch", batchFile]); + assert.equal(crossBatch.status, 1); + assert.match(crossBatch.stderr, /已绑定其他未完成批次/); + + // 成功合并 → 同 attempt 重归一化补齐 backendPath → 完全相同的再跑不写盘。 + const freshState = chainState(); + await writeFile(stateFile, JSON.stringify(freshState, null, 2)); + const success = (image) => writeResults([{ id: "character-a-battle", status: "succeeded", attemptId: "attempt-z", taskId: "task-z", promptHash: battleHash, generationKey: "key-b", image, error: null, completedAt: "2026-07-20T00:00:00.000Z" }]); + await success({ url: "https://cos.example.com/material/image-draw/battle.png?sign=first" }); + const first = run("reconcile_awb_results.mjs", [stateFile, resultsFile, "--batch", batchFile]); + assert.equal(first.status, 0, first.stderr); + let merged = JSON.parse(await readFile(stateFile, "utf8")); + assert.equal(merged.assets.characters[0].states[1].execution.status, "succeeded"); + assert.equal(merged.assets.characters[0].states[1].image.backendPath, undefined); + + await success({ url: "https://cos.example.com/material/image-draw/battle.png?sign=first", backendPath: "material/image-draw/battle.png" }); + const second = run("reconcile_awb_results.mjs", [stateFile, resultsFile, "--batch", batchFile]); + assert.equal(second.status, 0, second.stderr); + merged = JSON.parse(await readFile(stateFile, "utf8")); + assert.equal(merged.assets.characters[0].states[1].image.backendPath, "material/image-draw/battle.png", "重归一化必须能补齐稳定定位"); + const revisionAfterSecond = merged.revision; + + const third = run("reconcile_awb_results.mjs", [stateFile, resultsFile, "--batch", batchFile]); + assert.equal(third.status, 0, third.stderr); + merged = JSON.parse(await readFile(stateFile, "utf8")); + assert.equal(merged.revision, revisionAfterSecond, "无变化的 reconcile 不得推进 revision"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("审查失效:apply/merge 改写已批准正文自动回 needs_review;submitted 不被降回", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "comic-review-reset-")); + try { + const state = chainState(); + state.revision = 4; + const stateFile = path.join(dir, "project-state.json"); + const decisionsFile = path.join(dir, "review-decisions.json"); + await writeFile(stateFile, JSON.stringify(state, null, 2)); + await writeFile(decisionsFile, JSON.stringify({ + schemaVersion: 3, + projectId: "project-chain-test", + baseStateRevision: 1, + decisions: { "character-a-battle": { promptBody: characterMedia + "改写后的战损外观描述,纯白背景。" } } + }, null, 2)); + const applied = run("apply_review_decisions.mjs", [stateFile, "--decisions", decisionsFile]); + assert.equal(applied.status, 0, applied.stderr); + assert.match(applied.stderr, /审查决定基于 revision 1/, "陈旧 baseStateRevision 必须提示"); + const afterApply = JSON.parse(await readFile(stateFile, "utf8")); + const battle = afterApply.assets.characters[0].states[1]; + assert.equal(battle.review.promptStatus, "needs_review", "改正文未给新 status 时必须回 needs_review"); + assert.equal(afterApply.assets.characters[0].states[0].status, "succeeded", "执行轴 succeeded 不被审查应用降回"); + + // merge 白名单改写已批准正文:同样自动失效。 + const mergeState = chainState(); + mergeState.revision = 7; + await writeFile(stateFile, JSON.stringify(mergeState, null, 2)); + const patchFile = path.join(dir, "patch.json"); + await writeFile(patchFile, JSON.stringify({ + schemaVersion: 3, + projectId: "project-chain-test", + opId: "op-rewrite-1", + baseRevision: 7, + operations: [{ op: "update", id: "character-a-battle", changes: { promptBody: characterMedia + "又一次改写的战损描述,纯白背景。" } }] + }, null, 2)); + const merged = run("merge_agent_patches.mjs", [stateFile, patchFile]); + assert.equal(merged.status, 0, merged.stderr); + const afterMerge = JSON.parse(await readFile(stateFile, "utf8")); + assert.equal(afterMerge.assets.characters[0].states[1].review.promptStatus, "needs_review"); + assert.equal(afterMerge.assets.characters[0].states[1].status, "needs_review"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("merge 幂等:已应用 opId 的 roster 重合并不推进 revision", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "comic-roster-idem-")); + try { + const state = chainState(); + state.revision = 3; + delete state.workflow.storyBible; // 无圣经路径时对账自动跳过,聚焦幂等语义 + const stateFile = path.join(dir, "project-state.json"); + await writeFile(stateFile, JSON.stringify(state, null, 2)); + const patch = (baseRevision) => ({ + schemaVersion: 3, + projectId: "project-chain-test", + opId: "op-roster-1", + baseRevision, + operations: [], + roster: [{ candidateId: "C-01", name: "阿一", disposition: "included", reason: "具名主角", assetIds: ["character-a"] }] + }); + const patchFile = path.join(dir, "patch.json"); + await writeFile(patchFile, JSON.stringify(patch(3), null, 2)); + const first = run("merge_agent_patches.mjs", [stateFile, patchFile]); + assert.equal(first.status, 0, first.stderr); + const afterFirst = JSON.parse(await readFile(stateFile, "utf8")); + assert.equal(afterFirst.workflow.roster.entries.length, 1, "首次合并必须写入名册"); + const revisionAfterFirst = afterFirst.revision; + + await writeFile(patchFile, JSON.stringify(patch(revisionAfterFirst), null, 2)); + const second = run("merge_agent_patches.mjs", [stateFile, patchFile]); + assert.equal(second.status, 0, second.stderr); + const afterSecond = JSON.parse(await readFile(stateFile, "utf8")); + assert.equal(afterSecond.revision, revisionAfterFirst, "重复合并同一 opId 不得空转推进 revision"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("批量放行:changes_requested 永不覆盖", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "comic-changes-req-")); + try { + const state = chainState(); + state.config.authorizations = { batchPromptApproval: true }; + state.assets.characters[0].states[1].status = "changes_requested"; + state.assets.characters[0].states[1].review = { promptStatus: "changes_requested" }; + const stateFile = path.join(dir, "project-state.json"); + const decisionsFile = path.join(dir, "review-decisions.json"); + await writeFile(stateFile, JSON.stringify(state, null, 2)); + await writeFile(decisionsFile, JSON.stringify({ schemaVersion: 3, projectId: "project-chain-test", decisions: {} }, null, 2)); + const approved = run("approve_prompt_batch.mjs", [stateFile, "--decisions", decisionsFile]); + assert.equal(approved.status, 0, approved.stderr); + const decisions = JSON.parse(await readFile(decisionsFile, "utf8")).decisions; + assert.equal(decisions["character-a-battle"], undefined, "人工打回项不得被批量放行覆盖"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("readiness 后续版本链:四个负向条件逐一给出理由", () => { + const brokenReference = chainState(); + brokenReference.assets.characters[0].states[1].referenceStateId = "character-a-wrong"; + const wrongRef = readiness(listAssets(brokenReference).find((record) => record.asset.id === "character-a-battle"), brokenReference); + assert.ok(wrongRef.reasons.some((reason) => reason.includes("未直接引用本角色默认状态")), wrongRef.reasons.join(";")); + + const anchorNotDone = chainState(); + anchorNotDone.assets.characters[0].states[0].execution = { status: "submitted" }; + anchorNotDone.assets.characters[0].states[0].status = "submitted"; + const notDone = readiness(listAssets(anchorNotDone).find((record) => record.asset.id === "character-a-battle"), anchorNotDone); + assert.ok(notDone.reasons.some((reason) => reason.includes("默认状态图片尚未成功")), notDone.reasons.join(";")); + + const anchorNotLocked = chainState(); + anchorNotLocked.assets.characters[0].states[0].referenceAnchor = null; + const notLocked = readiness(listAssets(anchorNotLocked).find((record) => record.asset.id === "character-a-battle"), anchorNotLocked); + assert.ok(notLocked.reasons.some((reason) => reason.includes("默认状态图片尚未锁定")), notLocked.reasons.join(";")); + + const anchorNoImage = chainState(); + anchorNoImage.assets.characters[0].states[0].image = null; + const noImage = readiness(listAssets(anchorNoImage).find((record) => record.asset.id === "character-a-battle"), anchorNoImage); + assert.ok(noImage.reasons.some((reason) => reason.includes("默认状态缺少有效图片定位")), noImage.reasons.join(";")); +}); + +test("F02 门禁:取证确定才拦,解析不确定一律放行", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "comic-f02-")); + try { + const brief = (characterRows) => `# 项目创作方向 + +## 媒介与画质 + +${characterRows} +- **主维度·场景与道具**:${scenePropMedia} +`; + const singleBlock = brief(`- **主维度·角色**:${characterMedia}`); + const state = chainState(); + const stateFile = await writeChainProject(dir, state); + const briefPath = path.join(dir, "art-direction-brief.md"); + + const rewrite = async (briefContent, mutate) => { + await writeFile(briefPath, briefContent); + const current = JSON.parse(await readFile(stateFile, "utf8")); + current.workflow.artDirection.contentHash = hashValue(briefContent); + if (mutate) mutate(current); + await writeFile(stateFile, JSON.stringify(current, null, 2)); + }; + + await rewrite(singleBlock); + const pass = run("validate_project_state.mjs", [stateFile]); + assert.equal(pass.status, 0, pass.stderr); + + await rewrite(singleBlock, (current) => { + current.assets.characters[0].states[1].promptBody = "没有抄媒介块的战损外观描述,纯白背景。"; + current.assets.characters[0].states[1].status = "needs_review"; + current.assets.characters[0].states[1].review = { promptStatus: "needs_review" }; + }); + const reject = run("validate_project_state.mjs", [stateFile]); + assert.equal(reject.status, 1, "取证确定且不逐字开头时必须拒绝"); + assert.match(reject.stderr, /F02 CONTRACT/); + + const ambiguous = brief(`- **主维度·角色**:${characterMedia}\n- **副维度·角色**:另一套角色媒介块,非真人感,高清细节。`); + await rewrite(ambiguous, (current) => { + current.assets.characters[0].states[1].promptBody = "没有抄媒介块的战损外观描述,纯白背景。"; + current.assets.characters[0].states[1].status = "needs_review"; + current.assets.characters[0].states[1].review = { promptStatus: "needs_review" }; + }); + const uncertain = run("validate_project_state.mjs", [stateFile]); + assert.equal(uncertain.status, 0, "同类型多候选媒介块时不得拦截:" + uncertain.stderr); + + // 批量放行走同一门禁:未逐字开头的资产进入未达标清单。 + await rewrite(singleBlock, (current) => { + current.config.authorizations = { batchPromptApproval: true }; + current.assets.characters[0].states[1].promptBody = "没有抄媒介块的战损外观描述,纯白背景。"; + current.assets.characters[0].states[1].status = "needs_review"; + current.assets.characters[0].states[1].review = { promptStatus: "needs_review" }; + }); + const decisionsFile = path.join(dir, "review-decisions.json"); + await writeFile(decisionsFile, JSON.stringify({ schemaVersion: 3, projectId: "project-chain-test", decisions: {} }, null, 2)); + const batch = run("approve_prompt_batch.mjs", [stateFile, "--decisions", decisionsFile]); + assert.equal(batch.status, 0, batch.stderr); + assert.match(batch.stdout, /F02 CONTRACT/, "批量放行必须报告 F02 未达标"); + const decisions = JSON.parse(await readFile(decisionsFile, "utf8")).decisions; + assert.equal(decisions["character-a-battle"], undefined, "F02 未达标资产不得被放行"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/prompt-body-v3.test.mjs b/tests/prompt-body-v3.test.mjs index dfb307b..95e2246 100644 --- a/tests/prompt-body-v3.test.mjs +++ b/tests/prompt-body-v3.test.mjs @@ -235,6 +235,10 @@ test("record v3:角色块可含人物钉子,场景道具块含人物钉子 - **主维度·角色**:国风仙侠三维游戏角色美术,非真人感,低细节皮肤,无毛孔,哑光分束发丝,夸张五官比例,柔和漫射光,高清细节。 - **主维度·场景与道具**:${scenePropBlock} +## 世界语言与局部气质分流 + +修真宗门世界语言稳定;局部气质随资产功能分流。 + ## 人物群体呈现 架空东方世界,未明确角色按东亚面貌呈现。 @@ -354,6 +358,10 @@ test("record v3:媒介与画质模板写入 version 3,缺画面内文字语 主维度:商业电影级真人实拍质感,现代哥特调性,低调电影光与跨资产一致的成像。 +## 世界语言与局部气质分流 + +现代哥特世界语言稳定;局部气质随资产功能分流。 + ## 人物群体呈现 西式命名与西式世界观,未明确角色按欧美面貌为主呈现。 From 9fbf438999d18882caa05e088d8cc343ecc9082b Mon Sep 17 00:00:00 2001 From: Shanzoon <82895720+Shanzoon@users.noreply.github.com> Date: Mon, 20 Jul 2026 03:34:22 +0800 Subject: [PATCH 10/11] =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8F=B0=E5=89=8D?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E6=94=B6=E6=95=9B:/api/view-state=20?= =?UTF-8?q?=E9=99=84=20viewComputed,viewer=20=E5=88=A0=E9=99=A4=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E9=80=BB=E8=BE=91=E6=8B=B7=E8=B4=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 每资产附后端编译文本与解析后呈现配置(含兜底);展示同文从巧合变机制 - viewer.js 删除 fallbackPresentations 拷贝、客户端编译与 uniqueSegments - 新增 workspace-view-state 测试:真实起服验证同文、兜底、405 与路径越权 - 全量测试 78/78 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 6 ++ assets/viewer-app/viewer.js | 39 ++--------- scripts/workspace_server.mjs | 43 ++++++++---- tests/workspace-view-state.test.mjs | 101 ++++++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 49 deletions(-) create mode 100644 tests/workspace-view-state.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 55cad25..bba859e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,12 @@ - 可逆动作直接执行并复述;锁定、超出持久授权的扣费和其他不可逆动作需要确认。高后果创作项必须用成图后果句显式呈现。 - 运行时按阶段读取 references;生产不变量、岗位准入、Prompt 契约、旧版本兼容和供应商边界各有唯一权威文件。 +## 2026-07-20 — 工作台前后端收敛:viewComputed 单一编译出口 + +- `/api/view-state` 新增 `viewComputed`:每资产附带后端 `compilePrompt()` 编译文本与解析后的呈现配置(含 setup-era 兜底文案)。viewer.js 删除三处后端逻辑拷贝(`fallbackPresentations` 逐字拷贝、客户端编译 `compiledPrompt`/`uniqueSegments`、presentation 兜底解析),改为直接消费 viewComputed——"工作台展示、批次、供应商提交同一物化文本"从"两份实现恰好相同"变为机制保证。 +- 新增 `tests/workspace-view-state.test.mjs`:真实起服校验 viewComputed 编译文本与后端逐字一致、缺省呈现走后端兜底、写入 405 限制与越权路径拒绝(补上 workspace_server 此前的零测试项)。 +- 兼容性:`/api/project-state` canonical 原样返回不变;viewer 与 server 同包发布,无独立部署路径。全量 78/78。 + ## 2026-07-20 — 独立架构审查回收:参考图链路修复、审查失效机械化与 F02 门禁 背景:C5 安装后的独立只读审查(含两个隔离子代理复核)发现执行后半程存在断链与零测试带;用户拍板 F02 进机械门禁、表达型角色板保留姿态放宽待成图验收、历史批次分次提交。 diff --git a/assets/viewer-app/viewer.js b/assets/viewer-app/viewer.js index 59ca55a..ebd8100 100644 --- a/assets/viewer-app/viewer.js +++ b/assets/viewer-app/viewer.js @@ -129,26 +129,6 @@ function displayStatus(row) { return reviewStatus(row.asset); } -function uniqueSegments(values) { - const seen = new Set(); - const output = []; - for (const value of values) { - const segment = String(value ?? "").trim().replace(/\s+/g, " "); - if (!segment) continue; - const key = segment.toLocaleLowerCase("zh-CN"); - if (seen.has(key)) continue; - seen.add(key); - output.push(segment); - } - return output; -} - -const fallbackPresentations = { - characterState: { prompt: "单一角色状态资产图,完整呈现当前造型、服装、发型、妆容、姿态与随身物变化,主体清晰且便于复用", ratio: "16:9" }, - location: { prompt: "单一场景环境设定图,宽幅构图,清晰呈现空间布局、建筑结构、材质、光线与可复用环境细节", ratio: "16:9" }, - prop: { prompt: "单一道具设定图,完整清晰呈现主体轮廓、结构、材质、尺度与关键识别细节", ratio: "4:3" } -}; - function resolvedPromptProfile() { const profile = app.state.config?.promptProfile ?? {}; if (profile.version === 3) { @@ -179,12 +159,10 @@ function presentationScope(row) { return row.kind; } +// 呈现配置(含兜底文案)与最终编译文本由 /api/view-state 的 viewComputed 提供, +// 浏览器不再维护后端逻辑的拷贝,保证展示与批次、供应商提交同文。 function presentationForRow(row) { - const scope = presentationScope(row); - const configured = app.state.config?.assetPresentation; - if (configured?.[scope]) return configured[scope]; - const setupEraProject = configured?.version === 1 || Boolean(configured?.characterDefault); - return setupEraProject ? fallbackPresentations[scope] ?? null : null; + return app.state?.viewComputed?.[row.id]?.presentation ?? null; } function createRows() { @@ -258,16 +236,7 @@ function promptSegments(row) { } function compiledPrompt(row) { - const profile = resolvedPromptProfile(); - const segments = profile.version === 3 - ? [row.asset.promptBody ?? ""] - : profile.version === 2 - ? [profile.styleAnchor, row.asset.promptBody ?? ""] - : promptSegments(row).map((segment) => segment.value); - const parts = uniqueSegments(segments) - .map((segment) => segment.replace(/[。\s]+$/u, "")) - .filter(Boolean); - return parts.length ? parts.join("。") + "。" : ""; + return app.state?.viewComputed?.[row.id]?.compiledText ?? ""; } function workflowModel() { diff --git a/scripts/workspace_server.mjs b/scripts/workspace_server.mjs index fb77497..0d24ad6 100644 --- a/scripts/workspace_server.mjs +++ b/scripts/workspace_server.mjs @@ -7,7 +7,7 @@ import { createServer } from "node:http"; import { readFile, realpath, stat } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { hashValue, readJson, resolvedPromptProfile, schemaVersion, writeJsonAtomic } from "./lib/project.mjs"; +import { assetPresentation, assetPresentationScope, compilePrompt, hashValue, listAssets, readJson, resolvedPromptProfile, schemaVersion, writeJsonAtomic } from "./lib/project.mjs"; function option(name, fallback) { const position = process.argv.indexOf(name); @@ -201,22 +201,37 @@ async function optionalJson(file) { // Viewer 只需要兼容解析后的只读投影。旧 render 的版式污染由后端唯一解析器过滤, // 避免浏览器维护另一份正则;canonical /api/project-state 仍原样提供。 // 显式 version 1 的项目同样投影过滤后的全局渲染语言,保证工作台展示与后端编译同文。 +// viewComputed 附带每个资产的后端编译文本与解析后的呈现配置(含兜底), +// 浏览器不再维护编译逻辑或 presentation 默认文案的拷贝。 function projectStateForViewer(state) { const rawProfile = state.config?.promptProfile ?? {}; - if ([2, 3].includes(rawProfile.version)) return state; - const profile = resolvedPromptProfile(state); - return { - ...state, - config: { - ...(state.config ?? {}), - promptProfile: { - ...(rawProfile.version === 1 ? rawProfile : {}), - version: 1, - commonVisualLanguage: profile.commonVisualLanguage, - globalRenderLanguage: profile.globalRenderLanguage + const projected = [2, 3].includes(rawProfile.version) ? { ...state } : (() => { + const profile = resolvedPromptProfile(state); + return { + ...state, + config: { + ...(state.config ?? {}), + promptProfile: { + ...(rawProfile.version === 1 ? rawProfile : {}), + version: 1, + commonVisualLanguage: profile.commonVisualLanguage, + globalRenderLanguage: profile.globalRenderLanguage + } } - } - }; + }; + })(); + const viewComputed = {}; + for (const record of listAssets(state)) { + const presentation = assetPresentation(state, record); + viewComputed[record.asset.id] = { + presentationScope: assetPresentationScope(record), + presentation: presentation + ? { id: presentation.id ?? null, name: presentation.name ?? null, ratio: presentation.ratio ?? null, prompt: presentation.prompt ?? "" } + : null, + compiledText: compilePrompt(state, record).text + }; + } + return { ...projected, viewComputed }; } async function serveFile(response, canonicalRoot, requested) { diff --git a/tests/workspace-view-state.test.mjs b/tests/workspace-view-state.test.mjs new file mode 100644 index 0000000..c40ed2e --- /dev/null +++ b/tests/workspace-view-state.test.mjs @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; +import test from "node:test"; + +import { compilePrompt, listAssets } from "../scripts/lib/project.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +const characterMedia = "国风仙侠三维游戏角色美术,非真人感,低细节皮肤,无毛孔,哑光分束发丝,柔和漫射光,高清细节。"; + +function serverState() { + return { + schemaVersion: 3, + revision: 2, + project: { id: "project-viewstate-test", title: "工作台测试", sourceScript: "剧本.md", sourceHash: "hash-script" }, + config: { + promptLanguage: "zh-CN", + styleProfile: { id: "s", name: "测试", contentHash: "hash-style" }, + promptProfile: { version: 3, textPolicyVersion: 1 }, + image: { quality: "2k" }, + assetPresentation: { + version: 1, + characterDefault: { id: "l", name: "布局", ratio: "4:3", prompt: "设定板", requirements: [] } + // characterState/location/prop 缺省:应由后端兜底解析进入 viewComputed。 + } + }, + workflow: { + storyBible: { path: "story-asset-bible.md", sourceHash: "hash-script", contentHash: "hash-bible", status: "complete" }, + appliedOps: [] + }, + assets: { + characters: [{ + id: "character-a", + name: "阿一", + states: [ + { id: "character-a-default", name: "默认外观", sequence: 0, status: "needs_review", review: { promptStatus: "needs_review" }, textPolicy: { mode: "none" }, promptBody: characterMedia + "白衣修士,纯白背景。" }, + { id: "character-a-battle", name: "战损外观", sequence: 1, status: "needs_review", review: { promptStatus: "needs_review" }, referenceStateId: "character-a-default", textPolicy: { mode: "none" }, promptBody: characterMedia + "战损外观,纯白背景。" } + ] + }], + locations: [], + props: [] + } + }; +} + +async function startServer(dir) { + const child = spawn("node", [path.join(root, "scripts", "workspace_server.mjs"), dir, "--port", "0"], { stdio: ["ignore", "pipe", "pipe"] }); + const origin = await new Promise((resolve, reject) => { + let output = ""; + const timer = setTimeout(() => reject(new Error("workspace_server 启动超时:" + output)), 8000); + child.stdout.on("data", (chunk) => { + output += String(chunk); + const match = output.match(/http:\/\/[\d.]+:\d+/); + if (match) { + clearTimeout(timer); + resolve(match[0]); + } + }); + child.stderr.on("data", (chunk) => { output += String(chunk); }); + child.on("exit", (code) => reject(new Error("workspace_server 提前退出 code=" + code + ":" + output))); + }); + return { child, origin }; +} + +test("view-state 附带后端编译文本与兜底呈现配置;写入受限、越权路径被拒", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "comic-viewstate-")); + let child = null; + try { + const state = serverState(); + await writeFile(path.join(dir, "剧本.md"), "剧本原文\n"); + await writeFile(path.join(dir, "project-state.json"), JSON.stringify(state, null, 2)); + const started = await startServer(dir); + child = started.child; + const origin = started.origin; + + const view = await (await fetch(origin + "/api/view-state")).json(); + const records = listAssets(state); + const battle = records.find((record) => record.asset.id === "character-a-battle"); + assert.equal( + view.viewComputed["character-a-battle"].compiledText, + compilePrompt(state, battle).text, + "工作台展示文本必须等于后端编译结果" + ); + assert.equal(view.viewComputed["character-a-battle"].presentationScope, "characterState"); + assert.equal(view.viewComputed["character-a-battle"].presentation.ratio, "16:9", "缺省 characterState 必须由后端兜底解析"); + assert.equal(view.viewComputed["character-a-default"].presentation.ratio, "4:3"); + + const post = await fetch(origin + "/api/view-state", { method: "POST", body: "{}" }); + assert.equal(post.status, 405, "工作台仅可写入初始化选择"); + + const escape = await fetch(origin + "/project/../project-state.json"); + assert.ok(escape.status >= 400, "越权路径必须被拒绝,实际 " + escape.status); + } finally { + if (child) child.kill(); + await rm(dir, { recursive: true, force: true }); + } +}); From 908d0858a72a3fa89c0a3292c9d297610791d09a Mon Sep 17 00:00:00 2001 From: Shanzoon <82895720+Shanzoon@users.noreply.github.com> Date: Mon, 20 Jul 2026 04:19:06 +0800 Subject: [PATCH 11/11] =?UTF-8?q?=E8=A1=A5=E5=85=A8=20Dialog=20=E6=9E=84?= =?UTF-8?q?=E5=9B=BE=E9=A2=84=E8=A7=88=E5=B9=B6=E6=94=B9=E7=94=A8=209:16?= =?UTF-8?q?=20=E7=AB=8B=E7=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++ .../layout-examples/closeup-turnaround.jpg | Bin 0 -> 155399 bytes .../layout-examples/single-full-body-9x16.jpg | Bin 0 -> 89849 bytes .../setup-app/layout-examples/turnaround.jpg | Bin 0 -> 94501 bytes .../setup-app/layout-examples/upper-body.jpg | Bin 0 -> 119529 bytes assets/setup-app/setup.css | 2 +- assets/setup-app/setup.js | 7 ++- assets/setup-app/style-presets.json | 18 ++++--- assets/viewer-app/viewer.css | 2 +- assets/viewer-app/viewer.js | 7 ++- tests/presentation-scope.test.mjs | 10 ++-- tests/style-presets.test.mjs | 47 +++++++++++++++++- 12 files changed, 82 insertions(+), 18 deletions(-) create mode 100644 assets/setup-app/layout-examples/closeup-turnaround.jpg create mode 100644 assets/setup-app/layout-examples/single-full-body-9x16.jpg create mode 100644 assets/setup-app/layout-examples/turnaround.jpg create mode 100644 assets/setup-app/layout-examples/upper-body.jpg diff --git a/CHANGELOG.md b/CHANGELOG.md index bba859e..7d603ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ - 可逆动作直接执行并复述;锁定、超出持久授权的扣费和其他不可逆动作需要确认。高后果创作项必须用成图后果句显式呈现。 - 运行时按阶段读取 references;生产不变量、岗位准入、Prompt 契约、旧版本兼容和供应商边界各有唯一权威文件。 +## 2026-07-20 — 单张全身立绘改为竖版 9:16,补全 Dialog 构图预览 + +- **新建项目规格**:补齐“特写 + 正侧背三视图”“标准正侧背三视图”“单张全身立绘”“上半身肖像”的白底构图预览;`single-full-body` 默认角色构图由 4:3 改为 9:16,提示词明确人物居中、占主要画面高度、从发梢到足部完整可见及均衡留白。manifest 升至 v5,确保初始化选择 hash 反映规格变更。 +- **Dialog 展示**:统一工作台与兼容 setup 页面均从 manifest 的 ratio 动态设置预览框比例,不再把竖图强制裁进 16:9。 +- **资源体积**:四张新增构图预览均压缩为预览级 JPEG;横图为 1280×720,肖像为 720×960,竖版全身为 720×1279,总体约 450KB,不把原始 PNG 带入 Skill 包。 +- **兼容性与验证**:仅影响未固化 setup 的新项目;既有项目不迁移、不重写。测试锁定 9:16 契约、同规格预览和新项目落盘传播。 + ## 2026-07-20 — 工作台前后端收敛:viewComputed 单一编译出口 - `/api/view-state` 新增 `viewComputed`:每资产附带后端 `compilePrompt()` 编译文本与解析后的呈现配置(含 setup-era 兜底文案)。viewer.js 删除三处后端逻辑拷贝(`fallbackPresentations` 逐字拷贝、客户端编译 `compiledPrompt`/`uniqueSegments`、presentation 兜底解析),改为直接消费 viewComputed——"工作台展示、批次、供应商提交同一物化文本"从"两份实现恰好相同"变为机制保证。 diff --git a/assets/setup-app/layout-examples/closeup-turnaround.jpg b/assets/setup-app/layout-examples/closeup-turnaround.jpg new file mode 100644 index 0000000000000000000000000000000000000000..446981936205cc164d7fd6ebd0aaed4f31703bf0 GIT binary patch literal 155399 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®oyQ`~OB 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

frdF?Nm|LbJ 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

~6hQhh7tFA#XX;`VnN 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 d2NiWARv)~4 ``).join(""); + $("#setup-layout-catalog").innerHTML = app.manifest.layouts.map((item) => ``).join(""); renderSetupLayoutDetail(); } diff --git a/assets/setup-app/style-presets.json b/assets/setup-app/style-presets.json index edbe3f1..7ae98b7 100644 --- a/assets/setup-app/style-presets.json +++ b/assets/setup-app/style-presets.json @@ -1,5 +1,5 @@ { - "version": 4, + "version": 5, "styles": [ { "id": "script", @@ -171,7 +171,7 @@ "name": "特写 + 正侧背三视图", "ratio": "16:9", "prompt": "白色无缝背景的角色资产设定板,画面左侧为占主要视觉面积的正面胸像特写,清楚呈现面孔、发型与符合角色身份的稳定气质;画面右侧并列同一角色严格正面、侧面与背面的完整全身图,人物比例、面貌、发型和服装结构严格一致。", - "image": null, + "image": "layout-examples/closeup-turnaround.jpg", "requirements": [ { "label": "白色无缝背景", @@ -229,7 +229,7 @@ "name": "标准正侧背三视图", "ratio": "16:9", "prompt": "白色无缝背景的角色三视图设定板,正面、侧面与背面完整全身站立图并列,人物比例、发型、服装和足部细节一致,清晰呈现角色结构。", - "image": null, + "image": "layout-examples/turnaround.jpg", "requirements": [ { "label": "白色无缝背景", @@ -252,10 +252,14 @@ { "id": "single-full-body", "name": "单张全身立绘", - "ratio": "4:3", - "prompt": "白色无缝背景的单一角色全身立绘,清晰展示角色整体轮廓、服装、姿态和识别细节。", - "image": null, + "ratio": "9:16", + "prompt": "白色无缝背景的 9:16 竖版单一角色全身立绘,人物居中并占据主要画面高度,从发梢到足部完整清晰呈现角色整体轮廓、服装、自然姿态和识别细节,画面四周留白均衡。", + "image": "layout-examples/single-full-body-9x16.jpg", "requirements": [ + { + "label": "9:16竖版全身构图", + "match": "9:16|竖版" + }, { "label": "白色无缝背景", "match": "白色.{0,8}背景|白底" @@ -271,7 +275,7 @@ "name": "上半身肖像", "ratio": "3:4", "prompt": "白色无缝背景的角色上半身肖像,优先清晰呈现面孔、发型、气质和上身服装细节。", - "image": null, + "image": "layout-examples/upper-body.jpg", "requirements": [ { "label": "白色无缝背景", diff --git a/assets/viewer-app/viewer.css b/assets/viewer-app/viewer.css index 2fc010f..fa63ac4 100644 --- a/assets/viewer-app/viewer.css +++ b/assets/viewer-app/viewer.css @@ -305,7 +305,7 @@ button:disabled { cursor: not-allowed; opacity: .58; } .example figcaption { padding: 6px 8px; color: var(--muted); font-size: .5625rem; } .setup-empty { display: grid; min-height: 260px; place-items: center; color: var(--muted); text-align: center; font-size: .6875rem; line-height: 1.65; } .layout-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } -.layout-image { display: block; aspect-ratio: 16 / 9; background: var(--surface-muted); } +.layout-image { display: block; aspect-ratio: var(--layout-preview-ratio, 16 / 9); background: var(--surface-muted); } .layout-image.placeholder { display: grid; place-items: center; color: var(--muted); font-size: .625rem; } .layout-card b { display: block; padding: 8px 9px 3px; font-size: .75rem; } .layout-card small { display: block; padding: 0 9px 9px; color: var(--muted); font-size: .625rem; } diff --git a/assets/viewer-app/viewer.js b/assets/viewer-app/viewer.js index ebd8100..4cc0cc1 100644 --- a/assets/viewer-app/viewer.js +++ b/assets/viewer-app/viewer.js @@ -36,6 +36,11 @@ function escapedAttribute(value) { return escapeHtml(value).replace(/\x60/g, "`"); } +function layoutPreviewStyle(ratio) { + const match = String(ratio ?? "").trim().match(/^(\d+)\s*:\s*(\d+)$/); + return match ? `--layout-preview-ratio:${match[1]} / ${match[2]}` : ""; +} + function notify(message, error = false) { toast.textContent = message; toast.classList.toggle("error", error); @@ -596,7 +601,7 @@ function renderSetupLayoutDetail() { } function renderSetupLayouts() { - $("#setup-layout-catalog").innerHTML = (app.manifest?.layouts ?? []).map((item) => ``).join(""); + $("#setup-layout-catalog").innerHTML = (app.manifest?.layouts ?? []).map((item) => ``).join(""); renderSetupLayoutDetail(); } diff --git a/tests/presentation-scope.test.mjs b/tests/presentation-scope.test.mjs index 0bdd515..e76172f 100644 --- a/tests/presentation-scope.test.mjs +++ b/tests/presentation-scope.test.mjs @@ -223,7 +223,7 @@ ${render} assert.equal(Object.hasOwn(recorded.config.promptProfile, "render"), false); }); -test("新项目初始化直接持久化四类呈现配置", async (t) => { +test("新项目初始化把所选竖版角色构图持久化到默认态与外观版本", async (t) => { const directory = await mkdtemp(path.join(tmpdir(), "comic-assets-new-project-")); t.after(() => rm(directory, { recursive: true, force: true })); const sourcePath = path.join(directory, "source.md"); @@ -242,16 +242,16 @@ test("新项目初始化直接持久化四类呈现配置", async (t) => { projectId: state.project.id, status: "complete", visualPresetId: "script", - characterLayoutId: "closeup-front-back" + characterLayoutId: "single-full-body" }, null, 2)); const apply = spawnSync(process.execPath, [path.join(root, "scripts/apply_setup_decisions.mjs"), statePath], { encoding: "utf8" }); assert.equal(apply.status, 0, apply.stderr); state = JSON.parse(await readFile(statePath, "utf8")); assert.deepEqual(Object.keys(state.config.assetPresentation).sort(), ["characterDefault", "characterState", "location", "prop", "version"]); - assert.equal(state.config.assetPresentation.characterDefault.ratio, "4:3"); + assert.equal(state.config.assetPresentation.characterDefault.ratio, "9:16"); // 2026-07-18 B4:角色外观版本跟随所选构图与画幅(不再固定 16:9 单图),消除状态图单/多视图混杂。 - assert.equal(state.config.assetPresentation.characterState.ratio, "4:3"); - assert.equal(state.config.assetPresentation.characterState.id, "closeup-front-back-state"); + assert.equal(state.config.assetPresentation.characterState.ratio, "9:16"); + assert.equal(state.config.assetPresentation.characterState.id, "single-full-body-state"); assert.ok(state.config.assetPresentation.characterState.requirements.some((item) => item.label === "当前外观版本")); assert.equal(state.config.assetPresentation.location.ratio, "16:9"); assert.equal(state.config.assetPresentation.prop.ratio, "4:3"); diff --git a/tests/style-presets.test.mjs b/tests/style-presets.test.mjs index fbe5657..26a5485 100644 --- a/tests/style-presets.test.mjs +++ b/tests/style-presets.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; +import { access, readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; @@ -8,9 +8,25 @@ import { findDedicatedDeliveryLayoutTerms } from "../scripts/lib/project.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +function jpegDimensions(image) { + for (let offset = 2; offset < image.length - 9;) { + if (image[offset] !== 0xff) { + offset += 1; + continue; + } + const marker = image[offset + 1]; + const segmentLength = image.readUInt16BE(offset + 2); + if (marker >= 0xc0 && marker <= 0xc3) { + return { height: image.readUInt16BE(offset + 5), width: image.readUInt16BE(offset + 7) }; + } + offset += 2 + segmentLength; + } + throw new Error("JPEG 缺少可读取的帧尺寸"); +} + test("预设种子卫生:无版式词(人物钉子豁免)、无比例数字、均带定位标签", async () => { const manifest = JSON.parse(await readFile(path.join(root, "assets/setup-app/style-presets.json"), "utf8")); - assert.equal(manifest.version, 4); + assert.equal(manifest.version, 5); for (const preset of manifest.styles) { assert.ok(typeof preset.positioning === "string" && preset.positioning.trim(), preset.id + " 缺少 positioning 定位标签"); if (!preset.stylePromptSeed) continue; @@ -64,3 +80,30 @@ test("表达型角色板释放姿态,严格三视图仍保留生产角度", as assert.match(closeupTurnaround.prompt, /严格正面、侧面与背面/); assert.match(turnaround.prompt, /正面、侧面与背面完整全身站立图/); }); + +test("单张全身立绘使用 9:16 竖版构图与同规格预览", async () => { + const manifest = JSON.parse(await readFile(path.join(root, "assets/setup-app/style-presets.json"), "utf8")); + const layout = manifest.layouts.find((item) => item.id === "single-full-body"); + + assert.ok(layout, "缺少 single-full-body 布局"); + assert.equal(layout.ratio, "9:16"); + assert.match(layout.prompt, /9:16.*竖版/); + assert.match(layout.prompt, /人物居中/); + assert.match(layout.prompt, /足部完整/); + for (const requirement of layout.requirements) { + assert.match(layout.prompt, new RegExp(requirement.match), requirement.label + " 未被布局提示词覆盖"); + } + + const image = await readFile(path.join(root, "assets/setup-app", layout.image)); + assert.equal(image.subarray(0, 2).toString("hex"), "ffd8", "竖版预览应为 JPEG"); + const { width, height } = jpegDimensions(image); + assert.ok(Math.abs(width / height - 9 / 16) < 0.002, `竖版预览比例不接近 9:16:${width}x${height}`); +}); + +test("初始化 Dialog 的每种角色构图均有可加载预览图", async () => { + const manifest = JSON.parse(await readFile(path.join(root, "assets/setup-app/style-presets.json"), "utf8")); + for (const layout of manifest.layouts) { + assert.ok(typeof layout.image === "string" && layout.image.trim(), layout.id + " 缺少 Dialog 预览图"); + await access(path.join(root, "assets/setup-app", layout.image)); + } +});

Gusi3iXF4d1`fZKv3BfV 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ⅈ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 a^_9NcE9D!I6s`@)yk-4Pa~AmPeEq*ts&2dh literal 0 HcmV?d00001 diff --git a/assets/setup-app/layout-examples/turnaround.jpg b/assets/setup-app/layout-examples/turnaround.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6e228458b6cb12029cb8ca57ee39dfc842b318a1 GIT binary patch literal 94501 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