diff --git a/README.md b/README.md index 80cab60..167d4e3 100644 --- a/README.md +++ b/README.md @@ -39,22 +39,22 @@ | [SeqViz](workbenches/52-seqviz/) | 序列导入、注释审阅、环形/线性视图与导出 | 序列审阅与注释轨道 | | [farmOS-map](workbenches/53-farmos-map/) | 地块绘制、边界调整、备注与 GeoJSON 导出 | 地图与地块记录 | | [Mirador](workbenches/54-mirador/) | 本地图像、双页对照、逐页复核与报告导出 | 双图光台与复核账本 | -| [OpenSheetMusicDisplay](workbenches/55-opensheetmusicdisplay/) | MusicXML/MXL 导入、谱面校审、批注及原件/SVG 导出 | 乐谱纸张与边注 | -| [LangExtract](workbenches/56-langextract/) | 原文、JSONL证据定位、字段复核、人工修订与CSV/JSONL/项目导出 | 原文纸张与证据账本 | -| [Docling](workbenches/57-docling/) | DoclingDocument结构树、文本/表格校订、顺序、JSON/Markdown/CSV与HTML连接器 | 结构轨、校样纸张与表格检查器 | +| [OpenSheetMusicDisplay](workbenches/55-opensheetmusicdisplay/) | MusicXML/MXL 原件库、光标与回放、移调与声部切换、谱面批注联动、PDF/PNG/SVG 导出 | 乐谱库、原生谱面与联动批注账本 | +| [LangExtract](workbenches/56-langextract/) | 多文档原文标注、类别与示例配方、区间对齐、审阅统计、JSONL/CSV/可视化 HTML 导出 | 原文纸张与证据账本、档案菜单 | +| [Docling](workbenches/57-docling/) | 本地 PDF/DOCX/HTML/Markdown 转换、原页与结构块框选、文本表格校订、Docling JSON/Markdown/HTML/CSV 导出 | 原页、结构导航与校订检查器 | | [LangGraph.js](workbenches/58-langgraph/) | Gemini 3.8资料整理、真实StateGraph、人工复核、SQLite检查点与交付导出 | 三站复核流程与运行账本 | -| [Promptfoo](workbenches/59-promptfoo/) | 真实评测结果矩阵、基线、人工复核、限定套件执行器与CSV/JSON导出 | 用例×版本矩阵与结果对照 | -| [faster-whisper](workbenches/60-faster-whisper/) | 音频保存/回听、真实CPU转写、字幕时间编辑与SRT/VTT/完整项目导出 | 暗色波形、字幕行与任务队列 | -| [Presenton](workbenches/61-presenton/) | 资料、大纲、页序、备注、审阅、便携稿与异步制作服务连接器 | 逐页缩略轨、演示舞台与资料简报 | +| [Promptfoo](workbenches/59-promptfoo/) | 用例×提示版本×provider 评测矩阵、本机回放与脚本、常用断言、运行对比、发布判断、配置和结果导入导出 | 评测矩阵、基线差异与断言详情 | +| [faster-whisper](workbenches/60-faster-whisper/) | 示例音频与逐词转写、波形入出点、拆分合并、查找替换、说话人标注、SRT/VTT/TXT/JSON 导出 | 波形时间轴、字幕表与上下文检查器 | +| [Presenton](workbenches/61-presenton/) | 资料导入、大纲编辑、本地幻灯片生成、11 种版式与 5 套主题、原生图表 PPTX 与 PDF 导出 | 资料—大纲—成稿步骤、缩略轨与幻灯片舞台 | | [Penpot](workbenches/44-penpot/) | 颜色、字体、组件样本与交付说明,原生Penpot归档及CSS导出 | 设计资产样本册与色彩、字体分页 | | [HyperFrames](workbenches/62-hyperframes/) | 镜头文字与构图、时间线、HyperFrames HTML和真实MP4渲染 | 暗色动效舞台、镜头轨与输出队列 | -| [Graphify](workbenches/63-graphify/) | 真实AST抽取、原生图谱、关系路径、布局与审阅笔记 | 知识地图、符号目录与关系检查器 | +| [Graphify](workbenches/63-graphify/) | 多文件与文件夹知识抽取、Louvain 社区、力导向图、原文定位、搜索路径、GraphML/交互 HTML/完整项目导出 | 中央知识地图、社区资料轨与原文检查器 | | [PageIndex](workbenches/64-pageindex/) | 浏览器 PDF.js 阅读(无需服务)、章节树、字符级摘录/引用高亮、检索与 Host 原页问答 | 章节目录、PDF 原页与审阅问答 | | [DeepTutor](workbenches/65-deeptutor/) | 离线判题/排程/题库导入、错题解析与 Host 导师、可控出题与间隔复习 | 对页练习本与题号状态条 | | [Agent-Reach](workbenches/66-agent-reach/) | 内置 GitHub/网页/搜索/V2EX 采集、首开示例调研、原文摘录高亮、可选 Host 概括与报告 | 来源目录、原文阅读与摘录夹 | -| [OpenMontage](workbenches/67-openmontage/) | 上传原片、入出点、镜头复核、上游组接和MP4交付 | 分镜制作单、原片预览与成片架 | -| [DeerFlow](workbenches/68-deerflow/) | 研究提交、原生线程运行查询、来源复核和报告交付 | 研究进度、交付纸张与来源证据页 | -| [MiroFish](workbenches/69-mirofish/) | 已准备模拟的启动/停止、状态核对、原生动作与人工复核 | 角色目录、轮次时间线与动作复核 | +| [OpenMontage](workbenches/67-openmontage/) | 多轨视频图片音频字幕、移动修剪分割、转场混音、浏览器 MP4/WebM 成片与完整工程导出 | 影片菜单、监视器、多轨时间线与检查器 | +| [DeerFlow](workbenches/68-deerflow/) | 问题与可确认计划、本地检索、引用联动、来源核验、报告编辑、Markdown/HTML 导出及可选 SSE 研究服务 | 计划、执行、报告阶段与来源证据栏 | +| [MiroFish](workbenches/69-mirofish/) | 资料与实体图谱、人设编辑、可复现规则推演、事件注入、立场时间线、报告与完整推演文件导出 | 六阶段推演工作区、立场图表与结果时间线 | | [GEOInflow](workbenches/70-geoinflow/) | 日志与内容清单、原生UA/引荐分类、覆盖分析和审阅导出 | 宽幅访问账本、范围筛选与逐页调查 | | [OpenPencil](workbenches/71-openpencil/) | 图层与属性、原生SceneGraph、FIG/SVG及完整项目导出 | 全幅组件画板、图层与属性工具 | | [FigCraft](workbenches/72-figcraft/) | 无需引擎的近似校样、文案/图层/填色/交付状态写回、SVG/PNG/交付说明导出;原生 PNG/PDF 可选 | 画板胶片、校样舞台与审阅检查器 | @@ -88,6 +88,8 @@ X选题补充:[9个项目与转化优先级](docs/research/x-project-shortlist AI 补充调研:[32 个 AI 项目与工作台选型](docs/research/ai-workbench-candidates.md)。覆盖文档抽取、知识图谱、Agent、评测、字幕、配音、课件和视觉生成,另含 3 个通用组件;已列明接口、独立服务、模型许可和维护边界。该次首批完成6项;后续转化状态以当前清单为准,正式模型/外部服务范围以各项目说明为准。 +第3波10项已完成本地功能验收与打包,纳入本地整合分支。第4–6波30项待开展。详细范围、验证与限制见[第三波记录](docs/release-0.9.0.md#第3波--本地工作流与可选服务)。 + ## 工作台实景 以下为 57 个已验收本地工作台的真实 Chrome 界面,使用普通示例资料。点击预览进入项目说明;图片未经过界面合成或像素修饰。采集版本、原始尺寸与哈希见 [截图说明](docs/screenshots/README.md)。 diff --git a/catalog.json b/catalog.json index 62cf2d6..9027dca 100644 --- a/catalog.json +++ b/catalog.json @@ -2573,40 +2573,42 @@ "kind": "行业核心库集成", "workbench": "MusicXML/MXL → 声部/版式审阅 → 乐谱图与批注清单", "license": "BSD-3-Clause", - "caveat": "是渲染器;完整制谱和播放器不计入首版", + "caveat": "基础合成音回放未做听音验收;大文件用生成乐谱验证;PDF 为栅格校样;客户端导入未验证。", "effort": "中", "priority": "A", "directory": "workbenches/55-opensheetmusicdisplay", "status": "local-verified", - "checked_at": "2026-10-27", + "checked_at": "2026-10-29", "researchId": "V27", "sources": [ "https://github.com/opensheetmusicdisplay/opensheetmusicdisplay", "docs/research/niche-creative.md" ], - "version": "0.3.1", + "version": "0.9.0", "verification": { "build": "passed", - "tests": 12, + "tests": 17, "browser": "passed", "autosave": "passed", "workingDraftRecovery": "passed", - "productLayout": "needs-redesign", - "screenshot": "README.md#工作台实景", - "hostLlm": "not-retested", + "productLayout": "pending", + "screenshot": "docs/screenshots/opensheetmusicdisplay.png", + "hostLlm": "not-used", "hostImport": "unverified", - "verifiedAt": "2026-10-27", - "record": "docs/release-0.3.0.md", + "verifiedAt": "2026-10-29", + "record": "docs/release-0.9.0.md", "package": "passed", "sharedAutosaveRevert": "passed", "sharedClientSha256": "1663f8bb8ffa9a79d016180b7e98ee229b02683e246767f5146c9e1eac50312d", - "browserScope": "沿用 0.2.0 业务界面验收;0.3.0 共同保存代码经独立 Chrome/SQLite 回归,全部重新构建/测试/打包。", + "browserScope": "独立 Chrome 1440/375 主流程、导出、失败与自动保存验收;具体范围见 acceptance,客户端导入未验证。", "structuralLayout": { - "status": "needs-redesign", - "reviewedAt": "2026-10-28", - "record": "docs/layout-audit-2026-10-28.md", - "basis": "source header cluster; task redesign queued" - } + "status": "pending", + "reviewedAt": "2026-10-29", + "record": "workbenches/55-opensheetmusicdisplay/app/ACCEPTANCE.md", + "basis": "product flow and screenshots reviewed; formal cross-workbench review pending" + }, + "upstream": "OSMD 2.2.1 原生渲染、光标、移调与声部控制;大谱与导出本地浏览器验证", + "acceptance": "workbenches/55-opensheetmusicdisplay/app/ACCEPTANCE.md" }, "presentation": { "screenshots": [ @@ -2615,7 +2617,10 @@ "caption": "OpenSheetMusicDisplay 工作台实景" } ] - } + }, + "core_features": "MusicXML/MXL 原件库、光标与回放、移调与声部切换、谱面批注联动、PDF/PNG/SVG 导出", + "integration": "浏览器 OSMD 2.2.1 与 Web Audio;无需模型或外部服务", + "mode": "本地乐谱审校" }, { "name": "LangExtract", @@ -2624,21 +2629,21 @@ "category": "AI 工作台", "kind": "服务适配", "workbench": "证据字段抽取与人工复核台", - "core_features": "原文、JSONL证据定位、字段复核、人工修订与CSV/JSONL/项目导出", - "integration": "Python 核心+自建任务服务;Apache-2.0", + "core_features": "多文档原文标注、类别与示例配方、区间对齐、审阅统计、JSONL/CSV/可视化 HTML 导出", + "integration": "Node 区间对齐与可选 Floatboat Host 抽取;Python 服务可选", "effort": "中–高", "priority": "A", "license": "Apache-2.0", - "caveat": "真实模型抽取、Host与客户端导入未验证。", + "caveat": "Host 模型抽取仅 mock 验证;Python 服务未接真实模型;正式批次上限 880 KB;客户端导入未验证。", "sources": [ "https://github.com/google/langextract" ], "group": "AI 工具", - "mode": "独立服务与结果审阅", - "checked_at": "2026-10-27", + "mode": "本地证据标注与可选模型抽取", + "checked_at": "2026-10-29", "verification": { "build": "passed", - "tests": 17, + "tests": 34, "browser": "passed", "autosave": "passed", "workingDraftRecovery": "passed", @@ -2646,23 +2651,24 @@ "package": "passed", "screenshot": "docs/screenshots/langextract.png", "hostImport": "unverified", - "verifiedAt": "2026-10-27", - "record": "docs/release-0.4.0.md", + "verifiedAt": "2026-10-29", + "record": "docs/release-0.9.0.md", "acceptance": "workbenches/56-langextract/ACCEPTANCE.md", - "upstream": "真实LangExtract库+明确stub提供者;真实模型未验证", - "hostLlm": "not-used", + "upstream": "LangExtract 式 few-shot 提示、JSONL 与原文对齐;Host mock 合同及本地浏览器验证", + "hostLlm": "mock-verified", "structuralLayout": { "status": "pending", - "reviewedAt": "2026-10-28", - "record": "docs/layout-audit-2026-10-28.md", - "basis": "source scan only; visual/task review pending" - } + "reviewedAt": "2026-10-29", + "record": "workbenches/56-langextract/ACCEPTANCE.md", + "basis": "product flow and screenshots reviewed; formal cross-workbench review pending" + }, + "browserScope": "独立 Chrome 1440/375 主流程、导出、失败与自动保存验收;具体范围见 acceptance,客户端导入未验证。" }, "id": 56, "slug": "langextract", "directory": "workbenches/56-langextract", "status": "local-verified", - "version": "0.4.1", + "version": "0.9.0", "presentation": { "screenshots": [ { @@ -2679,45 +2685,46 @@ "category": "AI 工作台", "kind": "服务适配", "workbench": "文档结构、文本与表格校对台", - "core_features": "DoclingDocument结构树、文本/表格校订、顺序、JSON/Markdown/CSV与HTML连接器", - "integration": "官方 docling-serve;代码 MIT,模型单列", + "core_features": "本地 PDF/DOCX/HTML/Markdown 转换、原页与结构块框选、文本表格校订、Docling JSON/Markdown/HTML/CSV 导出", + "integration": "浏览器 PDF.js 与 Node 文档解析;Docling Serve 可选", "effort": "中–高", "priority": "A", "license": "MIT", - "caveat": "当前上传仅HTML;无PDF/OCR/DOCX预览或转换;官方Serve live与客户端导入未验证。", + "caveat": "本地解析不含 OCR 或官方版面模型;Docling Serve 用本机夹具验证,真实服务、Host 与客户端导入未验证。", "sources": [ "https://github.com/docling-project/docling" ], "group": "AI 工具", - "mode": "独立服务与结果审阅", - "checked_at": "2026-10-27", + "mode": "原页对照与结构校订", + "checked_at": "2026-10-29", "verification": { "build": "passed", - "tests": 23, + "tests": 35, "browser": "passed", "autosave": "passed", "workingDraftRecovery": "passed", - "productLayout": "pending", + "productLayout": "passed", "package": "passed", "screenshot": "docs/screenshots/docling.png", "hostImport": "unverified", - "verifiedAt": "2026-10-27", - "record": "docs/release-0.4.0.md", + "verifiedAt": "2026-10-29", + "record": "docs/release-0.9.0.md", "acceptance": "workbenches/57-docling/app/ACCEPTANCE.md", - "upstream": "真实Docling core HTML转换与原生JSON回读;官方Serve live未验证", + "upstream": "DoclingDocument 原生 schema 与 Markdown 导出兼容检查;Serve 为 HTTP 合同夹具", "hostLlm": "not-used", "structuralLayout": { - "status": "pending", - "reviewedAt": "2026-10-28", - "record": "docs/layout-audit-2026-10-28.md", - "basis": "source scan only; visual/task review pending" - } + "status": "passed", + "reviewedAt": "2026-10-29", + "record": "workbenches/57-docling/app/ACCEPTANCE.md", + "basis": "independent desktop/mobile task flows and comparison with adjacent workbench screenshots" + }, + "browserScope": "独立 Chrome 1440/375 主流程、导出、失败与自动保存验收;具体范围见 acceptance,客户端导入未验证。" }, "id": 57, "slug": "docling", "directory": "workbenches/57-docling", "status": "local-verified", - "version": "0.4.1", + "version": "0.9.0", "presentation": { "screenshots": [ { @@ -2791,45 +2798,46 @@ "category": "AI 工作台", "kind": "服务适配", "workbench": "提示词与模型回归评测台", - "core_features": "真实评测结果矩阵、基线、人工复核、限定套件执行器与CSV/JSON导出", - "integration": "外部 runner/真实结果导入;MIT", + "core_features": "用例×提示版本×provider 评测矩阵、本机回放与脚本、常用断言、运行对比、发布判断、配置和结果导入导出", + "integration": "Node 本机评测与可选 Floatboat Host;官方 Promptfoo CLI 可选", "effort": "中–高", "priority": "A", "license": "MIT", - "caveat": "限定v3结果与安全JSON套件;执行器独立部署,外部模型与客户端导入未验证。", + "caveat": "本机执行器为确定性子集;真实 Host/LLM 与客户端导入未验证;官方执行器仅回显/本机数据验收。", "sources": [ "https://github.com/promptfoo/promptfoo" ], "group": "AI 工具", - "mode": "独立服务与结果审阅", - "checked_at": "2026-10-27", + "mode": "多版本评测与发布复核", + "checked_at": "2026-10-29", "verification": { "build": "passed", - "tests": 11, + "tests": 25, "browser": "passed", "autosave": "passed", "workingDraftRecovery": "passed", - "productLayout": "needs-redesign", + "productLayout": "passed", "package": "passed", "screenshot": "docs/screenshots/promptfoo.png", "hostImport": "unverified", - "verifiedAt": "2026-10-27", - "record": "docs/release-0.4.0.md", - "acceptance": "workbenches/59-promptfoo/ACCEPTANCE.md", - "upstream": "真实Promptfoo evaluator回显评测;外部模型未验证", - "hostLlm": "not-used", + "verifiedAt": "2026-10-29", + "record": "docs/release-0.9.0.md", + "acceptance": "workbenches/59-promptfoo/app/ACCEPTANCE.md", + "upstream": "Promptfoo 官方执行器 216 格确定性结果对照、3 项执行器测试;Host 为 mock", + "hostLlm": "mock-verified", "structuralLayout": { - "status": "needs-redesign", - "reviewedAt": "2026-10-28", - "record": "docs/layout-audit-2026-10-28.md", - "basis": "source header cluster; task redesign queued" - } + "status": "passed", + "reviewedAt": "2026-10-29", + "record": "workbenches/59-promptfoo/app/ACCEPTANCE.md", + "basis": "independent desktop/mobile task flows and comparison with adjacent workbench screenshots" + }, + "browserScope": "独立 Chrome 1440/375 主流程、导出、失败与自动保存验收;具体范围见 acceptance,客户端导入未验证。" }, "id": 59, "slug": "promptfoo", "directory": "workbenches/59-promptfoo", "status": "local-verified", - "version": "0.4.1", + "version": "0.9.0", "presentation": { "screenshots": [ { @@ -2846,45 +2854,46 @@ "category": "AI 工作台", "kind": "服务适配", "workbench": "音频转录与字幕审校台", - "core_features": "音频保存/回听、真实CPU转写、字幕时间编辑与SRT/VTT/完整项目导出", - "integration": "自建 Python 任务服务,CPU 可起步;MIT", + "core_features": "示例音频与逐词转写、波形入出点、拆分合并、查找替换、说话人标注、SRT/VTT/TXT/JSON 导出", + "integration": "浏览器字幕校对;本机 faster-whisper Python 服务提供转写", "effort": "中–高", "priority": "A", "license": "MIT", - "caveat": "真实推理仅验证tiny.en英语短录音CPU路线;Python/模型独立部署,其他语种/GPU/客户端导入未验证。", + "caveat": "新音频自动转写仍需 Python 服务;small 中文 CPU 已有实测记录,翻译质量、GPU、长音频与客户端导入未验证。", "sources": [ "https://github.com/SYSTRAN/faster-whisper" ], "group": "AI 工具", - "mode": "独立服务与结果审阅", - "checked_at": "2026-10-27", + "mode": "本地字幕校对与可选语音转写", + "checked_at": "2026-10-29", "verification": { "build": "passed", - "tests": 15, + "tests": 30, "browser": "passed", "autosave": "passed", "workingDraftRecovery": "passed", - "productLayout": "needs-redesign", + "productLayout": "pending", "package": "passed", "screenshot": "docs/screenshots/faster-whisper.png", "hostImport": "unverified", - "verifiedAt": "2026-10-27", - "record": "docs/release-0.4.0.md", + "verifiedAt": "2026-10-29", + "record": "docs/release-0.9.0.md", "acceptance": "workbenches/60-faster-whisper/ACCEPTANCE.md", - "upstream": "真实tiny.en CPU INT8英语转写,主仓库完整链路复验", + "upstream": "已有 small 中文 CPU 真实转写与 tiny.en 英文记录;本轮复跑 30 项应用测试,未重复模型推理", "hostLlm": "not-used", "structuralLayout": { - "status": "needs-redesign", - "reviewedAt": "2026-10-28", - "record": "docs/layout-audit-2026-10-28.md", - "basis": "source header cluster; task redesign queued" - } + "status": "pending", + "reviewedAt": "2026-10-29", + "record": "workbenches/60-faster-whisper/ACCEPTANCE.md", + "basis": "product flow and screenshots reviewed; formal cross-workbench review pending" + }, + "browserScope": "独立 Chrome 1440/375 主流程、导出、失败与自动保存验收;具体范围见 acceptance,客户端导入未验证。" }, "id": 60, "slug": "faster-whisper", "directory": "workbenches/60-faster-whisper", "status": "local-verified", - "version": "0.4.1", + "version": "0.9.0", "presentation": { "screenshots": [ { @@ -2901,21 +2910,21 @@ "category": "AI 工作台", "kind": "服务适配", "workbench": "资料、大纲与 AI 课件制作台", - "core_features": "资料、大纲、页序、备注、审阅、便携稿与异步制作服务连接器", - "integration": "外部 Docker/self-host API;Apache-2.0", + "core_features": "资料导入、大纲编辑、本地幻灯片生成、11 种版式与 5 套主题、原生图表 PPTX 与 PDF 导出", + "integration": "本地资料生成与 PptxGenJS 导出;Floatboat Host/Presenton 服务可选", "effort": "中–高", "priority": "A", "license": "Apache-2.0", - "caveat": "实际生成及PPTX/PDF递送需要配置Presenton服务,尚未live验证;客户端导入未验证。", + "caveat": "PDF 为栅格页;PowerPoint 内打开、真实 Host/Presenton 服务及客户端导入未验证;本地生成按规则整理原文。", "sources": [ "https://github.com/presenton/presenton" ], "group": "AI 工具", - "mode": "独立服务与结果审阅", - "checked_at": "2026-10-27", + "mode": "资料到大纲与演示成稿", + "checked_at": "2026-10-29", "verification": { "build": "passed", - "tests": 29, + "tests": 49, "browser": "passed", "autosave": "passed", "workingDraftRecovery": "passed", @@ -2923,23 +2932,24 @@ "package": "passed", "screenshot": "docs/screenshots/presenton.png", "hostImport": "unverified", - "verifiedAt": "2026-10-27", - "record": "docs/release-0.4.0.md", + "verifiedAt": "2026-10-29", + "record": "docs/release-0.9.0.md", "acceptance": "workbenches/61-presenton/app/ACCEPTANCE.md", - "upstream": "本地编辑/导出与官方API合同;真实生成和上游文件递送未验证", - "hostLlm": "not-used", + "upstream": "PptxGenJS 原生图表导出、PDF 与导出结构验证;Host mock,真实 Presenton 服务未验证", + "hostLlm": "mock-verified", "structuralLayout": { "status": "pending", - "reviewedAt": "2026-10-28", - "record": "docs/layout-audit-2026-10-28.md", - "basis": "source scan only; visual/task review pending" - } + "reviewedAt": "2026-10-29", + "record": "workbenches/61-presenton/app/ACCEPTANCE.md", + "basis": "product flow and screenshots reviewed; formal cross-workbench review pending" + }, + "browserScope": "独立 Chrome 1440/375 主流程、导出、失败与自动保存验收;具体范围见 acceptance,客户端导入未验证。" }, "id": 61, "slug": "presenton", "directory": "workbenches/61-presenton", "status": "local-verified", - "version": "0.4.1", + "version": "0.9.0", "presentation": { "screenshots": [ { @@ -3013,44 +3023,45 @@ "category": "AI 工作台", "kind": "引擎/应用", "workbench": "代码与资料知识地图", - "core_features": "真实AST抽取、原生图谱、关系路径、布局与审阅笔记", - "integration": "独立Python服务,默认8781", + "core_features": "多文件与文件夹知识抽取、Louvain 社区、力导向图、原文定位、搜索路径、GraphML/交互 HTML/完整项目导出", + "integration": "Node 内置抽取与内容地址库;Python AST/Floatboat Host 可选", "effort": "中–高", "priority": "A", "license": "Apache-2.0", - "caveat": "真实graphifyy 0.9.97 AST提取;语义LLM提取未启用;Floatboat客户端导入未验证。", + "caveat": "Node 抽取为确定性启发式,复杂语义不保证;Host 仅 mock;大图极限、真实 Python HTTP 到 UI 及客户端导入未验证。", "sources": [ "https://github.com/Graphify-Labs/graphify" ], "group": "AI 工具", - "mode": "引擎封装", - "checked_at": "2026-10-28", + "mode": "资料抽取与知识地图审阅", + "checked_at": "2026-10-29", "slug": "graphify", "directory": "workbenches/63-graphify", "status": "local-verified", - "version": "0.5.1", + "version": "0.9.0", "verification": { "build": "passed", - "tests": 12, + "tests": 26, "browser": "passed", "autosave": "passed", "workingDraftRecovery": "passed", - "productLayout": "needs-redesign", + "productLayout": "passed", "package": "passed", "screenshot": "docs/screenshots/graphify.png", "hostImport": "unverified", - "verifiedAt": "2026-10-28", - "record": "docs/release-0.5.0.md", - "acceptance": "workbenches/63-graphify/ACCEPTANCE.md", - "upstream": "真实graphifyy 0.9.97 AST提取;语义LLM提取未启用", - "hostLlm": "not-used", + "verifiedAt": "2026-10-29", + "record": "docs/release-0.9.0.md", + "acceptance": "workbenches/63-graphify/app/ACCEPTANCE.md", + "upstream": "Python graphifyy 0.9.97 真实库测试;Node 与独立 Chrome 多文件抽取、社区及导出验证", + "hostLlm": "mock-verified", "serviceTests": 4, "structuralLayout": { - "status": "needs-redesign", - "reviewedAt": "2026-10-28", - "record": "docs/layout-audit-2026-10-28.md", - "basis": "source header cluster; task redesign queued" - } + "status": "passed", + "reviewedAt": "2026-10-29", + "record": "workbenches/63-graphify/app/ACCEPTANCE.md", + "basis": "independent desktop/mobile task flows and comparison with adjacent workbench screenshots" + }, + "browserScope": "独立 Chrome 1440/375 主流程、导出、失败与自动保存验收;具体范围见 acceptance,客户端导入未验证。" }, "presentation": { "screenshots": [ @@ -3240,44 +3251,45 @@ "category": "AI 工作台", "kind": "引擎/应用", "workbench": "视频脚本、分镜、素材与制作审批", - "core_features": "上传原片、入出点、镜头复核、上游组接和MP4交付", - "integration": "独立Python/FFmpeg制作服务,默认8797", + "core_features": "多轨视频图片音频字幕、移动修剪分割、转场混音、浏览器 MP4/WebM 成片与完整工程导出", + "integration": "浏览器本地合成与分块素材库;外部 OpenMontage 渲染可选", "effort": "中–高", "priority": "A", "license": "AGPL-3.0", - "caveat": "真实OpenMontage裁切/组接;未启用AI视频生成;Floatboat客户端导入未验证。", + "caveat": "长片、大素材、4K 和其他编码器组合未验证;可选 OpenMontage 服务未重测;Host、客户端导入未验证。", "sources": [ "https://github.com/calesthio/OpenMontage" ], "group": "AI 工具", - "mode": "引擎封装", - "checked_at": "2026-10-28", + "mode": "多轨剪辑与本地成片", + "checked_at": "2026-10-29", "slug": "openmontage", "directory": "workbenches/67-openmontage", "status": "local-verified", - "version": "0.5.1", + "version": "0.9.0", "verification": { "build": "passed", - "tests": 9, + "tests": 25, "browser": "passed", "autosave": "passed", "workingDraftRecovery": "passed", - "productLayout": "needs-redesign", + "productLayout": "passed", "package": "passed", "screenshot": "docs/screenshots/openmontage.png", "hostImport": "unverified", - "verifiedAt": "2026-10-28", - "record": "docs/release-0.5.0.md", - "acceptance": "workbenches/67-openmontage/ACCEPTANCE.md", - "upstream": "真实OpenMontage裁切/组接;未启用AI视频生成", + "verifiedAt": "2026-10-29", + "record": "docs/release-0.9.0.md", + "acceptance": "workbenches/67-openmontage/app/ACCEPTANCE.md", + "upstream": "独立 Chrome 实际编码并以 ffprobe 核对桌面/375px H.264+AAC 成片;可选服务仅合同测试", "hostLlm": "not-used", "serviceTests": 4, "structuralLayout": { - "status": "needs-redesign", - "reviewedAt": "2026-10-28", - "record": "docs/layout-audit-2026-10-28.md", - "basis": "source header cluster; task redesign queued" - } + "status": "passed", + "reviewedAt": "2026-10-29", + "record": "workbenches/67-openmontage/app/ACCEPTANCE.md", + "basis": "independent desktop/mobile task flows and comparison with adjacent workbench screenshots" + }, + "browserScope": "独立 Chrome 1440/375 主流程、导出、失败与自动保存验收;具体范围见 acceptance,客户端导入未验证。" }, "presentation": { "screenshots": [ @@ -3296,44 +3308,45 @@ "category": "AI 工作台", "kind": "引擎/应用", "workbench": "研究计划、证据与报告交付", - "core_features": "研究提交、原生线程运行查询、来源复核和报告交付", - "integration": "需配置官方DeerFlow服务与模型;默认请求gemini-3.8-flash", + "core_features": "问题与可确认计划、本地检索、引用联动、来源核验、报告编辑、Markdown/HTML 导出及可选 SSE 研究服务", + "integration": "Node 检索与本地研究底稿;Floatboat Host/DeerFlow SSE 可选", "effort": "中–高", "priority": "A", "license": "MIT", - "caveat": "真实官方序列化器与请求模型;运行HTTP契约通过,真实研究服务未验证;Floatboat客户端导入未验证。", + "caveat": "真实 DeerFlow、外网检索与 Host 未测;服务与检索用本机夹具,Host 用 mock;客户端导入未验证。", "sources": [ "https://github.com/bytedance/deer-flow" ], "group": "AI 工具", - "mode": "引擎封装", - "checked_at": "2026-10-28", + "mode": "计划确认、研究执行与引用报告", + "checked_at": "2026-10-29", "slug": "deerflow", "directory": "workbenches/68-deerflow", "status": "local-verified", - "version": "0.5.1", + "version": "0.9.0", "verification": { "build": "passed", - "tests": 19, + "tests": 53, "browser": "passed", "autosave": "passed", "workingDraftRecovery": "passed", - "productLayout": "needs-redesign", + "productLayout": "pending", "package": "passed", "screenshot": "docs/screenshots/deerflow.png", "hostImport": "unverified", - "verifiedAt": "2026-10-28", - "record": "docs/release-0.5.0.md", + "verifiedAt": "2026-10-29", + "record": "docs/release-0.9.0.md", "acceptance": "workbenches/68-deerflow/ACCEPTANCE.md", - "upstream": "真实官方序列化器与请求模型;运行HTTP契约通过,真实研究服务未验证", - "hostLlm": "not-used", + "upstream": "本机检索夹具、DeerFlow SSE 断线续接与请求合同;真实研究服务未验证", + "hostLlm": "mock-verified", "serviceTests": 0, "structuralLayout": { - "status": "needs-redesign", - "reviewedAt": "2026-10-28", - "record": "docs/layout-audit-2026-10-28.md", - "basis": "source header cluster; task redesign queued" - } + "status": "pending", + "reviewedAt": "2026-10-29", + "record": "workbenches/68-deerflow/ACCEPTANCE.md", + "basis": "product flow and screenshots reviewed; formal cross-workbench review pending" + }, + "browserScope": "独立 Chrome 1440/375 主流程、导出、失败与自动保存验收;具体范围见 acceptance,客户端导入未验证。" }, "presentation": { "screenshots": [ @@ -3352,44 +3365,45 @@ "category": "AI 工作台", "kind": "引擎/应用", "workbench": "角色、假设与情景推演实验", - "core_features": "已准备模拟的启动/停止、状态核对、原生动作与人工复核", - "integration": "需配置官方MiroFish服务,预先准备模拟、模型和Zep", + "core_features": "资料与实体图谱、人设编辑、可复现规则推演、事件注入、立场时间线、报告与完整推演文件导出", + "integration": "Node 本地规则推演;Floatboat Host 与 MiroFish/OASIS/Zep 服务可选", "effort": "中–高", "priority": "A", "license": "AGPL-3.0", - "caveat": "原生动作格式与运行HTTP契约通过;真实OASIS/Zep模拟未验证;Floatboat客户端导入未验证。", + "caveat": "本地规则模拟不等于模型预测;保留 900 KB 模型容量限制,PDF 无 OCR;真实 Host/OASIS/Zep 与客户端导入未验证。", "sources": [ "https://github.com/666ghj/MiroFish" ], "group": "AI 工具", - "mode": "引擎封装", - "checked_at": "2026-10-28", + "mode": "资料、图谱、人设、推演与结果复核", + "checked_at": "2026-10-29", "slug": "mirofish", "directory": "workbenches/69-mirofish", "status": "local-verified", - "version": "0.5.1", + "version": "0.9.0", "verification": { "build": "passed", - "tests": 14, + "tests": 35, "browser": "passed", "autosave": "passed", "workingDraftRecovery": "passed", - "productLayout": "needs-redesign", + "productLayout": "passed", "package": "passed", "screenshot": "docs/screenshots/mirofish.png", "hostImport": "unverified", - "verifiedAt": "2026-10-28", - "record": "docs/release-0.5.0.md", - "acceptance": "workbenches/69-mirofish/ACCEPTANCE.md", - "upstream": "原生动作格式与运行HTTP契约通过;真实OASIS/Zep模拟未验证", - "hostLlm": "not-used", + "verifiedAt": "2026-10-29", + "record": "docs/release-0.9.0.md", + "acceptance": "workbenches/69-mirofish/app/ACCEPTANCE.md", + "upstream": "本地确定性推演、导出回读与 Host mock 验证;真实 OASIS/Zep 未运行", + "hostLlm": "mock-verified", "serviceTests": 0, "structuralLayout": { - "status": "needs-redesign", - "reviewedAt": "2026-10-28", - "record": "docs/layout-audit-2026-10-28.md", - "basis": "source header cluster; task redesign queued" - } + "status": "passed", + "reviewedAt": "2026-10-29", + "record": "workbenches/69-mirofish/app/ACCEPTANCE.md", + "basis": "independent desktop/mobile task flows and comparison with adjacent workbench screenshots" + }, + "browserScope": "独立 Chrome 1440/375 主流程、导出、失败与自动保存验收;具体范围见 acceptance,客户端导入未验证。" }, "presentation": { "screenshots": [ diff --git a/docs/release-0.9.0.md b/docs/release-0.9.0.md index 379c707..d8edfe2 100644 --- a/docs/release-0.9.0.md +++ b/docs/release-0.9.0.md @@ -78,6 +78,31 @@ | Oasis Editor | Oasis Editor 改版前 | Oasis Editor 0.9.0 | | CircuitJS | CircuitJS 改版前 | CircuitJS 0.9.0 | +## 第3波 · 本地工作流与可选服务 + +第3波10项已完成本地功能验收与打包,纳入本地整合分支。第4–6波的30项尚未开展。本地验收与商店上架、客户端导入分别记录。 + +| 工作台 | 0.9.0 能力 | 应用测试 | 验收记录 | +| --- | --- | ---: | --- | +| OpenSheetMusicDisplay | MusicXML/MXL 原件库、光标与回放、移调与声部切换、谱面批注联动、PDF/PNG/SVG 导出 | 17 | [验收](../workbenches/55-opensheetmusicdisplay/app/ACCEPTANCE.md) | +| LangExtract | 多文档原文标注、类别与示例配方、区间对齐、审阅统计、JSONL/CSV/可视化 HTML 导出 | 34 | [验收](../workbenches/56-langextract/ACCEPTANCE.md) | +| Docling | 本地 PDF/DOCX/HTML/Markdown 转换、原页与结构块框选、文本表格校订、Docling JSON/Markdown/HTML/CSV 导出 | 35 | [验收](../workbenches/57-docling/app/ACCEPTANCE.md) | +| Promptfoo | 用例×提示版本×provider 评测矩阵、本机回放与脚本、常用断言、运行对比、发布判断、配置和结果导入导出 | 25 | [验收](../workbenches/59-promptfoo/app/ACCEPTANCE.md) | +| faster-whisper | 示例音频与逐词转写、波形入出点、拆分合并、查找替换、说话人标注、SRT/VTT/TXT/JSON 导出 | 30 | [验收](../workbenches/60-faster-whisper/ACCEPTANCE.md) | +| Presenton | 资料导入、大纲编辑、本地幻灯片生成、11 种版式与 5 套主题、原生图表 PPTX 与 PDF 导出 | 49 | [验收](../workbenches/61-presenton/app/ACCEPTANCE.md) | +| Graphify | 多文件与文件夹知识抽取、Louvain 社区、力导向图、原文定位、搜索路径、GraphML/交互 HTML/完整项目导出 | 26 | [验收](../workbenches/63-graphify/app/ACCEPTANCE.md) | +| OpenMontage | 多轨视频图片音频字幕、移动修剪分割、转场混音、浏览器 MP4/WebM 成片与完整工程导出 | 25 | [验收](../workbenches/67-openmontage/app/ACCEPTANCE.md) | +| DeerFlow | 问题与可确认计划、本地检索、引用联动、来源核验、报告编辑、Markdown/HTML 导出及可选 SSE 研究服务 | 53 | [验收](../workbenches/68-deerflow/ACCEPTANCE.md) | +| MiroFish | 资料与实体图谱、人设编辑、可复现规则推演、事件注入、立场时间线、报告与完整推演文件导出 | 35 | [验收](../workbenches/69-mirofish/app/ACCEPTANCE.md) | + +本轮共 **329 项应用测试**通过,全部已接收项的 check、package 通过。最初五项由整合者独立重跑 check/test;剩余项由接手实现者在各自工作树完成检查与独立浏览器验收。整合按项目逐一比对最终提交,未重复计算解包后重跑或服务测试。 + +1440/375 浏览器主流程、自动保存和失败处理详见逐项记录。最初五项的浏览器运行来自本日原验收记录,整合者复核最终截图,未将历史运行记为本次重跑;后五项进行了本次独立浏览器验收。Docling、Graphify 另在解包副本运行测试。OpenMontage 实际导出桌面与窄屏成片,以 ffprobe 核对编码、尺寸和音轨。功能验收与结构对照分别记录,部分项目的正式跨工作台结构审查仍为 pending。 + +安装包逐文件核对源码和最终应用文档。OSMD 清理文件尾部空行后重新打包;DeerFlow 原包携带旧版应用说明,已在最终文档完成后重新打包并更新哈希。manifest 资源路径按模板规则规范化。未推送、未合并 main、未发布商店。 + +真实 Floatboat Host/LLM 未调用,模型通道仅使用 mock;faster-whisper 的 small 中文 CPU 实测沿用本日原验收记录。外部服务范围以逐项验收为准。共享自动保存的约900KB项目上限仍存在;部分大文件通过独立内容库保存,不能把输入上限等同完整项目可保存容量。 + ## 新增 · ComfyUI(#79) 基于 MIT 的 `@comfyorg/litegraph` 0.17.2 节点画布,经 ComfyUI 官方 HTTP/WebSocket API 连接用户自有服务;工作台为 MIT,不含 ComfyUI 或 ComfyUI_frontend 的 GPL 代码与模型权重。 diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md index 9185a3c..179c63a 100644 --- a/docs/screenshots/README.md +++ b/docs/screenshots/README.md @@ -2,7 +2,7 @@ 真实Chrome运行界面,使用普通示例资料。图片为浏览器原生PNG,未合成界面或修改像素。最近采集日期:2026-10-29。 -0.9.0 第1波更新8项、第2波更新8项、新增 ComfyUI;其余保留历史采集版本。ComfyUI 印样带中的图片来自模拟服务,不是真实生成结果。示例、真实引擎输出、真实Host回答和服务未连接状态按各项目验收记录区分。 +0.9.0 第1波更新8项、第2波更新8项、第3波更新10项、新增 ComfyUI;其余保留历史采集版本。ComfyUI 印样带中的图片来自模拟服务,不是真实生成结果。示例、真实引擎输出、真实Host回答和服务未连接状态按各项目验收记录区分。 | 图片 | 采集版本 | 原始尺寸 | 大小 | SHA-256 | | --- | --- | --- | --- | --- | @@ -31,22 +31,22 @@ | [seqviz.png](seqviz.png) | 0.2.0 | 1440 × 980 | 145.9 KiB | `d0dd49f1c4f92a298caa7107ced737694cbd8b3cb87000b07c221258733b2ea1` | | [farmos-map.png](farmos-map.png) | 0.2.0 | 1440 × 1000 | 129.5 KiB | `740a90faad06eb6aa22fcbebcfb3e3ad11ac84cddf40dac61f2ad5d0fd076814` | | [mirador.png](mirador.png) | 0.2.0 | 1440 × 1000 | 123.4 KiB | `24efe96f869e1be32cb1f43e47eb4902ca5d008f52ace32350d8db25c54360dc` | -| [opensheetmusicdisplay.png](opensheetmusicdisplay.png) | 0.2.0 | 1440 × 1000 | 81.0 KiB | `05fb14de1ef33ba47f9138729959899a21a01cd053a737a8ad2b64a7a4c9f2d1` | -| [langextract.png](langextract.png) | 0.4.0 | 1440 × 1243 | 160.8 KiB | `a5d4eef95117713c1fc8ce3f656659b4bf747e180e5fe7348cff8ef0dc19397d` | -| [docling.png](docling.png) | 0.4.0 | 1440 × 1000 | 128.4 KiB | `9991beb70e9285d26fc7e1108b53ef01c83593a3e474c9bc372a0f37d655ce7c` | +| [opensheetmusicdisplay.png](opensheetmusicdisplay.png) | 0.9.0 | 1440 × 900 | 207.2 KiB | `3b537ba8976f0b8678a0bc4a4015dbf155fd075a017c38eda25f0cc599483334` | +| [langextract.png](langextract.png) | 0.9.0 | 1440 × 1100 | 240.6 KiB | `b35e0bc51a88619ddce1b31780b729606cb7b74645e0c5eadb73066e080de33f` | +| [docling.png](docling.png) | 0.9.0 | 1440 × 900 | 328.0 KiB | `0cf3e95c25115c6d7a2ee6bf05ce106918d6dc7e0cd558afda7a3a520c767f10` | | [langgraph.png](langgraph.png) | 0.4.1 | 1440 × 1313 | 188.4 KiB | `686185f8275b53d40f075cb7c8f68e91f79a12476f0e2ee4da30cba675e212ea` | -| [promptfoo.png](promptfoo.png) | 0.4.0 | 1440 × 1185 | 105.7 KiB | `5ab982dd8f1ab9830671fbf3ecfbcf76a5b5de9a99608e0c4b20da812134eb65` | -| [faster-whisper.png](faster-whisper.png) | 0.4.0 | 1440 × 1392 | 145.7 KiB | `f662dd5659d280b5555fcb9a61abaefca01ac07541fccbcbf23bfa5195628d32` | -| [presenton.png](presenton.png) | 0.4.0 | 1440 × 1206 | 185.5 KiB | `c26564d38a34c6620396e5d576d5e3ad0c098ab230597e1d7879003a4c1e70c3` | +| [promptfoo.png](promptfoo.png) | 0.9.0 | 1440 × 1000 | 295.3 KiB | `dc2fcbba1ade9bbd25f3c36b60f52f0fc3db4cbbec4d377c83d48608d316de1c` | +| [faster-whisper.png](faster-whisper.png) | 0.9.0 | 1440 × 960 | 246.1 KiB | `7786c80d85e2f9ae90cf1550fc24ebcdfdc02744e3cf8cb6c4f0abc84ae5ad2d` | +| [presenton.png](presenton.png) | 0.9.0 | 1440 × 900 | 196.7 KiB | `1b0652eaf58a68e667277168f7efdae9767dc6c9fabdc8497f869af921b41c21` | | [penpot.png](penpot.png) | 0.5.0 | 1440 × 1000 | 72.0 KiB | `6fa3f9dfe034acc01869d8f4cd8b40b057ee3e2f454ae52bba161db7b9c79412` | | [hyperframes.png](hyperframes.png) | 0.5.0 | 1440 × 1149 | 128.0 KiB | `c24389c51798d7e300f9bf2d37625f31c691628ddadf15705c2e7912f62258a4` | -| [graphify.png](graphify.png) | 0.5.0 | 1440 × 1100 | 115.8 KiB | `d75280a48fe240d3e3187471d2670897a25630726ca7c16b95bfb1c9b2975ee1` | +| [graphify.png](graphify.png) | 0.9.0 | 1440 × 900 | 325.8 KiB | `1e0cee859346773b5d3ebc7c55a12318b60070a1ecfcac334e607cd86455c6a4` | | [pageindex.png](pageindex.png) | 0.9.0 | 1440 × 900 | 240.6 KiB | `ec0ec8c1d009e8cff2ee2fa79786d26c89773932dac1fea1c093f12582f141b3` | | [deeptutor.png](deeptutor.png) | 0.9.0 | 1440 × 900 | 239.2 KiB | `359a289a06965c0dd2358cc514c3be731c81195bc3573cff89c8d65212ecc568` | | [agent-reach.png](agent-reach.png) | 0.9.0 | 1440 × 900 | 321.3 KiB | `a363ba029896dc2de954a34b6ce89a8e751ef0211b2b8618355f3cde282e2326` | -| [openmontage.png](openmontage.png) | 0.5.0 | 1440 × 1100 | 122.7 KiB | `5a11fac21433b61856c62cdb9707fe5e08be448d4d83669376aa534489050bbe` | -| [deerflow.png](deerflow.png) | 0.5.0 | 1440 × 1337 | 155.1 KiB | `afe240962bb7b23a5cf75ce632210058acb93c9d4b001036cfdee1b568955e69` | -| [mirofish.png](mirofish.png) | 0.5.0 | 1440 × 1393 | 225.9 KiB | `e09aeb3bc89814586761cfc908366a7c98f501e092cd919b5552856ba58b0b32` | +| [openmontage.png](openmontage.png) | 0.9.0 | 1440 × 900 | 286.0 KiB | `0b6a061503e92f755c0a965df7afd2d9a3c58d82d3d931cb5f000e10579a3ca5` | +| [deerflow.png](deerflow.png) | 0.9.0 | 1440 × 900 | 394.8 KiB | `e2025ecf9f43b5c8012b9073e96b0eec1306da490f0158411fc4fb609da96af8` | +| [mirofish.png](mirofish.png) | 0.9.0 | 1440 × 1000 | 148.6 KiB | `f7afa30872f63885bf89577ae53a1813bb6fdbaca6bfb58f748bdd3fa68186f6` | | [geoinflow.png](geoinflow.png) | 0.5.0 | 1440 × 1941 | 176.0 KiB | `64f321a6fed1bfd363a50f9cfd80eed256bbf6474f2e99e1eba0415c2d5d9ce7` | | [openpencil.png](openpencil.png) | 0.5.0 | 1440 × 1000 | 108.4 KiB | `4d4471197e9fee67c626b9a80cfe34f6ae57b3dc824c662c416b7937ed9e51bc` | | [figcraft.png](figcraft.png) | 0.9.0 | 1440 × 900 | 326.2 KiB | `8b2b7f2ec622b0a536cf905451594274f6c8b9f4fcaa4874deaa024ed9d442fb` | @@ -64,4 +64,4 @@ | [gltf-transform.png](gltf-transform.png) | 0.9.0 | 1440 × 900 | 143.7 KiB | `e1f5b5a1a8520c4204e8acc28e4f420043e4941dd329a27feeaf9a3f9fdd5aed` | | [comfyui.png](comfyui.png) | 0.9.0 | 1440 × 900 | 153.9 KiB | `d3ba181c4efe1c4f97aa3dc4c9604c9514be39d093a040f2bb84b41dffddeb58` | -共57张,合计 8.95 MiB。 +共57张,合计 10.16 MiB。 diff --git a/docs/screenshots/deerflow.png b/docs/screenshots/deerflow.png index c882760..64cf1db 100644 Binary files a/docs/screenshots/deerflow.png and b/docs/screenshots/deerflow.png differ diff --git a/docs/screenshots/docling.png b/docs/screenshots/docling.png index 71481c4..66fc529 100644 Binary files a/docs/screenshots/docling.png and b/docs/screenshots/docling.png differ diff --git a/docs/screenshots/faster-whisper.png b/docs/screenshots/faster-whisper.png index 0252dc6..97be604 100644 Binary files a/docs/screenshots/faster-whisper.png and b/docs/screenshots/faster-whisper.png differ diff --git a/docs/screenshots/graphify.png b/docs/screenshots/graphify.png index baaf890..7e8e64f 100644 Binary files a/docs/screenshots/graphify.png and b/docs/screenshots/graphify.png differ diff --git a/docs/screenshots/langextract.png b/docs/screenshots/langextract.png index 515311b..dc730cd 100644 Binary files a/docs/screenshots/langextract.png and b/docs/screenshots/langextract.png differ diff --git a/docs/screenshots/mirofish.png b/docs/screenshots/mirofish.png index a2ba196..87e0dec 100644 Binary files a/docs/screenshots/mirofish.png and b/docs/screenshots/mirofish.png differ diff --git a/docs/screenshots/openmontage.png b/docs/screenshots/openmontage.png index 06c352d..bbdacc0 100644 Binary files a/docs/screenshots/openmontage.png and b/docs/screenshots/openmontage.png differ diff --git a/docs/screenshots/opensheetmusicdisplay.png b/docs/screenshots/opensheetmusicdisplay.png index ebc4d7a..62f2733 100644 Binary files a/docs/screenshots/opensheetmusicdisplay.png and b/docs/screenshots/opensheetmusicdisplay.png differ diff --git a/docs/screenshots/presenton.png b/docs/screenshots/presenton.png index 6cd57ed..d1433ba 100644 Binary files a/docs/screenshots/presenton.png and b/docs/screenshots/presenton.png differ diff --git a/docs/screenshots/promptfoo.png b/docs/screenshots/promptfoo.png index 918100a..1db260d 100644 Binary files a/docs/screenshots/promptfoo.png and b/docs/screenshots/promptfoo.png differ diff --git a/workbenches/55-opensheetmusicdisplay/NOTICE.md b/workbenches/55-opensheetmusicdisplay/NOTICE.md index 29164ee..4fd5d72 100644 --- a/workbenches/55-opensheetmusicdisplay/NOTICE.md +++ b/workbenches/55-opensheetmusicdisplay/NOTICE.md @@ -1,9 +1,14 @@ # 第三方许可与内容来源 -- [OpenSheetMusicDisplay](https://github.com/opensheetmusicdisplay/opensheetmusicdisplay) **2.2.1**,BSD-3-Clause;使用 npm 官方发布浏览器构建,原始许可保留在 app/src/vendor/OSMD-LICENSE。[官方入门与 load/render API](https://github.com/opensheetmusicdisplay/opensheetmusicdisplay/wiki/Getting-started)、[源项目许可](https://github.com/opensheetmusicdisplay/opensheetmusicdisplay/blob/develop/LICENSE)。 +- [OpenSheetMusicDisplay](https://github.com/opensheetmusicdisplay/opensheetmusicdisplay) **2.2.1**,BSD-3-Clause;使用 npm 官方发布浏览器构建(含 Cursor、TransposeCalculator),原始许可保留在 app/src/vendor/OSMD-LICENSE。[官方入门与 load/render API](https://github.com/opensheetmusicdisplay/opensheetmusicdisplay/wiki/Getting-started)、[源项目许可](https://github.com/opensheetmusicdisplay/opensheetmusicdisplay/blob/develop/LICENSE)。 - VexFlow **1.2.93**,MIT,由 OSMD 使用;音乐字形由上游渲染器输出为 SVG 路径,没有额外下载网络字体。 - [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) **5.11.2**,MIT。 - [JSZip](https://github.com/Stuk/jszip) **3.10.1**,上游提供 MIT 或 GPLv3 双许可,本项目使用 MIT 许可选项。 - 全部已安装生产依赖的许可汇编在 app/src/vendor/THIRD_PARTY_LICENSES.txt,保留上游第三方声明。gl 可选原生依赖被显式移除;没有分发原生渲染程序。 -《Morning Lines》及其 MusicXML 由本项目编写,随仓库许可提供。没有下载或复制第三方乐谱。软件许可不授予用户上传乐谱的内容权利。源包不含用户上传文件、预览数据库或任何凭据。 +内置示例均由本项目录入为 MusicXML,没有下载或复制第三方乐谱或版本: +- 《Minuet in G major》BWV Anh. 114,Christian Petzold(约 1725 年,旋律属公有领域);左手声部为本项目的简化编配,本版以 CC0 1.0 提供。 +- 《Ode to Joy》,贝多芬第九交响曲 Op. 125 末乐章主题(1824 年,公有领域);弦乐四重奏编配为本项目原创,以 CC0 1.0 提供。 +- 《Morning Lines》为本项目原创练习曲,以 CC0 1.0 提供。 + +PDF 写入器、SHA-256 与示例记谱生成器为本项目代码,未引入新的第三方依赖。软件许可不授予用户上传乐谱的内容权利。源包不含用户上传文件、预览数据库或任何凭据。 diff --git a/workbenches/55-opensheetmusicdisplay/README.md b/workbenches/55-opensheetmusicdisplay/README.md index 51882cb..30f74e4 100644 --- a/workbenches/55-opensheetmusicdisplay/README.md +++ b/workbenches/55-opensheetmusicdisplay/README.md @@ -1,43 +1,30 @@ -# 乐谱审校室 · OpenSheetMusicDisplay +# 乐谱审校台 · OpenSheetMusicDisplay -当前版本:**0.3.0**(自动保存与产品化布局)。 -导入已有 MusicXML/MXL,在真实乐谱上核对声部与版式,按小节留下审校记录。原文件、显示设置、当前审阅位置和批注一起保存,刷新后可重新打开。支持导出当前谱面 SVG、原始文件、MXL 中的 MusicXML,以及带原件 SHA-256 的批注报告 JSON。 +当前版本:**0.9.0**。导入制谱软件导出的 MusicXML / MXL(最大 32 MB),在 OpenSheetMusicDisplay 渲染的真实谱面上逐音移动光标、回放、移调、切换声部与版式;在音符或小节上留下编号批注,批注直接画在谱面上并与右侧账本联动;导出带批注的 PDF 校样、PNG、SVG 与批注 JSON。原文件逐字节保留。 -软件用于乐谱审阅,不编辑音符、不提供音频播放。内置《Morning Lines》为本项目原创双声部示例,没有复制第三方乐谱。 +![乐谱审校台](../../docs/screenshots/opensheetmusicdisplay.png) ## 使用 -在总仓库执行 `pnpm workbench prepare opensheetmusicdisplay`,进入本目录执行: +在总仓库执行 `pnpm workbench prepare opensheetmusicdisplay`,进入本目录: ```sh -pnpm dev +pnpm dev # 打开打印出的完整地址 pnpm workbench:check pnpm test pnpm package ``` -打开预览命令打印的完整地址。新建或导入乐谱,选择显示声部;在小节索引中选择批注声部与小节,再添加记录、标记已核对。修改后自动保存,可从顶部稿件列表重新打开。窄屏使用小节选择和“转到批注区”,谱纸在独立区域横向滚动。 - -`artifacts/opensheetmusicdisplay-studio-0.3.0.fbworkbench` 可通过 **Floatboat → My → Workbenches → Import workbench** 导入。源包不包含预览数据库;客户端安装流程尚未验收。 +首开即是示例《小步舞曲 G 大调》。在谱面上点击音符定位(蓝色光标跟随),「在此添加批注」写下问题并选类别;←/→ 逐音移动光标、Shift+←/→ 逐小节、空格回放、N 添加批注。「乐谱库」菜单负责导入、示例、最近的审校、恢复草稿与删除;修改自动保存。 ## 支持范围 -- UTF-8、score-partwise MusicXML;原文件 1–200 KB,XML 内容不超过 180 KB。 -- 1–12 个声部,每声部 1–200 小节,总计 1–4000 个音符或休止符;最多 100 条批注,每条 1000 字符。 -- 不接受 DOCTYPE、ENTITY、外部图片/链接/脚本及样式处理指令;带外部 DTD 的文件需先在制谱软件中导出不含声明的版本。不会访问外部 DTD 或网络字体。 -- MXL 使用 META-INF/container.xml 的第一个 rootfile;只支持普通非加密 ZIP(Stored/Deflate),最多 32 个条目,声明解压总量不超过 500 KB,每条目不超过 180 KB。另对实际解压流限额,拒绝路径穿越、重复条目及 ZIP64。 -- SVG 对应当前显示声部、版式和比例,批注作为报告单独导出。源文件下载保留上传字节;报告是审校清单,不是完整项目备份或 MusicXML 制谱文件。 - -## 验收 - -2026-10-27:严格模板构建、12 项契约/业务/数据库测试通过。Chrome 真实 MusicXML/MXL 导入、声部筛选、版式与比例、小节批注及核对状态、保存刷新恢复、原件逐字节下载通过;无效 XML/外部声明拒绝后保留原工作,切换项目会先同步工作草稿。SVG 独立打开有真实音符路径,源 XML 独立打开并通过 XML 解析;报告包含原件哈希与正确小节锚点。375px、暗色、reduced-motion、键盘导航均检查。 - -模型与外部服务不是本应用功能,manifest 没有 llm 能力。上游版本与许可见 [NOTICE.md](NOTICE.md),工程边界见 [app/ENGINEERING.md](app/ENGINEERING.md)。 - -自动保存:停止输入约 700 ms 后保存,后台请求不锁编辑。未写完的空白批注、显示设置及当前小节可在刷新后恢复;不合法的业务输入显示“草稿已自动保存,待完善输入”,不会覆盖最后的合法内容。网络失败时保留本机副本,版本冲突提供恢复草稿导出与服务器版本入口。导入先验证,再创建独立项目。 - -当前布局:谱面为主体、右侧小节索引与审校批注;375px、暗色与减少动画偏好保持可用。 +- `.musicxml` / `.xml`(UTF-8、UTF-16 等)与 `.mxl`;原文件 ≤ 32 MB,解压后的 MusicXML ≤ 64 MB;≤ 64 声部、每声部 ≤ 5000 小节、≤ 40 万音符。标准 MusicXML DOCTYPE 可直接导入(不下载 DTD);DTD 内部子集、实体声明、样式表处理指令被拒绝;外部图片与链接仅提示忽略。 +- 版式:A4 竖版/横版分页、无限滚动、单行横卷;缩放 30–300%;移调 ±12 半音;声部显示开关;小节号、标题/作曲、声部名可切换。 +- 批注:锚定声部 + 小节 + 可选拍位,7 种类别,最多 1000 条;空白批注保存为草稿。 +- 导出:PDF(谱页 + 批注清单页,谱页为 JPEG 图像)、PNG(当前页)、SVG(全部页)、批注 JSON v2、原文件、MXL 展开的 MusicXML。 +- 回放为 Web Audio 简单合成音色,不代表乐器音色与演奏法。 -## 0.3.0 保存基础修复 +示例:Petzold《小步舞曲 G 大调》BWV Anh. 114(旋律公有领域,左手为本项目简化编配)、贝多芬《欢乐颂》主题(公有领域,本项目弦乐四重奏编配)、《Morning Lines》(本项目原创)。均由本项目录入,未复制第三方版本。 -快速编辑后撤回到已确认内容时,保存状态和本机工作副本一起恢复;服务端草稿回执与缓存重试按统一协议处理。整合验收见 [0.3.0 发布记录](../../docs/release-0.3.0.md)。 +验收记录见 [app/ACCEPTANCE.md](app/ACCEPTANCE.md),工程边界见 [app/ENGINEERING.md](app/ENGINEERING.md),上游版本与许可见 [NOTICE.md](NOTICE.md)。`artifacts/opensheetmusicdisplay-studio-0.9.0.fbworkbench` 可通过 **Floatboat → My → Workbenches → Import workbench** 导入;客户端安装流程尚未验收。 diff --git a/workbenches/55-opensheetmusicdisplay/app/ACCEPTANCE.md b/workbenches/55-opensheetmusicdisplay/app/ACCEPTANCE.md new file mode 100644 index 0000000..abcff42 --- /dev/null +++ b/workbenches/55-opensheetmusicdisplay/app/ACCEPTANCE.md @@ -0,0 +1,65 @@ +# 乐谱审校台 0.9.0 验收 + +日期 2026-10-29。功能验收与结构审查分别记录。 + +## 之前 → 现在 + +| 方面 | 0.3.1 | 0.9.0 | +| --- | --- | --- | +| 真实输入 | 原文件 ≤ 200 KB、XML ≤ 180 KB、≤ 12 声部 × 200 小节;**任何带 DOCTYPE 的文件被拒**(制谱软件导出的 MusicXML 几乎都带);外部图片引用即拒 | 原文件 ≤ 32 MB(分块上传进原件库)、解压后 ≤ 64 MB、≤ 64 声部 × 5000 小节;接受标准 DOCTYPE(不下载),只拒绝内部子集/实体/样式表;图片与链接仅提示忽略;UTF-16、Latin-1;MXL 跳过 PDF rootfile、缺容器时兜底 | +| 光标与回放 | 无;README 声明“不提供音频播放” | OSMD 光标逐音/逐小节移动、点击谱面跳到拍位;Web Audio 回放驱动光标,速度 50–150% | +| OSMD 能力 | 单一“显示声部”下拉、A4/连续、5 档比例 | 声部多选开关、A4 竖/横、无限滚动、单行横卷、30–300% 缩放与适合宽度、TransposeCalculator 移调 ±12、小节号三档、标题/作曲与声部名开关 | +| 批注 | 只在右侧表单,绑定小节;不显示在谱面 | 锚定小节 + 拍位,7 类;按乐谱顺序编号画在每页 SVG 内(待核对/已核对/选中三色),点谱面编号 ↔ 账本条目双向定位;筛选全部/待核对/本小节 | +| 导出 | 无批注的 SVG、原件、报告 JSON | PDF 校样(谱页含批注编号 + 中文批注清单页)、PNG 当前页、SVG 全部页(含批注)、批注 JSON v2、原文件、展开的 MusicXML | +| 示例 | 8 小节原创双声部 | 小步舞曲(钢琴 32 小节、两段反复、力度与速度)、欢乐颂弦乐四重奏(4 声部 16 小节)、Morning Lines | +| 页头 | “稿件”菜单(项目下拉/新建/删除)+ 立即保存主按钮 + 两行文件/显示工具条 | 「乐谱库」菜单承载导入、示例、最近审校、恢复草稿、删除;保存只显示状态(离线/冲突时“重试保存”) | + +## 功能验收:local-verified + +### 命令(Node 22.23.4) + +- `pnpm workbench:check`:manifest、依赖、类型、构建通过(仅共享 `autosave-client.ts` 体积提示)。 +- `pnpm test`:**17/17** 通过(0.3.1 为 12)。覆盖:三份示例的时值自检、索引与授权说明;同步 SHA-256 与 Node 一致;UTF-8 BOM/UTF-16LE/BE/ISO-8859-1 解码与非法字节;标准 DOCTYPE 接受、内部子集/ENTITY/自定义实体/样式表拒绝、外部图片仅警告、语法错误行号、timewise 提示;弱起小节号、拍号延续、credit 标题兜底、40 声部 × 300 小节、65 声部拒绝;约 8 MB MusicXML 与 MXL 读取;MXL rootfile 选择、缺容器兜底、穿越/伪装/257 条目/加密/真实超限/伪报长度;模型 13 类非法输入与原型键、草稿放宽空白正文、批注排序、拍位换算;批注 JSON v2;PDF 交叉引用偏移与 JPEG 尺寸;原件库多块上传/去重/MXL 解压、缺块 409/篡改/编码/序号/超限 413/无效乐谱 422;示例入库与保存前索引核对;0.3 旧项目与草稿迁移;自动保存幂等、空白批注草稿不覆盖正式内容、过期版本冲突、缺原件只存草稿、导入另建项目。 +- `pnpm package`:`artifacts/opensheetmusicdisplay-studio-0.9.0.fbworkbench`,约 55 个文件,不含业务数据(产物哈希随最终提交记录在整合报告中)。 + +### 真实浏览器(Chrome for Testing 1228 headless + puppeteer-core,`FLOATBOAT_HOST_MODE=offline`) + +1440×900: + +- 空数据目录首开:服务端 0 个项目,谱面直接渲染《小步舞曲》,两条示范批注以编号 1、2 画在第 8、20 小节;状态“编辑后自动保存”。 +- 点击第 13 小节第 3 拍的音符 → 位置卡“第 13 小节 · 第 3 拍”、锚定到音符,朱红框与拍位竖线、蓝色光标同到该处;“在此添加批注”并输入 → 谱面出现第 3 个编号,账本按乐谱顺序重排,状态 saved。 +- 键盘:→ ×2 到第 14 小节第 1 拍,Shift+→ 到第 15 小节;空格回放/暂停;点谱面编号 → 账本对应条目高亮并滚入视野。 +- 移调 +2:调号由 G 大调变为 A 大调(三个升号),批注标记仍在原小节。 +- 导出:批注 JSON(3 条、编号/小节号/拍位/类别、sha256 等于原件);SVG 556 KB 含批注层;PNG 2580×3649;PDF 2 页(macOS Quick Look 渲染谱页含编号标记,第 2 页为中文批注清单);原文件与上传字节一致(29 984 B)。导出不带“选中”高亮。 +- 导入失败:截断的 XML → “XML 语法无效(第 3 行:…)”;带 `` 内部子集 → 安全拒绝说明;HTML 改名 .xml → “缺少 score-partwise 根元素”;三次都保留当前审校。 +- 导入真实规模:`orchestral-study.mxl`(12 声部 240 小节,12 240 个音符)选文件到出图约 2.1 s,31 页;`huge-8x1200.musicxml`(5.4 MB,8 块上传,40 800 个音符)约 4.8 s,76 页。每次导入另建审校,原审校不变。 +- 示例《欢乐颂》:关闭 Violin II 与 Viola 后谱面只剩两声部、标签“声部 2/4”,Escape 关闭面板焦点回到按钮;回放 4.5 s 后位于第 2 小节第 4 拍(100 bpm 下符合),暂停后位置卡恢复“当前位置”。版式:A4 横版 1032×730、无限滚动单页、单行横卷宽 2256。 +- 原件缺失:用 API 写入一份引用未上传原件的草稿 → 打开后谱面显示“原件不在本工作台…选择原文件 evening-lines.musicxml 即可找回”;选择该文件 → “已找回原件”,批注保留,状态转为已自动保存。 +- 自动保存契约:第一次 POST 延迟 3 s,期间 phase=saving、名称仍可编辑并继续输入,刷新后为较新的“延迟保存测试 AB”;回包丢失(服务端已提交、浏览器收到网络错误)→ 自动重试 → saved,项目数 4→4、revision 13→14;空白批注 → draft“批注不能为空”,刷新后空白批注与琥珀提示恢复,补全后 saved;导入与打开示例均另建项目。 +- 控制台:除三次导入失败对应的预期 422 资源错误、缺原件场景对应的预期 404 外,无控制台或页面错误。 + +375×812(浅色与暗色):无横向溢出(scrollWidth 375);工具条三行;谱纸为 520 px 高的独立滚动区,OSMD 以 680 px 行宽排版可横向滚动;乐谱库面板左右各留 12 px,Escape 关闭后焦点回到「乐谱库」;在窄屏点击音符并添加批注,saved。暗色下面板转深、谱纸保持白底黑字。 + +脚本、日志、截图与测试文件:`/Users/zhangqinzhong/IdeaProjects/floatboat/archive/2026-10-29-workbench-depth/opensheetmusicdisplay/`(不入库)。 + +### Web Interface Guidelines + +按规则逐项复核改动源码(本轮未联网拉取最新规则文本):图标按钮均有 aria-label/title;菜单 Escape 与外部点击关闭并还焦点;表单控件有 name、label 与 autocomplete=off;可见 focus-visible;跳转链接;`translate="no"` 用于乐谱标题、作者、声部名;数字 tabular-nums;`overscroll-behavior: contain`;`touch-action: manipulation`;删除批注与删除审校为菜单内二次确认(不使用 confirm 对话框);theme-color 按明暗配色;reduced-motion 下滚动不做平滑动画;谱面编号标记可 Tab 聚焦、Enter/空格激活。 + +### 未覆盖与限制 + +- **真实 Floatboat Host 未测**;本应用不声明、不调用任何 Host/模型能力。Floatboat 客户端导入源包未实测。 +- 回放在 headless 浏览器中验证了光标随时值推进与 AudioContext 正常运行,发声未经人耳试听;回放为简单合成音色,不区分乐器,不演奏装饰音、力度与渐变;乐器移调(如单簧管 B♭)不参与发声换算。 +- PDF 谱页为 JPEG 图像(约 250 dpi),不含可选中文字;“无限滚动/单行横卷”下导出 PDF 时另做 A4 排版。 +- OSMD 排版在主线程,上百页的总谱会让界面短暂停顿;没有 Web Worker 排版。 +- 批注锚点按 MusicXML 小节顺序(声部 + 序号 + 拍位),不支持跨小节区间或单个声部内多声部(voice)的区分。 +- 原件库中不再被引用的原件不会自动回收。本地恢复依赖同源 IndexedDB,0.3 版本留在浏览器 IndexedDB 中尚未同步的本机副本无法转换(服务端已保存的项目与草稿会在启动时迁移)。 +- 大文件验收用的是生成的测试谱,没有用 MuseScore/Finale/Sibelius 的原始导出文件;DOCTYPE、UTF-16、MXL 容器等按 MusicXML 4.0 规范构造测试。 + +## 结构审查:pass(待主代理整合确认) + +- 主任务:谱纸占桌面约 75% 宽、工具条一行贴在谱纸上沿;右侧 360 px 批注账本,顶部是当前位置与“在此添加批注”,下方为编号批注列表。旧的“稿件菜单 + 立即保存 + 两行文件/显示工具条 + 下拉式逐节核对表单”已删除。 +- 文件生命周期:「乐谱库」菜单(导入、示例、最近的审校、恢复草稿、打开服务器版本、删除);没有项目选择器与新建/立即保存组合;保存为状态文字。 +- 对照截图:PageIndex(`docs/screenshots/pageindex.png`)是目录 + 原页 + 问答三栏,本台无目录、定位在谱面上完成;0.3.1 本台旧截图(审计基线)中批注与谱面分离,现在批注直接标在谱面并随导出保留。 +- 空态与有内容:空数据目录首开即可点谱、写批注、回放;没有批注的示例显示账本空态说明。 +- 截图:`docs/screenshots/opensheetmusicdisplay.png`(1440×900,空数据目录首开后添加一条批注)。 diff --git a/workbenches/55-opensheetmusicdisplay/app/DESIGN.md b/workbenches/55-opensheetmusicdisplay/app/DESIGN.md index 94253e1..6d50c71 100644 --- a/workbenches/55-opensheetmusicdisplay/app/DESIGN.md +++ b/workbenches/55-opensheetmusicdisplay/app/DESIGN.md @@ -1,7 +1,55 @@ -# 出版校审 · 产品布局 +# 乐谱审校台 · 产品布局(0.9.0) -用户围绕已有乐谱做出版/排练前校审。采用两列校样桌:左侧宽幅谱面阅读区,右侧窄批注边栏;声部与小节定位放在批注边栏顶部,不再使用左索引+中画布+右表单的通用三栏。文件与显示操作压成紧凑页眉,主标题不重复占据首屏。 +## 用户与主任务 -谱面始终黑字白纸,外围是冷灰工作面;朱红位置标记和已核对状态表达校审语境。批注像页边校记,与原谱分离。375px 优先看到谱面,定位/批注顺序进入同一列,并提供直接跳转。 +乐团资料员、出版社编辑、器乐教师拿到一份制谱软件导出的 MusicXML/MXL,要在排练或付印前逐处核对:听一遍、看清每个声部、在出问题的小节或音符上留下编号批注,再把带批注的校样(PDF/PNG)和批注清单交给作者或演奏者。最常做的动作依次是:点谱面定位 → 写批注 → 勾“已核对”;其次是回放、移调、切换声部与版式、导出。 -原始XML/MXL及审阅状态自动保存;空白新批注、导入检查和试排过程必须保留当前工作,不能误报已保存。源文件不改写、不加载外部资源;没有音符编辑或播放器承诺。React稳定实例、键盘焦点、暗色与reduced-motion保持可用。 +## 工作对象与路径 + +用户面对的对象是“一份乐谱的审校”(以乐谱标题命名,例如“小步舞曲 G 大调 · 排练前校对”),内部存为 project。 + +- **首次使用**:空数据目录首开即是内置示例《小步舞曲 G 大调》(Petzold,BWV Anh. 114)及两条示范批注,可直接点谱、回放、写批注,不必先建项目。 +- **继续上次工作**:自动保存的最近审校在「乐谱库」菜单里,刷新后回到上次的文档、显示设置与审阅位置。 +- **导入**:「乐谱库 → 导入 MusicXML / MXL」。原文件分块上传到原件库,服务端解析通过、浏览器试排前 8 小节后,才另建一份新审校;失败时当前工作不变,错误说明原因(XML 第几行、DTD 内部子集、不是 score-partwise 等)。 +- **示例**:「乐谱库 → 示例乐谱」三份,每次打开另建一份审校。 +- **恢复**:审校引用的原件不在本机(例如从其他环境恢复的草稿)时,谱面位置显示“选择原文件…”,选择内容一致的原文件即找回,批注不动;内容不同则作为新导入。冲突时菜单出现“打开服务器版本”,任何时候可“导出恢复草稿”。 +- **工作副本**:编辑即建立,约 700 ms 后自动保存;空白批注作为草稿保存但不进入正式版本。 + +## 空间关系 + +``` +┌ 页眉:𝄞 谱校 · [乐谱库▾] · 审校名称 / 著录(作曲·编配·声部·小节·原件大小)· 保存状态 ┐ +├ 谱面工具条:光标/回放/速度 · 缩放 · 版式 · 移调 · 声部▾ · 导出▾ ──────────┬ 批注账本 ─┤ +│ │ 当前位置 │ +│ 宽幅谱纸(OSMD SVG,白纸) │ 声部/小节 │ +│ · 蓝色光标(逐音移动、回放时跟随) │ 锚定方式 │ +│ · 朱红框:当前小节;竖线:当前拍位 │ [在此添加] │ +│ · 编号圆标:批注(朱红=待核对,灰=已核对,蓝=选中) │ 筛选 │ +│ │ 编号批注列表│ +├ 页数 · 音符数 · 移调状态 · 版权说明 ───────────────────────────────────┴───────────┤ +``` + +主操作贴着对象:定位在谱面上点击完成,添加批注在当前位置卡片里;回放、移调、版式与导出在谱纸上沿。文件生命周期只在「乐谱库」菜单:导入、示例、最近审校、恢复草稿、删除(菜单内二次确认)。没有“项目选择 + 新建 / 立即保存”页头组合;保存只有状态文字,离线或冲突时出现“重试保存”,⌘/Ctrl+S 可立即保存。 + +谱面与账本双向联动:点谱面编号 → 账本滚动到该条并高亮;点账本编号或位置 → 谱面滚动到该小节、光标移到拍位。光标移动(←/→ 逐音,Shift+←/→ 逐小节)同步“当前位置”。 + +## 视觉 + +- 冷灰校样桌(#e3e7ea)+ 白纸谱面,谱纸在暗色模式下仍白底黑字;工具与账本为浅灰面板。 +- 朱红 #c2410c 只用于批注与当前位置,蓝 #2563eb 只用于光标、选中与回放状态,灰蓝表示已核对。 +- 文档标题与当前小节号用宋体(Songti SC / Noto Serif CJK),呼应出版校样;界面文字用系统无衬线,数字等宽。 +- 密度:工具条 32 px 控件;账本每条批注一张卡片,编号圆标与谱面一致。 + +## 关键状态 + +读取原件 / 解析 / 排版(谱面上方浮动提示);导入分块进度(保存状态处);原件缺失(谱面内找回入口);渲染失败(谱面内 alert,已保存内容不受影响);回放中(按钮变蓝“暂停”,位置卡片显示“正在回放”);空白批注(卡片内琥珀色提示,状态为“草稿已自动保存,待完善输入”);导出中(导出按钮显示进度,例如“正在生成 PDF 第 3/12 页…”)。 + +## 窄屏(375 px) + +页眉换行:品牌、乐谱库、保存状态一行,审校名称与著录一行;工具条分三行(回放、缩放与版式、移调与声部/导出);谱纸固定 64 vh 的独立滚动区,OSMD 以至少 680 px 的行宽排版,可横向滚动或缩小比例;账本在谱纸下方完整展开。菜单在窄屏改为左右各留 12 px 的固定面板。 + +## 与相邻工作台的差异 + +- **PageIndex**(docs/screenshots/pageindex.png):左目录 + 中原页 + 右摘录/问答三栏,主区约 50%。本台没有左侧目录,谱纸占约 75% 宽;定位靠在谱面上点击与光标,而不是目录树;批注直接画进谱纸并随导出保留。 +- **旧版本台**(0.3,docs/screenshots/opensheetmusicdisplay.png 的历史版本):页眉有“稿件”菜单 + 立即保存,第二、三行是文件与显示工具条,右侧是“逐节核对”下拉表单,批注不在谱面上。现在页眉只剩乐谱库与状态,工具条一行贴着谱纸,定位改为谱面点击,批注编号显示在谱面上。 +- **Mirador**(双图光台 + 复核账本):同为“看图 + 账本”,但 Mirador 是左资料抽屉和深色光台;本台是白纸校样与音乐专用的时间轴(光标、回放、拍位)。 diff --git a/workbenches/55-opensheetmusicdisplay/app/ENGINEERING.md b/workbenches/55-opensheetmusicdisplay/app/ENGINEERING.md index df3d03f..c6311cc 100644 --- a/workbenches/55-opensheetmusicdisplay/app/ENGINEERING.md +++ b/workbenches/55-opensheetmusicdisplay/app/ENGINEERING.md @@ -1,31 +1,27 @@ -# 乐谱审校工程 +# 乐谱审校工程(0.9.0) -固定 Node 22、React 19.2.3、严格 TypeScript、官方 Vite profile。root/app 精确依赖与锁一致:opensheetmusicdisplay 2.2.1、fast-xml-parser 5.11.2、jszip 3.10.1。覆盖规则移除 OSMD 的 gl 原生可选依赖,仅使用发布的浏览器构建;没有更改 SDK、CSP 或 Vite。 +固定 Node 22、React 19.2.3、严格 TypeScript、官方 Vite profile。root/app 精确依赖与锁一致:opensheetmusicdisplay 2.2.1、fast-xml-parser 5.11.2、jszip 3.10.1、lucide-react 0.562.0。覆盖规则移除 OSMD 的 gl 原生可选依赖。0.9.0 没有新增依赖:PDF 由自带的最小写入器生成,回放用浏览器 Web Audio。 -## 模块与数据 +## 模块 -- contracts/musicxml 验证 XML 语法、资源声明、深度/规模,读取 score-partwise 声部/小节索引。 -- contracts/archive 在 JSZip 解压前检查 ZIP 中央目录;再用固定版本实际存在的 internalStream 接口做实际输出限额。上游类型缺少该方法,因此仅对已核实的方法作窄类型补充并运行时检查,不关闭类型检查。 -- contracts/domain 验证原文件编码、XML 对应关系、显示参数、审阅位置和批注锚点;archive.verifySource 在 POST/PUT 时重新解压 MXL 核实原件与渲染 XML 一致。 -- 原件 base64 和安全解析后的 XML 保存在项目 payload;限制足够小,不需要临时 blob URL 或外部路径。原件下载解码完整字节,报告记录 SHA-256。 -- server/store 使用参数化 SQL,revision 防止旧窗口覆盖新版本。迁移 0002_projects.sql 保持不变,测试使用内存 SQLite。 -- UI/App 管理工作流程,MeasureIndex 与 AnnotationBook 分离导航和批注;ScoreView memo 避免批注输入重建乐谱。OSMD 动态加载,关闭自动 resize,只在明确设置或文件变化后渲染。每次 load 绑定独立 DOM 容器;异步完成时检查活动状态,卸载清理自己的实例。 -- SVG 导出将所有谱页按真实尺寸串接成独立 SVG,白底、音符路径保留,不依赖工作台 CSS。报告 JSON 与源文件分开导出。 +- `contracts/musicxml.ts`:BOM/声明编码解码;安全检查(接受标准 DOCTYPE,拒绝内部子集/实体/样式表);fast-xml-parser 解析出声部、谱表数、小节号、拍号延续、著录(work-title / movement-title / creator / credit-words / rights)与警告。8 MB 文件约 0.7 s。 +- `contracts/archive.ts`:MXL 中央目录检查、按真实输出字节计数的流式解压(JSZip `internalStream`,上游类型缺此方法,窄类型补充并运行时检查),`readSource()` 统一入口。 +- `contracts/domain.ts`:模型 `{source: SourceRef, index, view, annotations, focus}` 与逐字段校验;`restoreScoreDraft` 只放宽空白正文。枚举值用 `Object.hasOwn` 防原型键。 +- `contracts/samples.ts` + `score-builder.ts`:记谱串生成标准 MusicXML 并逐小节检查时值;`sha256.ts` 为种子同步生成与服务端一致的原件标识。 +- `contracts/report.ts`(批注 JSON v2)、`contracts/pdf.ts`(每页一张 DCTDecode JPEG 的 PDF 1.4,UTF-16BE 标题)。 +- `server/sources.ts`:`score_sources` / `score_source_parts`(迁移 0004)。768 KB 原始字节一块(base64 后约 1 MB,低于运行时 2 MB 请求上限);finish 时校验块序、长度与 SHA-256,解析失败 422;MXL 解压结果与索引一并入库;`/api/sources/:id/musicxml` 与 `/original` 以 immutable 缓存返回。内置示例启动时写入。`verify(project)` 在自动保存事务之前核对原件存在且索引一致。 +- `server/legacy.ts`:0.3.x 项目与自动保存草稿(原件 base64 内嵌)启动时迁入原件库并改写结构,项目 revision +1,草稿与回执同步;只处理一次。 +- `score/engine.ts`:OSMD 选项映射(版式、小节号、标题、声部名、光标、关闭自动合并多小节休止以保持小节一一对应)、移调、几何查询(GraphicalMeasure 外框、拍位横坐标、点击命中最近谱表与音符)、光标定位、在每页 SVG 内绘制批注层、页面快照与拼接、屏幕外渲染。 +- `score/ScoreView.tsx`:持有 OSMD 实例;原件变化时新建,显示设置变化时同实例重排,批注/位置变化只重绘批注层。`score/playback.ts`:基于光标迭代器(clone 预读下一时值)的 Web Audio 调度。`score/exporter.ts`:SVG→canvas(data: URL,符合 CSP)→ PNG/JPEG,批注清单页用 canvas 排版中文后进 PDF。 +- UI:`App.tsx` 状态与流程,`FileMenu`(乐谱库)、`ScoreToolbar`、`Ledger`、`Menu`(Escape/外部点击关闭并还焦点)、`sources.ts`(分块上传、MusicXML 缓存两份)。 -## 边界与恢复 +## 边界 -MusicXML 不是任意 XML 查看器;不加载外部 DTD、实体、图片、链接或脚本。MXL 不支持分卷/ZIP64/加密,只处理首个 rootfile;不从文件中的 URL 发起网络请求。输入必须先通过业务校验与离屏真实试渲染,才替换 React 项目状态。 - -所有会修改数据的控件在导入/保存/打开期间禁用。失败保留原文、批注;保存成功但列表刷新失败单独提示。空白批注不会保存,项目原件和批注在同一版本中提交。显示声部与批注声部是不同概念,批注绑定原始声部 ID + 小节序号,展示小节号单独记录,以支持弱起/重复编号。 +- 批注坐标存在 SVG 用户坐标里(OSMD 单位 × 10),因此缩放、导出与打印保持对齐;在“无限滚动/单行横卷”版式下导出 PDF 时另做一次屏幕外 A4 排版。 +- 回放为简单合成音色,不区分乐器、不演奏装饰音/力度变化;反复记号按 OSMD 迭代器展开。 +- OSMD 排版在主线程执行。本机 Chrome 从选择文件到出图:12 声部 240 小节的 MXL 约 2 s(31 页),8 声部 1200 小节的 5.4 MB MusicXML 约 5 s(76 页)。 +- 未引用的原件保留在原件库,暂无回收。 ## 验证 -pnpm workbench:check → pnpm test → pnpm dev Chrome 主流程 → pnpm package。测试覆盖 XML/实体/外部资源、ZIP 路径/大小/伪报长度、原件一致性、批注锚点、报告哈希、SQLite 恢复和409冲突。浏览器覆盖真实文件与导出、刷新恢复、暗色/375px/reduced-motion、无失败资源与CSP错误。没有音频、模型或其他 Host 能力;客户端导入安装不由本地预览代替。 - -scripts/collect-licenses.mjs 可重新生成生产依赖许可证汇编;它是维护脚本,不参与工作台运行或自定义编译。 - -## 自动保存与工作草稿 - -新增 0003_autosave.sql,旧迁移保持不变。autosave-server 在事务中保存完整工作副本,并仅将通过业务验证的内容写入 projects;documentId/mutationId 支持丢失响应后的幂等重试。客户端串行提交、防抖、IndexedDB 回退,documentKey/loadEpoch 仅在文档切换时更新。保存返回时不覆盖期间继续输入的内容。草稿状态与正式内容状态明确区分。 - -音乐原件通过 verifySource 在事务之前进行异步字节对应校验;恢复器仅放宽空批注,XML/ZIP 安全和锚点检查保持。 +`pnpm workbench:check` → `pnpm test`(17 项)→ `pnpm dev` 真实浏览器(1440 / 375、暗色)→ `pnpm package`。没有 Host 能力调用;客户端导入安装不由本地预览代替。scripts/collect-licenses.mjs 可重新生成生产依赖许可证汇编。 diff --git a/workbenches/55-opensheetmusicdisplay/app/REQUIREMENTS.md b/workbenches/55-opensheetmusicdisplay/app/REQUIREMENTS.md index 24ca814..fc503d8 100644 --- a/workbenches/55-opensheetmusicdisplay/app/REQUIREMENTS.md +++ b/workbenches/55-opensheetmusicdisplay/app/REQUIREMENTS.md @@ -1,17 +1,31 @@ -# 乐谱审校需求 +# 乐谱审校需求(0.9.0) 用户:乐团资料员、音乐出版社编辑、器乐教师。 -主流程:导入 MusicXML/MXL → 确认原谱渲染 → 选择声部和 A4/连续版式 → 选择声部与小节添加批注/核对状态 → 保存 → 重新打开 → 导出 SVG、原件、批注报告。 +主流程:导入 MusicXML/MXL(或打开示例)→ 确认原谱渲染 → 回放、移调、选择显示声部与版式 → 在谱面上点击音符或小节定位,添加编号批注(类别、正文、核对状态)→ 自动保存 → 重新打开 → 导出带批注的 PDF/PNG/SVG、批注 JSON、原文件。 -原始文件必须逐字节保留;审阅位置、声部筛选、比例、版式、批注一起持久化。导入先安全解析并试渲染,再替换当前工作;任何失败保持当前数据。覆盖未保存修改、删除项目及删除批注需明确确认。保存以 revision 检查并发冲突。 +## 输入 -支持 UTF-8 score-partwise;文件 200 KB、XML 180 KB、12 声部、每声部 200 小节、4000 音符/休止符、100 条批注。拒绝外部实体/资源,MXL 另检查目录路径、条目与实际解压长度。文件限制和不支持的 DTD 在 README 与错误信息中明确说明。 +- `.musicxml` / `.xml`(UTF-8、UTF-16、声明的 ISO-8859-1/Windows-1252)与压缩 `.mxl`,原文件最大 32 MB,解压后的 MusicXML 最大 64 MB。 +- 制谱软件导出的标准 DOCTYPE(只引用公开 DTD)被接受且从不下载;DTD 内部子集、ENTITY 声明、自定义实体、xml-stylesheet 被拒绝。外部图片、链接仅提示“已忽略”。 +- score-partwise;最多 64 声部、每声部 5000 小节、40 万个音符/休止符。score-timewise 给出转换提示。 +- MXL:按 container.xml 选第一个 MusicXML rootfile(跳过 PDF 等),缺少容器时使用唯一的根目录乐谱;拒绝分卷、ZIP64、加密、未知压缩、路径穿越、重复条目,实际解压流按字节限额。 +- 原文件逐字节保留在原件库(SHA-256 为键,内容去重);项目只保存原件引用、解析索引、显示设置、批注与审阅位置。 -仅实现渲染与审校,不承诺音符编辑、完整制谱、播放器或任意 MusicXML 扩展。无 AI/外部服务依赖。原创建谱示例只用于展示。 +## 能力(使用锁定的 OpenSheetMusicDisplay 2.2.1) -验收:真实引擎输出、XML/MXL 原件恢复、批注锚点与报告、错误恢复、保存冲突、键盘与375px、dark/reduced-motion、独立打开SVG/XML、固定模板check/test/package;导入客户端安装另行验收。 +- 光标:逐音前后移动、逐小节跳转、点击谱面后光标移到该拍位;回放时跟随。 +- 回放:Web Audio 合成器按光标逐时值发声(可见声部、移调后音高、乐谱速度 × 50–150%),不加载外部音色。 +- 缩放 30–300%、适合宽度;版式 A4 竖版/横版分页、无限滚动(Endless)、单行横卷。 +- 声部显示开关(至少保留一个)、小节号(每行开头/每小节/不显示)、标题与作曲信息、声部名称。 +- 移调 ±12 半音(TransposeCalculator,调号一并改写)。 +- 批注:锚定声部 + 小节(+ 可选拍位),7 类,最多 1000 条、每条 2000 字;按乐谱顺序编号,画在谱面上,与账本双向联动;筛选全部/待核对/本小节。 +- 导出:PDF 校样(每页谱面含批注编号 + 批注清单页)、PNG 当前页、SVG 全部页、批注 JSON(v2,含原件 SHA-256、小节号、拍位、类别)、原文件、MXL 展开的 MusicXML。 -自动保存:停止输入约 700 ms 后保存,后台请求不锁编辑。未写完的空白批注、显示设置及当前小节可在刷新后恢复;不合法的业务输入显示“草稿已自动保存,待完善输入”,不会覆盖最后的合法内容。网络失败时保留本机副本,版本冲突提供恢复草稿导出与服务器版本入口。导入先验证,再创建独立项目。 +## 持久化与恢复 -当前布局:谱面为主体、右侧小节索引与审校批注;375px、暗色与减少动画偏好保持可用。 +自动保存约 700 ms 后提交,后台请求不锁编辑;空白批注保存为草稿、不覆盖正式内容;网络失败保留本机副本;冲突提供服务器版本与恢复草稿导出;导入与示例另建新审校。原件缺失时可选择同一原文件找回。0.3.x 旧项目(原件内嵌)在启动时迁入原件库。 + +## 不做 + +音符编辑与制谱、真实乐器音色、MIDI 输出、多人实时协作。无 AI/外部服务依赖,manifest 没有 llm 能力。 diff --git a/workbenches/55-opensheetmusicdisplay/app/index.html b/workbenches/55-opensheetmusicdisplay/app/index.html index 0cf288f..c6dd369 100644 --- a/workbenches/55-opensheetmusicdisplay/app/index.html +++ b/workbenches/55-opensheetmusicdisplay/app/index.html @@ -4,7 +4,9 @@ - + + + 乐谱审校室 diff --git a/workbenches/55-opensheetmusicdisplay/app/manifest.json b/workbenches/55-opensheetmusicdisplay/app/manifest.json index 3ef8058..0cf3a99 100644 --- a/workbenches/55-opensheetmusicdisplay/app/manifest.json +++ b/workbenches/55-opensheetmusicdisplay/app/manifest.json @@ -3,9 +3,9 @@ "appId": "workbench.bea0c027-bedd-4ee0-ba91-00c23c23361e", "registryName": "opensheetmusicdisplay-studio", "name": "乐谱审校室", - "description": "MusicXML/MXL 原件留存、声部与版式审阅、小节批注和 SVG 导出", - "version": "0.3.1", - "changelog": "共享自动保存修复:成功保存后立即切换文档不再误报“本机缓存尚未修复”。", + "description": "MusicXML/MXL 原件审校:OSMD 光标逐音移动与回放、移调、声部与版式切换,批注编号标在谱面上,导出带批注的 PDF/PNG/SVG 与批注 JSON", + "version": "0.9.0", + "changelog": "0.9.0:原件库分块上传,接住 32 MB 原文件与标准 DOCTYPE 的真实 MusicXML/MXL;光标、Web Audio 回放、移调、四种版式、声部开关与小节号;批注锚定小节/拍位并编号画在谱面上,与批注账本联动;带批注的 PDF/PNG/SVG 导出;三份公有领域/原创示例。", "sdkVersion": "1.0.0", "runtime": "node22-sqlite-v1", "build": { diff --git a/workbenches/55-opensheetmusicdisplay/app/migrations/0004_sources.sql b/workbenches/55-opensheetmusicdisplay/app/migrations/0004_sources.sql new file mode 100644 index 0000000..0f73f3a --- /dev/null +++ b/workbenches/55-opensheetmusicdisplay/app/migrations/0004_sources.sql @@ -0,0 +1,17 @@ +-- 0.9.0:乐谱原件改为按 SHA-256 存入原件库,项目只保存引用、索引、显示设置与批注。 +CREATE TABLE score_sources( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + size INTEGER NOT NULL, + data BLOB NOT NULL, + xml TEXT, + score_index TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE TABLE score_source_parts( + source_id TEXT NOT NULL, + part INTEGER NOT NULL, + data BLOB NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY(source_id, part) +); diff --git a/workbenches/55-opensheetmusicdisplay/app/package.json b/workbenches/55-opensheetmusicdisplay/app/package.json index 6b3381d..0630c11 100644 --- a/workbenches/55-opensheetmusicdisplay/app/package.json +++ b/workbenches/55-opensheetmusicdisplay/app/package.json @@ -1,7 +1,7 @@ { "name": "local-workbench", "private": true, - "version": "0.3.1", + "version": "0.9.0", "type": "module", "scripts": { "start": "node --enable-source-maps dist/server/index.mjs", diff --git a/workbenches/55-opensheetmusicdisplay/app/src/contracts/archive.ts b/workbenches/55-opensheetmusicdisplay/app/src/contracts/archive.ts index 007331e..3f44b25 100644 --- a/workbenches/55-opensheetmusicdisplay/app/src/contracts/archive.ts +++ b/workbenches/55-opensheetmusicdisplay/app/src/contracts/archive.ts @@ -1,48 +1,49 @@ +// ABOUTME: 压缩 MusicXML(.mxl)的目录检查与限额解压,以及原文件到可渲染 MusicXML 的统一入口。 import JSZip from "jszip"; -import { safeXml, inspectScore, MAX_XML_BYTES } from "./musicxml.ts"; -import { - bytesToBase64, - originalBytes, - type SourceFile, - type ScoreProject, - validateScore, -} from "./domain.ts"; +import { decodeXml, inspectScore, MAX_XML_BYTES, safeXml, type ScoreIndex } from "./musicxml.ts"; import { isRecord } from "./project.ts"; + +/** 上传原文件上限:普通 MusicXML 或 MXL。 */ +export const MAX_SOURCE_BYTES = 32 * 1024 * 1024; +const MAX_ENTRIES = 256; +const MAX_DECLARED_TOTAL = 512 * 1024 * 1024; + +export type SourceKind = "xml" | "mxl"; + +export function sourceKind(fileName: string, bytes: Uint8Array): SourceKind { + if (!/\.(?:xml|musicxml|mxl)$/i.test(fileName)) throw new Error("请选择 .musicxml、.xml 或 .mxl 文件。"); + const zip = bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04; + if (zip) return "mxl"; + if (/\.mxl$/i.test(fileName)) throw new Error("这个 .mxl 不是 ZIP 压缩包,可能已损坏。"); + return "xml"; +} + function safePath(name: string) { - return ( - !!name && - !name.startsWith("/") && - !name.includes("\\") && - !name.split("/").includes("..") && - !/[\u0000-\u001f]/.test(name) - ); + return !!name && !name.startsWith("/") && !name.includes("\\") && !name.split("/").includes("..") && !/[\u0000-\u001f]/.test(name); } -export function inspectZip(bytes: Uint8Array): void { - if (bytes.length < 22 || bytes.length > 200000) - throw new Error("MXL 压缩文件必须小于 200 KB。"); + +/** 解压前读取 ZIP 中央目录:拒绝分卷、ZIP64、加密、未知压缩方式、路径穿越与重复条目。 */ +export function inspectZip(bytes: Uint8Array): string[] { + if (bytes.length < 22) throw new Error("MXL 压缩包不完整。"); + if (bytes.length > MAX_SOURCE_BYTES) throw new Error(`原文件超过 ${MAX_SOURCE_BYTES / 1048576} MB。`); const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); let end = -1; for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 65557); i--) - if ( - view.getUint32(i, true) === 0x06054b50 && - i + 22 + view.getUint16(i + 20, true) === bytes.length - ) { + if (view.getUint32(i, true) === 0x06054b50 && i + 22 + view.getUint16(i + 20, true) === bytes.length) { end = i; break; } - if (end < 0 || view.getUint16(end + 4, true) || view.getUint16(end + 6, true)) - throw new Error("MXL ZIP 目录无效,不支持分卷。"); + if (end < 0 || view.getUint16(end + 4, true) || view.getUint16(end + 6, true)) throw new Error("MXL 的 ZIP 目录无效(不支持分卷)。"); const count = view.getUint16(end + 10, true), size = view.getUint32(end + 12, true), offset = view.getUint32(end + 16, true); - if (!count || count > 32 || offset + size > end) - throw new Error("MXL 最多允许 32 个文件,不支持 ZIP64。"); + if (count === 0xffff || offset === 0xffffffff) throw new Error("MXL 使用了 ZIP64,请用制谱软件重新导出。"); + if (!count || count > MAX_ENTRIES || offset + size > end) throw new Error(`MXL 最多 ${MAX_ENTRIES} 个条目,目录需完整。`); let cursor = offset, total = 0; - const names = new Set(); + const names: string[] = []; for (let i = 0; i < count; i++) { - if (cursor + 46 > end || view.getUint32(cursor, true) !== 0x02014b50) - throw new Error("MXL 文件目录损坏。"); + if (cursor + 46 > end || view.getUint32(cursor, true) !== 0x02014b50) throw new Error("MXL 文件目录损坏。"); const flags = view.getUint16(cursor + 8, true), method = view.getUint16(cursor + 10, true), expanded = view.getUint32(cursor + 24, true), @@ -50,46 +51,33 @@ export function inspectZip(bytes: Uint8Array): void { extra = view.getUint16(cursor + 30, true), comment = view.getUint16(cursor + 32, true); total += expanded; - if ( - flags & 1 || - ![0, 8].includes(method) || - expanded > MAX_XML_BYTES || - total > 500000 - ) - throw new Error("MXL 含加密、未知压缩或解压后过大的内容。"); + if (flags & 1) throw new Error("MXL 已加密,无法读取。"); + if (![0, 8].includes(method)) throw new Error("MXL 使用了不支持的压缩方式(仅支持存储与 Deflate)。"); + if (total > MAX_DECLARED_TOTAL) throw new Error("MXL 声明的解压总量过大。"); const next = cursor + 46 + nameSize + extra + comment; if (next > end) throw new Error("MXL 文件目录越界。"); - const name = new TextDecoder("utf-8", { fatal: true }).decode( - bytes.subarray(cursor + 46, cursor + 46 + nameSize), - ); - if (!safePath(name) || names.has(name)) - throw new Error("MXL 路径不安全或重复。"); - names.add(name); + const name = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(cursor + 46, cursor + 46 + nameSize)); + if (!safePath(name) || names.includes(name)) throw new Error("MXL 含不安全或重复的路径。"); + names.push(name); cursor = next; } if (cursor !== offset + size) throw new Error("MXL 目录长度不一致。"); + return names; } -async function boundedFile( - zip: JSZip, - name: string, - limit: number, -): Promise { + +/** 以实际输出字节计数的流式解压,防止伪报长度。 */ +async function boundedFile(zip: JSZip, name: string, limit: number): Promise { const entry = zip.file(name); - if (!entry) throw new Error("MXL 缺少指定的乐谱文件。"); + if (!entry) throw new Error(`MXL 缺少 ${name}。`); + const streaming = entry as JSZip.JSZipObject & { + internalStream?: (type: "uint8array") => JSZip.JSZipStreamHelper; + }; + if (typeof streaming.internalStream !== "function") throw new Error("MXL 流式解压接口不可用。"); + const stream = streaming.internalStream("uint8array"); return new Promise((resolve, reject) => { const chunks: Uint8Array[] = []; let size = 0, failed = false; - const streaming = entry as JSZip.JSZipObject & { - internalStream: ( - type: "uint8array", - ) => JSZip.JSZipStreamHelper; - }; - if (typeof streaming.internalStream !== "function") { - reject(new Error("MXL 流式解压接口不可用。")); - return; - } - const stream = streaming.internalStream("uint8array"); stream .on("data", (chunk) => { if (failed) return; @@ -97,7 +85,7 @@ async function boundedFile( if (size > limit) { failed = true; stream.pause(); - reject(new Error("MXL 解压内容超过限制。")); + reject(new Error(`MXL 中的乐谱解压后超过 ${Math.round(limit / 1048576)} MB。`)); return; } chunks.push(chunk); @@ -116,52 +104,35 @@ async function boundedFile( .resume(); }); } + +/** 选择 container.xml 中第一个 MusicXML rootfile;缺少容器时退回到唯一的根目录 .xml/.musicxml。 */ export async function extractMxl(bytes: Uint8Array): Promise { - inspectZip(bytes); + const names = inspectZip(bytes); const zip = await JSZip.loadAsync(bytes, { createFolders: false }); - const containerBytes = await boundedFile( - zip, - "META-INF/container.xml", - 10000, - ); - const tree = safeXml( - new TextDecoder("utf-8", { fatal: true }).decode(containerBytes), - ); - const container = tree.container, - files = isRecord(container) ? container.rootfiles : undefined, - root = isRecord(files) ? files.rootfile : undefined; - const rootfile = Array.isArray(root) ? root[0] : root; - const name = isRecord(rootfile) ? rootfile["@_full-path"] : undefined; - if (typeof name !== "string" || !safePath(name)) - throw new Error("MXL 容器未指定安全的 MusicXML 文件。"); - const xml = new TextDecoder("utf-8", { fatal: true }).decode( - await boundedFile(zip, name, MAX_XML_BYTES), - ); - inspectScore(xml); - return xml; -} -export async function importScore( - bytes: Uint8Array, - fileName: string, -): Promise { - const kind = fileName.toLowerCase().endsWith(".mxl") ? "mxl" : "xml"; - if (!/\.(?:xml|musicxml|mxl)$/i.test(fileName)) - throw new Error("请选择 .musicxml、.xml 或 .mxl 文件。"); - if (!bytes.length || bytes.length > 200000) - throw new Error("原文件需要 1–200 KB。"); - const xml = - kind === "mxl" - ? await extractMxl(bytes) - : new TextDecoder("utf-8", { fatal: true }).decode(bytes); - inspectScore(xml); - return { fileName, kind, originalBase64: bytesToBase64(bytes), xml }; + let target: string | undefined; + if (names.includes("META-INF/container.xml")) { + const { tree } = safeXml(decodeXml(await boundedFile(zip, "META-INF/container.xml", 64 * 1024))); + const container = tree.container, + files = isRecord(container) ? container.rootfiles : undefined, + roots = isRecord(files) ? files.rootfile : undefined; + const candidates = (Array.isArray(roots) ? roots : [roots]).filter(isRecord); + const chosen = + candidates.find((root) => typeof root["@_media-type"] !== "string" || /musicxml/i.test(String(root["@_media-type"]))) ?? candidates[0]; + target = chosen && typeof chosen["@_full-path"] === "string" ? chosen["@_full-path"] : undefined; + if (!target || !safePath(target) || !names.includes(target)) throw new Error("MXL 容器没有指向包内的 MusicXML 文件。"); + } else { + const scores = names.filter((name) => !name.includes("/") && /\.(?:xml|musicxml)$/i.test(name)); + if (scores.length !== 1) throw new Error("MXL 缺少 META-INF/container.xml,且无法确定乐谱文件。"); + target = scores[0]; + } + return decodeXml(await boundedFile(zip, target, MAX_XML_BYTES)); } -export async function verifySource(value: unknown): Promise { - const data = validateScore(value); - if ( - data.source.kind === "mxl" && - (await extractMxl(originalBytes(data.source))) !== data.source.xml - ) - throw new Error("MXL 原文件与审阅内容不一致。"); - return data; + +/** 原文件 → 可渲染的 MusicXML 文本与索引。服务端与测试使用同一实现。 */ +export async function readSource(bytes: Uint8Array, fileName: string): Promise<{ kind: SourceKind; xml: string; index: ScoreIndex }> { + if (!bytes.length) throw new Error("文件为空。"); + if (bytes.length > MAX_SOURCE_BYTES) throw new Error(`原文件超过 ${MAX_SOURCE_BYTES / 1048576} MB。`); + const kind = sourceKind(fileName, bytes); + const xml = kind === "mxl" ? await extractMxl(bytes) : decodeXml(bytes); + return { kind, xml, index: inspectScore(xml) }; } diff --git a/workbenches/55-opensheetmusicdisplay/app/src/contracts/domain.ts b/workbenches/55-opensheetmusicdisplay/app/src/contracts/domain.ts index 18903a8..1360021 100644 --- a/workbenches/55-opensheetmusicdisplay/app/src/contracts/domain.ts +++ b/workbenches/55-opensheetmusicdisplay/app/src/contracts/domain.ts @@ -1,140 +1,230 @@ +// ABOUTME: 审校项目的数据模型与校验:原件引用、谱面索引、显示设置、批注锚点。 import { boundedText, isRecord } from "./project.ts"; -import { inspectScore, MAX_XML_BYTES } from "./musicxml.ts"; -export interface SourceFile { +import { MAX_MEASURES, MAX_PARTS, type MeasureInfo, type PartInfo, type ScoreIndex } from "./musicxml.ts"; +import type { SourceKind } from "./archive.ts"; + +export interface SourceRef { + /** 原文件 SHA-256(小写十六进制),同时是服务端原件库的键。 */ + id: string; fileName: string; - kind: "xml" | "mxl"; - originalBase64: string; - xml: string; + kind: SourceKind; + size: number; +} +export type Layout = "page" | "landscape" | "endless" | "line"; +export const LAYOUTS: Record = { + page: "A4 竖版分页", + landscape: "A4 横版分页", + endless: "无限滚动", + line: "单行横卷", +}; +export type MeasureNumbers = "system" | "every" | "off"; +export interface ViewSettings { + hiddenParts: string[]; + layout: Layout; + zoom: number; + /** 半音,-12…12,使用 OSMD TransposeCalculator。 */ + transpose: number; + measureNumbers: MeasureNumbers; + credits: boolean; + partNames: boolean; + /** 回放速度百分比。 */ + tempo: number; } +export const ANNOTATION_KINDS = { + pitch: "音高", + rhythm: "节奏", + dynamics: "力度", + articulation: "演奏法", + layout: "排版", + text: "文字", + other: "其他", +} as const; +export type AnnotationKind = keyof typeof ANNOTATION_KINDS; +/** 小节内拍位:以全音符为单位的分数 [分子, 分母];null 表示整小节。 */ +export type Beat = [number, number] | null; export interface Annotation { id: string; partId: string; measureIndex: number; + beat: Beat; + kind: AnnotationKind; text: string; resolved: boolean; + createdAt: string; } -export interface ScoreProject { - source: SourceFile; +export interface Focus { partId: string; - layout: "page" | "continuous"; - zoom: number; + measureIndex: number; + beat: Beat; +} +export interface ScoreProject { + source: SourceRef; + index: ScoreIndex; + view: ViewSettings; annotations: Annotation[]; - focus: { partId: string; measureIndex: number }; + focus: Focus; } -export function bytesToBase64(bytes: Uint8Array): string { - let binary = ""; - for (let i = 0; i < bytes.length; i++) - binary += String.fromCharCode(bytes[i]); - return btoa(binary); +export const MAX_ANNOTATIONS = 1000; +export const MAX_NOTE_TEXT = 2000; +export const ZOOM_MIN = 0.3, + ZOOM_MAX = 3; + +export function defaultView(): ViewSettings { + return { hiddenParts: [], layout: "page", zoom: 0.8, transpose: 0, measureNumbers: "system", credits: true, partNames: true, tempo: 100 }; } -export function originalBytes(source: SourceFile): Uint8Array { - if ( - source.originalBase64.length > 270_000 || - !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test( - source.originalBase64, - ) - ) - throw new Error("原文件编码无效或过大。"); - const text = atob(source.originalBase64); - if (text.length > 200_000 || !text.length) - throw new Error("原文件需要 1–200 KB。"); - return Uint8Array.from(text, (c) => c.charCodeAt(0)); +export function newProject(source: SourceRef, index: ScoreIndex): ScoreProject { + return { source, index, view: defaultView(), annotations: [], focus: { partId: index.parts[0].id, measureIndex: 0, beat: null } }; } + +const str = (value: unknown, max: number, label: string): string => { + if (typeof value !== "string" || value.length > max) throw new Error(`${label}无效。`); + return value; +}; +const int = (value: unknown, min: number, max: number, label: string): number => { + if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) throw new Error(`${label}无效。`); + return value; +}; + +export function parseSource(value: unknown): SourceRef { + if (!isRecord(value)) throw new Error("项目需要乐谱原文件。"); + if (typeof value.id !== "string" || !/^[0-9a-f]{64}$/.test(value.id)) throw new Error("原文件标识无效。"); + if (value.kind !== "xml" && value.kind !== "mxl") throw new Error("不支持的原文件类型。"); + const fileName = str(value.fileName, 200, "原文件名"); + if (!fileName.trim() || /[\\/\u0000-\u001f]/.test(fileName)) throw new Error("原文件名无效。"); + return { id: value.id, fileName, kind: value.kind, size: int(value.size, 1, 32 * 1024 * 1024, "原文件大小") }; +} + +export function parseIndex(value: unknown): ScoreIndex { + if (!isRecord(value) || !Array.isArray(value.parts) || !Array.isArray(value.measures) || !Array.isArray(value.warnings)) + throw new Error("乐谱索引无效,请重新导入。"); + if (!value.parts.length || value.parts.length > MAX_PARTS) throw new Error("乐谱索引的声部数量无效。"); + if (!value.measures.length || value.measures.length > MAX_MEASURES) throw new Error("乐谱索引的小节数量无效。"); + const ids = new Set(); + const parts: PartInfo[] = value.parts.map((part) => { + if (!isRecord(part)) throw new Error("乐谱索引的声部无效。"); + const id = str(part.id, 80, "声部标识"); + if (ids.has(id)) throw new Error("声部标识重复。"); + ids.add(id); + return { + id, + name: str(part.name, 200, "声部名称"), + abbreviation: str(part.abbreviation, 200, "声部缩写"), + staves: int(part.staves, 1, 8, "谱表数"), + measures: int(part.measures, 1, MAX_MEASURES, "小节数"), + }; + }); + const measures: MeasureInfo[] = value.measures.map((measure) => { + if (!isRecord(measure)) throw new Error("乐谱索引的小节无效。"); + return { n: str(measure.n, 16, "小节号"), t: str(measure.t, 16, "拍号") }; + }); + if (parts.some((part) => part.measures > measures.length)) throw new Error("乐谱索引的小节数量不一致。"); + return { + title: str(value.title, 200, "标题"), + subtitle: str(value.subtitle, 200, "副标题"), + composer: str(value.composer, 200, "作曲"), + lyricist: str(value.lyricist, 200, "作词"), + arranger: str(value.arranger, 200, "编曲"), + rights: str(value.rights, 200, "版权"), + parts, + measures, + notes: int(value.notes, 1, 10_000_000, "音符数"), + warnings: value.warnings.slice(0, 20).map((item) => str(item, 200, "警告")), + }; +} + +function parseView(value: unknown, index: ScoreIndex): ViewSettings { + if (!isRecord(value) || !Array.isArray(value.hiddenParts)) throw new Error("显示设置无效。"); + const hidden = value.hiddenParts.map((id) => str(id, 80, "隐藏声部")); + if (new Set(hidden).size !== hidden.length || hidden.some((id) => !index.parts.some((part) => part.id === id))) + throw new Error("隐藏的声部不存在。"); + if (hidden.length >= index.parts.length) throw new Error("至少显示一个声部。"); + if (typeof value.layout !== "string" || !Object.hasOwn(LAYOUTS, value.layout)) throw new Error("版式无效。"); + if (typeof value.zoom !== "number" || !Number.isFinite(value.zoom) || value.zoom < ZOOM_MIN || value.zoom > ZOOM_MAX) + throw new Error("缩放比例无效。"); + if (!["system", "every", "off"].includes(value.measureNumbers as string)) throw new Error("小节号设置无效。"); + if (typeof value.credits !== "boolean" || typeof value.partNames !== "boolean") throw new Error("标题或声部名设置无效。"); + return { + hiddenParts: hidden, + layout: value.layout as Layout, + zoom: Math.round(value.zoom * 100) / 100, + transpose: int(value.transpose, -12, 12, "移调"), + measureNumbers: value.measureNumbers as MeasureNumbers, + credits: value.credits, + partNames: value.partNames, + tempo: int(value.tempo, 25, 200, "回放速度"), + }; +} + +function parseBeat(value: unknown): Beat { + if (value === null) return null; + if (!Array.isArray(value) || value.length !== 2) throw new Error("拍位无效。"); + const [n, d] = value; + int(n, 0, 1_000_000, "拍位"); + int(d, 1, 1_000_000, "拍位"); + return [n as number, d as number]; +} + +function anchor(index: ScoreIndex, partId: unknown, measureIndex: unknown) { + const part = index.parts.find((item) => item.id === partId); + if (!part || typeof measureIndex !== "number" || !Number.isInteger(measureIndex) || measureIndex < 0 || measureIndex >= part.measures) + throw new Error("批注的小节或声部不存在。"); + return { partId: part.id, measureIndex }; +} + export function validateScore(value: unknown): ScoreProject { return parseScore(value, false); } +/** 恢复工作副本时放宽:允许尚未填写的空白批注;结构、锚点与大小检查不变。 */ export function restoreScoreDraft(value: unknown): ScoreProject { return parseScore(value, true); } function parseScore(value: unknown, draft: boolean): ScoreProject { - if (!isRecord(value) || !isRecord(value.source)) - throw new Error("项目需要乐谱原文件。"); - const input = value.source; - if (input.kind !== "xml" && input.kind !== "mxl") - throw new Error("不支持的原文件类型。"); - const source: SourceFile = { - fileName: boundedText(input.fileName, 160), - kind: input.kind, - originalBase64: boundedText(input.originalBase64, 270000), - xml: boundedText(input.xml, MAX_XML_BYTES), - }; - if (!source.fileName.trim() || /[\\/\u0000-\u001f]/.test(source.fileName)) - throw new Error("原文件名无效。"); - const bytes = originalBytes(source); - if ( - source.kind === "xml" && - new TextDecoder("utf-8", { fatal: true }).decode(bytes) !== source.xml - ) - throw new Error("MusicXML 原文件与审阅内容不一致。"); - const info = inspectScore(source.xml); - if ( - typeof value.partId !== "string" || - (value.partId !== "" && !info.parts.some((p) => p.id === value.partId)) - ) - throw new Error("显示声部不存在。"); - if (value.layout !== "page" && value.layout !== "continuous") - throw new Error("版式无效。"); - if ( - typeof value.zoom !== "number" || - ![0.7, 0.85, 1, 1.15, 1.3].includes(value.zoom) - ) - throw new Error("缩放级别无效。"); - if (!Array.isArray(value.annotations) || value.annotations.length > 100) - throw new Error("最多保留 100 条批注。"); - const focus = - value.focus === undefined - ? { partId: info.parts[0].id, measureIndex: 0 } - : value.focus; - if ( - !isRecord(focus) || - typeof focus.partId !== "string" || - typeof focus.measureIndex !== "number" || - !Number.isInteger(focus.measureIndex) || - focus.measureIndex < 0 || - !info.parts.some( - (part) => - part.id === focus.partId && - Number(focus.measureIndex) < part.measures.length, - ) - ) - throw new Error("当前审阅小节无效。"); + if (!isRecord(value)) throw new Error("项目内容无效。"); + const source = parseSource(value.source); + const index = parseIndex(value.index); + const view = parseView(value.view, index); + if (!Array.isArray(value.annotations) || value.annotations.length > MAX_ANNOTATIONS) + throw new Error(`最多保留 ${MAX_ANNOTATIONS} 条批注。`); const ids = new Set(); - const annotations = value.annotations.map((item) => { - if ( - !isRecord(item) || - typeof item.id !== "string" || - !/^[-\w]{1,80}$/.test(item.id) || - ids.has(item.id) || - typeof item.partId !== "string" || - typeof item.measureIndex !== "number" || - !Number.isInteger(item.measureIndex) || - typeof item.resolved !== "boolean" - ) + const annotations = value.annotations.map((item): Annotation => { + if (!isRecord(item) || typeof item.id !== "string" || !/^[-\w]{1,80}$/.test(item.id) || ids.has(item.id) || typeof item.resolved !== "boolean") throw new Error("批注结构无效。"); ids.add(item.id); - const part = info.parts.find((part) => part.id === item.partId); - if ( - !part || - item.measureIndex < 0 || - item.measureIndex >= part.measures.length - ) - throw new Error("批注的小节或声部不存在。"); - const text = boundedText(item.text, 1000); + if (typeof item.kind !== "string" || !Object.hasOwn(ANNOTATION_KINDS, item.kind)) throw new Error("批注类别无效。"); + const text = boundedText(item.text, MAX_NOTE_TEXT); if (!draft && !text.trim()) throw new Error("批注不能为空。"); + const createdAt = str(item.createdAt, 40, "批注时间"); + if (Number.isNaN(Date.parse(createdAt))) throw new Error("批注时间无效。"); return { id: item.id, - partId: item.partId, - measureIndex: item.measureIndex, + ...anchor(index, item.partId, item.measureIndex), + beat: parseBeat(item.beat), + kind: item.kind as AnnotationKind, text, resolved: item.resolved, + createdAt, }; }); - return { - source, - partId: value.partId, - layout: value.layout, - zoom: value.zoom, - annotations, - focus: { partId: focus.partId, measureIndex: focus.measureIndex }, - }; + if (!isRecord(value.focus)) throw new Error("当前审阅位置无效。"); + const focus = { ...anchor(index, value.focus.partId, value.focus.measureIndex), beat: parseBeat(value.focus.beat) }; + return { source, index, view, annotations, focus }; +} + +/** 按乐谱顺序排列批注:小节 → 拍位 → 声部顺序 → 创建时间。 */ +export function orderAnnotations(project: Pick): Annotation[] { + const partOrder = new Map(project.index.parts.map((part, i) => [part.id, i])); + const at = (beat: Beat) => (beat ? beat[0] / beat[1] : -1); + return [...project.annotations].sort( + (a, b) => + a.measureIndex - b.measureIndex || + at(a.beat) - at(b.beat) || + (partOrder.get(a.partId) ?? 0) - (partOrder.get(b.partId) ?? 0) || + a.createdAt.localeCompare(b.createdAt), + ); +} + +export function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + return btoa(binary); } diff --git a/workbenches/55-opensheetmusicdisplay/app/src/contracts/musicxml.ts b/workbenches/55-opensheetmusicdisplay/app/src/contracts/musicxml.ts index a615bf2..6ab9de3 100644 --- a/workbenches/55-opensheetmusicdisplay/app/src/contracts/musicxml.ts +++ b/workbenches/55-opensheetmusicdisplay/app/src/contracts/musicxml.ts @@ -1,140 +1,202 @@ +// ABOUTME: MusicXML 文本解码、安全检查与声部/小节索引;浏览器与 Node 共用。 import { XMLParser, XMLValidator } from "fast-xml-parser"; import { isRecord } from "./project.ts"; -export const MAX_XML_BYTES = 180_000; + +/** 解压或解码后的 MusicXML 文本上限(UTF-16 文件按字节计)。 */ +export const MAX_XML_BYTES = 64 * 1024 * 1024; +export const MAX_PARTS = 64; +export const MAX_MEASURES = 5000; +export const MAX_NOTES = 400_000; + export interface PartInfo { id: string; name: string; - measures: { index: number; number: string; notes: number }[]; + abbreviation: string; + staves: number; + measures: number; } -export interface ScoreInfo { +export interface MeasureInfo { + /** 乐谱中印的小节号(弱起、重复编号时与序号不同)。 */ + n: string; + /** 拍号,例如 "3/4";未声明时为空。 */ + t: string; +} +export interface ScoreIndex { title: string; + subtitle: string; composer: string; + lyricist: string; + arranger: string; + rights: string; parts: PartInfo[]; - noteCount: number; - measureCount: number; + measures: MeasureInfo[]; + notes: number; + /** 导入时被忽略的内容(外部图片、链接等),不影响渲染。 */ + warnings: string[]; } -export function safeXml(xml: string): Record { - if (new TextEncoder().encode(xml).length > MAX_XML_BYTES) - throw new Error("XML 超过 180 KB,请导出较短的乐谱。"); - if (//g, ""); - let depth = 0; - for (const match of cleaned.matchAll(/<(?:(?:"[^"]*"|'[^']*')|[^'">])*>/g)) { - const tag = match[0]; - if (tag.startsWith("")) depth++; - if (depth > 40) throw new Error("XML 层级过深。"); + +const ENCODINGS = new Set(["utf-8", "utf8", "utf-16", "utf-16le", "utf-16be", "iso-8859-1", "latin1", "windows-1252", "us-ascii", "ascii"]); + +/** 按 BOM 与 XML 声明解码;无法识别或包含非法字节时报错,不做静默替换。 */ +export function decodeXml(bytes: Uint8Array): string { + if (!bytes.length) throw new Error("文件为空。"); + if (bytes.length > MAX_XML_BYTES) throw new Error(`MusicXML 超过 ${MAX_XML_BYTES / 1048576} MB。`); + let label = "utf-8"; + if (bytes[0] === 0xff && bytes[1] === 0xfe) label = "utf-16le"; + else if (bytes[0] === 0xfe && bytes[1] === 0xff) label = "utf-16be"; + else if (bytes[0] === 0x3c && bytes[1] === 0x00) label = "utf-16le"; + else if (bytes[0] === 0x00 && bytes[1] === 0x3c) label = "utf-16be"; + else { + const head = new TextDecoder("latin1").decode(bytes.subarray(0, 200)); + const declared = /^\s*(?:|)?<\?xml[^>]*encoding\s*=\s*["']([\w.-]+)["']/i.exec(head)?.[1]?.toLowerCase(); + if (declared && !declared.startsWith("utf-16")) { + if (!ENCODINGS.has(declared)) throw new Error(`不支持的文本编码 ${declared},请导出 UTF-8 MusicXML。`); + label = declared; + } + } + try { + return new TextDecoder(label, { fatal: true }).decode(bytes); + } catch { + throw new Error("文件编码无效:请确认是完整的 MusicXML(UTF-8 或 UTF-16)。"); } +} + +/** + * 检查后解析 XML。标准的 MusicXML DOCTYPE(只引用公开 DTD、无内部子集)被接受且从不下载; + * 内部子集、ENTITY 声明与自定义实体被拒绝。外部图片与链接仅记为警告,渲染器不会加载它们。 + */ +export function safeXml(xml: string): { tree: Record; warnings: string[] } { + if (xml.length > MAX_XML_BYTES) + throw new Error(`MusicXML 超过 ${MAX_XML_BYTES / 1048576} MB。`); + const doctype = /[]*(\[)?/i.exec(xml); + if (doctype?.[1] || / { - if (++count > 30_000 || level > 45) - throw new Error("XML 结构超出支持范围。"); - if (Array.isArray(node)) { - for (const item of node) walk(item, level + 1); - } else if (isRecord(node)) { - for (const [key, value] of Object.entries(node)) { - if ( - /(?:^|:)(?:image|credit-image|link|script|iframe|object)$/i.test( - key, - ) || - (/^@_/.test(key) && /(?:href|src|schemaLocation)$/i.test(key)) || - /^@_(?:on[a-z]+|style)$/i.test(key) - ) - throw new Error("乐谱不能引用图片、链接、脚本或其他外部资源。"); - walk(value, level + 1); - } - } - }; - walk(parsed); - return parsed; + const warnings: string[] = []; + const images = (xml.match(/<(?:credit-)?image\b/g) ?? []).length; + const links = (xml.match(/ - v === undefined ? [] : Array.isArray(v) ? v : [v]; -const label = (v: unknown, fallback = "") => - typeof v === "string" - ? v.slice(0, 200) - : isRecord(v) && typeof v["#text"] === "string" - ? v["#text"].slice(0, 200) - : fallback; -export function inspectScore(xml: string): ScoreInfo { - const tree = safeXml(xml), - score = tree["score-partwise"]; - if (!isRecord(score)) - throw new Error( - "仅支持 score-partwise MusicXML;请在制谱软件中导出按声部组织的文件。", - ); + +const list = (v: unknown): unknown[] => (v === undefined || v === null ? [] : Array.isArray(v) ? v : [v]); +const text = (v: unknown, fallback = ""): string => { + if (typeof v === "string") return v.replace(/\s+/g, " ").trim().slice(0, 200) || fallback; + if (Array.isArray(v)) return text(v[0], fallback); + if (isRecord(v) && typeof v["#text"] === "string") return text(v["#text"], fallback); + return fallback; +}; + +/** 读取 score-partwise 的声部、小节号、拍号与著录信息。 */ +export function inspectScore(xml: string): ScoreIndex { + const { tree, warnings } = safeXml(xml); + if (isRecord(tree["score-timewise"])) + throw new Error("这是按时间组织(score-timewise)的 MusicXML;请在制谱软件中导出 score-partwise。"); + const score = tree["score-partwise"]; + if (!isRecord(score)) throw new Error("不是 MusicXML 乐谱:缺少 score-partwise 根元素。"); const partList = score["part-list"]; - if (!isRecord(partList)) throw new Error("乐谱缺少声部列表。"); + if (!isRecord(partList)) throw new Error("乐谱缺少声部列表(part-list)。"); const declarations = list(partList["score-part"]); const bodies = list(score.part); - if ( - !declarations.length || - declarations.length > 12 || - bodies.length !== declarations.length - ) - throw new Error("乐谱需要 1–12 个完整声部。"); + if (!declarations.length) throw new Error("乐谱没有声部。"); + if (declarations.length > MAX_PARTS) throw new Error(`最多支持 ${MAX_PARTS} 个声部。`); + const byId = new Map>(); + for (const body of bodies) + if (isRecord(body) && typeof body["@_id"] === "string") byId.set(body["@_id"], body); const seen = new Set(); - let noteCount = 0; - const parts = declarations.map((declaration) => { + let notes = 0; + let longest: unknown[] = []; + const parts: PartInfo[] = declarations.map((declaration) => { if (!isRecord(declaration)) throw new Error("声部声明无效。"); const id = declaration["@_id"]; - if (typeof id !== "string" || !/^[-\w.]{1,80}$/.test(id) || seen.has(id)) + if (typeof id !== "string" || !/^[-\w.:]{1,80}$/.test(id) || seen.has(id)) throw new Error("声部标识无效或重复。"); seen.add(id); - const matches = bodies.filter( - (body) => isRecord(body) && body["@_id"] === id, - ); - if (matches.length !== 1 || !isRecord(matches[0])) - throw new Error("声部内容与声明不一致。"); - const measures = list(matches[0].measure); - if (!measures.length || measures.length > 200) - throw new Error("每个声部需要 1–200 个小节。"); + const body = byId.get(id); + if (!body) throw new Error(`声部 ${id} 已声明但没有内容。`); + const measures = list(body.measure); + if (!measures.length) throw new Error(`声部 ${id} 没有小节。`); + if (measures.length > MAX_MEASURES) throw new Error(`每个声部最多 ${MAX_MEASURES} 小节。`); + if (measures.length > longest.length) longest = measures; + let staves = 1; + for (const measure of measures) { + if (!isRecord(measure)) throw new Error("小节内容无效。"); + notes += list(measure.note).length; + for (const attributes of list(measure.attributes)) + if (isRecord(attributes) && attributes.staves !== undefined) { + const count = Number.parseInt(text(attributes.staves), 10); + if (Number.isInteger(count) && count > staves && count <= 8) staves = count; + } + } + if (notes > MAX_NOTES) throw new Error(`乐谱超过 ${MAX_NOTES.toLocaleString("en-US")} 个音符/休止符。`); return { id, - name: label(declaration["part-name"], id), - measures: measures.map((measure, index) => { - if (!isRecord(measure)) throw new Error("小节内容无效。"); - const notes = list(measure.note); - noteCount += notes.length; - return { - index, - number: label(measure["@_number"], String(index + 1)), - notes: notes.length, - }; - }), + name: text(declaration["part-name"], id), + abbreviation: text(declaration["part-abbreviation"]), + staves, + measures: measures.length, }; }); - if (!noteCount || noteCount > 4000) - throw new Error("乐谱需要 1–4000 个音符或休止符。"); - const work = isRecord(score.work) ? score.work : {}, - identification = isRecord(score.identification) ? score.identification : {}; - const composer = list(identification.creator).find( - (item) => isRecord(item) && item["@_type"] === "composer", - ); + if (!notes) throw new Error("乐谱没有音符或休止符。"); + let time = ""; + const measures: MeasureInfo[] = longest.map((measure, index) => { + const record = isRecord(measure) ? measure : {}; + for (const attributes of list(record.attributes)) { + if (!isRecord(attributes)) continue; + const signature = list(attributes.time)[0]; + if (isRecord(signature)) { + const beats = text(signature.beats), + type = text(signature["beat-type"]); + if (/^\d+(?:\+\d+)*$/.test(beats) && /^\d+$/.test(type)) time = `${beats}/${type}`; + } + } + return { n: text(record["@_number"], String(index + 1)).slice(0, 16), t: time }; + }); + const work = isRecord(score.work) ? score.work : {}; + const identification = isRecord(score.identification) ? score.identification : {}; + const creator = (type: string) => + text(list(identification.creator).find((item) => isRecord(item) && item["@_type"] === type)); + const credit = (type: string) => { + for (const item of list(score.credit)) + if (isRecord(item) && list(item["credit-type"]).some((value) => text(value) === type)) + return text(list(item["credit-words"])[0]); + return ""; + }; return { - title: label( - work["work-title"], - label(score["movement-title"], "未命名乐谱"), - ), - composer: label(composer), + title: text(work["work-title"]) || text(score["movement-title"]) || credit("title") || "未命名乐谱", + subtitle: text(work["work-title"]) ? text(score["movement-title"]) || credit("subtitle") : credit("subtitle"), + composer: creator("composer") || credit("composer"), + lyricist: creator("lyricist") || creator("poet") || credit("lyricist"), + arranger: creator("arranger"), + rights: text(list(identification.rights)[0]) || credit("rights"), parts, - noteCount, - measureCount: Math.max(...parts.map((part) => part.measures.length)), + measures, + notes, + warnings, }; } + +/** 拍位(小节内的全音符分数)转为第几拍,例如 3/4 拍中 1/4 → 2。 */ +export function beatLabel(beat: readonly [number, number] | null, time: string): string { + if (!beat) return ""; + const type = Number(time.split("/")[1]) || 4; + const value = 1 + (beat[0] / beat[1]) * type; + return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""); +} diff --git a/workbenches/55-opensheetmusicdisplay/app/src/contracts/pdf.ts b/workbenches/55-opensheetmusicdisplay/app/src/contracts/pdf.ts new file mode 100644 index 0000000..f8b2b48 --- /dev/null +++ b/workbenches/55-opensheetmusicdisplay/app/src/contracts/pdf.ts @@ -0,0 +1,85 @@ +// ABOUTME: 最小 PDF 1.4 写入器:每页嵌入一张 JPEG(DCTDecode),用于导出带批注的谱面校样。 +export interface PdfPage { + jpeg: Uint8Array; + /** 图像像素尺寸。 */ + width: number; + height: number; + /** 页面尺寸(pt,1/72 英寸)。 */ + pageWidth: number; + pageHeight: number; +} + +export function jpegSize(bytes: Uint8Array): { width: number; height: number } { + if (bytes[0] !== 0xff || bytes[1] !== 0xd8) throw new Error("不是 JPEG 图像。"); + let offset = 2; + while (offset + 9 < bytes.length) { + if (bytes[offset] !== 0xff) throw new Error("JPEG 结构无效。"); + const marker = bytes[offset + 1]; + const length = (bytes[offset + 2] << 8) | bytes[offset + 3]; + if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) + return { height: (bytes[offset + 5] << 8) | bytes[offset + 6], width: (bytes[offset + 7] << 8) | bytes[offset + 8] }; + offset += 2 + length; + } + throw new Error("JPEG 缺少尺寸信息。"); +} + +export function buildPdf(pages: PdfPage[], info: { title: string; subject?: string }): Uint8Array { + if (!pages.length) throw new Error("没有可导出的页面。"); + const encoder = new TextEncoder(); + const chunks: Uint8Array[] = []; + const offsets: number[] = []; + let length = 0; + const push = (part: string | Uint8Array) => { + const bytes = typeof part === "string" ? encoder.encode(part) : part; + chunks.push(bytes); + length += bytes.length; + }; + // PDF 文本字符串用 UTF-16BE 十六进制,标题可含中文。 + const pdfText = (value: string) => { + let hex = "FEFF"; + for (const unit of value.slice(0, 200)) { + const code = unit.codePointAt(0)!; + if (code > 0xffff) { + const v = code - 0x10000; + hex += (0xd800 + (v >> 10)).toString(16).padStart(4, "0") + (0xdc00 + (v & 0x3ff)).toString(16).padStart(4, "0"); + } else hex += code.toString(16).padStart(4, "0"); + } + return `<${hex.toUpperCase()}>`; + }; + const object = (id: number, body: string | (string | Uint8Array)[]) => { + offsets[id] = length; + push(`${id} 0 obj\n`); + for (const part of Array.isArray(body) ? body : [body]) push(part); + push("\nendobj\n"); + }; + push("%PDF-1.4\n%âãÏÓ\n"); + const pageIds = pages.map((_, i) => 4 + i * 3); + object(1, "<< /Type /Catalog /Pages 2 0 R >>"); + object(2, `<< /Type /Pages /Kids [${pageIds.map((id) => `${id} 0 R`).join(" ")}] /Count ${pages.length} >>`); + object(3, `<< /Title ${pdfText(info.title)} ${info.subject ? `/Subject ${pdfText(info.subject)} ` : ""}/Producer (Floatboat score review) >>`); + pages.forEach((page, i) => { + const id = pageIds[i]; + const w = page.pageWidth.toFixed(2), + h = page.pageHeight.toFixed(2); + const content = `q ${w} 0 0 ${h} 0 0 cm /Im0 Do Q`; + object(id, `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${w} ${h}] /Resources << /XObject << /Im0 ${id + 2} 0 R >> >> /Contents ${id + 1} 0 R >>`); + object(id + 1, `<< /Length ${content.length} >>\nstream\n${content}\nendstream`); + object(id + 2, [ + `<< /Type /XObject /Subtype /Image /Width ${page.width} /Height ${page.height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${page.jpeg.length} >>\nstream\n`, + page.jpeg, + "\nendstream", + ]); + }); + const count = 4 + pages.length * 3; + const xref = length; + push(`xref\n0 ${count}\n0000000000 65535 f \n`); + for (let id = 1; id < count; id++) push(`${String(offsets[id]).padStart(10, "0")} 00000 n \n`); + push(`trailer\n<< /Size ${count} /Root 1 0 R /Info 3 0 R >>\nstartxref\n${xref}\n%%EOF\n`); + const out = new Uint8Array(length); + let at = 0; + for (const chunk of chunks) { + out.set(chunk, at); + at += chunk.length; + } + return out; +} diff --git a/workbenches/55-opensheetmusicdisplay/app/src/contracts/report.ts b/workbenches/55-opensheetmusicdisplay/app/src/contracts/report.ts index dbcd3eb..7d816a2 100644 --- a/workbenches/55-opensheetmusicdisplay/app/src/contracts/report.ts +++ b/workbenches/55-opensheetmusicdisplay/app/src/contracts/report.ts @@ -1,35 +1,51 @@ -import { validateScore, originalBytes, type ScoreProject } from "./domain.ts"; -import { inspectScore } from "./musicxml.ts"; -export async function reviewReport(project: ScoreProject, title: string) { - const info = inspectScore(project.source.xml); - const data = validateScore(project), - bytes = originalBytes(data.source), - digest = await crypto.subtle.digest("SHA-256", new Uint8Array(bytes)); +// ABOUTME: 批注 JSON 导出:原件哈希、谱面著录、显示设置与按乐谱顺序编号的批注(含小节号、拍位)。 +import { ANNOTATION_KINDS, orderAnnotations, validateScore, type ScoreProject } from "./domain.ts"; +import { beatLabel } from "./musicxml.ts"; + +export function reviewReport(value: ScoreProject, title: string, exportedAt = new Date().toISOString()) { + const project = validateScore(value); + const { index } = project; return { format: "floatboat-score-review", - version: 1, + version: 2, title, - exportedAt: new Date().toISOString(), - source: { - fileName: data.source.fileName, - kind: data.source.kind, - bytes: bytes.length, - sha256: Array.from(new Uint8Array(digest), (byte) => - byte.toString(16).padStart(2, "0"), - ).join(""), + exportedAt, + source: { fileName: project.source.fileName, kind: project.source.kind, bytes: project.source.size, sha256: project.source.id }, + score: { + title: index.title, + subtitle: index.subtitle, + composer: index.composer, + lyricist: index.lyricist, + arranger: index.arranger, + rights: index.rights, + parts: index.parts.map(({ id, name, staves, measures }) => ({ id, name, staves, measures })), + measures: index.measures.length, + notes: index.notes, }, - score: info, - view: { - partId: data.partId, - layout: data.layout, - zoom: data.zoom, - focus: data.focus, + view: project.view, + summary: { + total: project.annotations.length, + open: project.annotations.filter((note) => !note.resolved).length, + resolved: project.annotations.filter((note) => note.resolved).length, }, - annotations: data.annotations.map((note) => ({ - ...note, - partName: info.parts.find((part) => part.id === note.partId)?.name, - measureNumber: info.parts.find((part) => part.id === note.partId) - ?.measures[note.measureIndex].number, - })), + annotations: orderAnnotations(project).map((note, i) => { + const measure = index.measures[note.measureIndex]; + return { + number: i + 1, + id: note.id, + partId: note.partId, + partName: index.parts.find((part) => part.id === note.partId)?.name ?? note.partId, + measureIndex: note.measureIndex, + measureNumber: measure?.n ?? String(note.measureIndex + 1), + beat: note.beat ? beatLabel(note.beat, measure?.t ?? "") : null, + offset: note.beat ? `${note.beat[0]}/${note.beat[1]}` : null, + kind: note.kind, + kindLabel: ANNOTATION_KINDS[note.kind], + text: note.text, + resolved: note.resolved, + createdAt: note.createdAt, + }; + }), }; } +export type ReviewReport = ReturnType; diff --git a/workbenches/55-opensheetmusicdisplay/app/src/contracts/samples.ts b/workbenches/55-opensheetmusicdisplay/app/src/contracts/samples.ts new file mode 100644 index 0000000..9be399f --- /dev/null +++ b/workbenches/55-opensheetmusicdisplay/app/src/contracts/samples.ts @@ -0,0 +1,130 @@ +// ABOUTME: 内置示例乐谱:公有领域旋律(本项目编配与录入)和本项目原创练习曲,以及首开时的种子项目。 +import { buildScore } from "./score-builder.ts"; +import { inspectScore, type ScoreIndex } from "./musicxml.ts"; +import { newProject, type ScoreProject } from "./domain.ts"; +import { sha256Hex } from "./sha256.ts"; + +const minuetRight = [ + "D5:q G4:e A4 B4 C5", "D5:q G4 G4", "E5:q C5:e D5 E5 F#5", "G5:q G4 G4", + "C5:q D5:e C5 B4 A4", "B4:q C5:e B4 A4 G4", "F#4:q G4:e A4 B4 G4", "A4:h.", + "D5:q G4:e A4 B4 C5", "D5:q G4 G4", "E5:q C5:e D5 E5 F#5", "G5:q G4 G4", + "C5:q D5:e C5 B4 A4", "B4:q C5:e B4 A4 G4", "A4:q B4:e A4 G4 F#4", "G4:h.", + "B5:q G5:e A5 B5 G5", "A5:q D5:e E5 F#5 D5", "G5:q E5:e F#5 G5 D5", "C#5:q B4:e C#5 A4:q", + "A4:e B4 C#5 D5 E5 F#5", "G5:q F#5 E5", "F#5:q A4 C#5", "D5:h.", + "D5:q G4:e F#4 G4:q", "E5:q G4:e F#4 G4:q", "D5:q C5 B4", "A4:e G4 F#4 G4 A4:q", + "D4:e E4 F#4 G4 A4 B4", "C5:q B4 A4", "B4:e D5 G4:q F#4", "G4:h.", +].join("|"); +const minuetLeft = [ + "G3:h +B3 +D4 A3:q", "B3:h.", "C4:h.", "B3:h.", "A3:h.", "G3:h.", "D4:h B3:q", "D4:q D3:e C4 B3 A3", + "B3:h A3:q", "G3:q B3 G3", "C4:h.", "B3:q C4:e B3 A3 G3", "A3:h F#3:q", "G3:h B3:q", "C4:q D4 D3", "G3:h G2:q", + "G3:h.", "F#3:h.", "E3:h G3:q", "A3:h A2:q", "A3:h.", "B3:h C#4:q", "D4:q F#3 A3", "D4:q D3 C4", + "B3:h.", "C4:h.", "B3:q A3 G3", "D4:h.", "D3:h.", "E3:h F#3:q", "G3:h D3:q", "G2:h.", +].join("|"); + +export const minuetXml = buildScore({ + title: "Minuet in G major", + subtitle: "BWV Anh. 114", + composer: "Christian Petzold (c. 1725)", + arranger: "Left hand simplified, Floatboat edition", + rights: "Melody public domain; this edition CC0 1.0", + fifths: 1, + beats: 3, + beatType: 4, + tempo: { words: "Moderato", bpm: 112 }, + repeats: { forward: [17], backward: [16, 32] }, + dynamics: { 1: "mf", 17: "p", 25: "f" }, + parts: [ + { id: "P1", name: "Piano", abbreviation: "Pno.", program: 1, staves: [ { clef: "G", music: minuetRight }, { clef: "F", music: minuetLeft } ] }, + ], +}); + +const odeMelody = [ + "F#5:q F#5 G5 A5", "A5:q G5 F#5 E5", "D5:q D5 E5 F#5", "F#5:q. E5:e E5:h", + "F#5:q F#5 G5 A5", "A5:q G5 F#5 E5", "D5:q D5 E5 F#5", "E5:q. D5:e D5:h", + "E5:q E5 F#5 D5", "E5:q F#5:e G5 F#5:q D5", "E5:q F#5:e G5 F#5:q E5", "D5:q E5 A4:h", + "F#5:q F#5 G5 A5", "A5:q G5 F#5 E5", "D5:q D5 E5 F#5", "E5:q. D5:e D5:h", +]; +const odeSecond = [ + "D5:q D5 E5 F#5", "E5:q E5 D5 C#5", "A4:q A4 C#5 D5", "D5:q. C#5:e C#5:h", + "D5:q D5 E5 F#5", "E5:q E5 D5 C#5", "A4:q A4 C#5 D5", "C#5:q. A4:e A4:h", + "C#5:q C#5 D5 A4", "C#5:q D5:e E5 D5:q A4", "C#5:q D5:e E5 D5:q C#5", "A4:q C#5 E4:h", + "D5:q D5 E5 F#5", "E5:q E5 D5 C#5", "A4:q A4 C#5 D5", "C#5:q. A4:e A4:h", +]; +const odeViola = [ + "F#4:h A4", "C#4:h E4", "F#4:h A4", "A4:h G4", "F#4:h A4", "C#4:h E4", "F#4:h A4", "G4:h F#4", + "A4:h A4", "A4:h F#4", "A4:h G4", "F#4:h E4", "F#4:h A4", "C#4:h E4", "F#4:h A4", "G4:h F#4", +]; +const odeCello = [ + "D3:h F#3", "A2:h C#3", "D3:h F#3", "A2:h A2", "D3:h F#3", "A2:h C#3", "D3:h F#3", "A2:h D3", + "A2:h C#3", "A2:h D3", "A2:h C#3", "D3:h A2", "D3:h F#3", "A2:h C#3", "D3:h F#3", "A2:h D2", +]; + +export const odeXml = buildScore({ + title: "Ode to Joy", + subtitle: "Theme from Symphony No. 9, Op. 125", + composer: "Ludwig van Beethoven (1824)", + arranger: "String quartet arrangement, Floatboat edition", + rights: "Melody public domain; this arrangement CC0 1.0", + fifths: 2, + beats: 4, + beatType: 4, + tempo: { words: "Allegro assai", bpm: 100 }, + repeats: { forward: [9], backward: [16] }, + dynamics: { 1: "p", 9: "mf", 13: "f" }, + parts: [ + { id: "P1", name: "Violin I", abbreviation: "Vln. I", program: 41, staves: [{ clef: "G", music: odeMelody.join("|") }] }, + { id: "P2", name: "Violin II", abbreviation: "Vln. II", program: 41, staves: [{ clef: "G", music: odeSecond.join("|") }] }, + { id: "P3", name: "Viola", abbreviation: "Vla.", program: 42, staves: [{ clef: "C", music: odeViola.join("|") }] }, + { id: "P4", name: "Violoncello", abbreviation: "Vc.", program: 43, staves: [{ clef: "F", music: odeCello.join("|") }] }, + ], +}); + +const morningSteps = [ + ["C", "E", "G", "E"], ["D", "F", "A", "F"], ["E", "G", "B", "G"], ["F", "A", "C", "A"], + ["G", "B", "D", "B"], ["A", "C", "E", "C"], ["G", "F", "D", "B"], ["C", "E", "G", "C"], +]; +export const morningXml = buildScore({ + title: "Morning Lines", + composer: "Floatboat original study", + rights: "Original demonstration score; CC0 1.0", + fifths: 0, + beats: 4, + beatType: 4, + parts: [ + { id: "P1", name: "Flute", abbreviation: "Fl.", program: 74, staves: [{ clef: "G", music: morningSteps.map((m) => m.map((s, i) => `${s}5${i ? "" : ":q"}`).join(" ")).join("|") }] }, + { id: "P2", name: "Cello", abbreviation: "Vc.", program: 43, staves: [{ clef: "F", music: morningSteps.map((m) => `${m[0]}3:w`).join("|") }] }, + ], +}); + +export interface Sample { + key: string; + fileName: string; + label: string; + note: string; + xml: string; +} +export const SAMPLES: Sample[] = [ + { key: "minuet", fileName: "petzold-minuet-in-g.musicxml", label: "小步舞曲 G 大调(钢琴)", note: "Petzold,BWV Anh. 114 · 32 小节,两段反复", xml: minuetXml }, + { key: "ode", fileName: "beethoven-ode-to-joy-quartet.musicxml", label: "欢乐颂(弦乐四重奏)", note: "贝多芬第九交响曲主题 · 4 声部 16 小节", xml: odeXml }, + { key: "morning", fileName: "morning-lines.musicxml", label: "Morning Lines(长笛与大提琴)", note: "本项目原创练习曲 · 8 小节", xml: morningXml }, +]; + +const encoder = new TextEncoder(); +export function sampleSource(sample: Sample): { bytes: Uint8Array; id: string; index: ScoreIndex } { + const bytes = encoder.encode(sample.xml); + return { bytes, id: sha256Hex(bytes), index: inspectScore(sample.xml) }; +} +export function sampleProject(sample: Sample): ScoreProject { + const { bytes, id, index } = sampleSource(sample); + return newProject({ id, fileName: sample.fileName, kind: "xml", size: bytes.length }, index); +} + +/** 首开时的审校对象:小步舞曲,附两条示范批注。 */ +export function seedProject(): ScoreProject { + const project = sampleProject(SAMPLES[0]); + project.annotations = [ + { id: "seed-turn", partId: "P1", measureIndex: 7, beat: null, kind: "articulation", text: "第一段结尾:确认是否加颤音(各版本不一),排练前与演奏者统一。", resolved: false, createdAt: "2026-10-29T09:00:00.000Z" }, + { id: "seed-csharp", partId: "P1", measureIndex: 19, beat: [0, 1], kind: "pitch", text: "转入 D 大调:升 C 需在校样中清晰可见。", resolved: true, createdAt: "2026-10-29T09:05:00.000Z" }, + ]; + return project; +} diff --git a/workbenches/55-opensheetmusicdisplay/app/src/contracts/score-builder.ts b/workbenches/55-opensheetmusicdisplay/app/src/contracts/score-builder.ts new file mode 100644 index 0000000..6ff5dea --- /dev/null +++ b/workbenches/55-opensheetmusicdisplay/app/src/contracts/score-builder.ts @@ -0,0 +1,138 @@ +// ABOUTME: 用简短记谱串生成标准 score-partwise MusicXML,供内置示例与测试使用;会检查每小节时值。 +// +// 记谱串:小节以 "|" 分隔,音符以空格分隔。 +// D5:q 音名+变音(#/b)+八度:时值(w h q e s,加 . 为附点),时值省略时沿用上一个 +// +B3 与前一个音同时发声(和弦),时值相同 +// r:h 休止符 +// !p 在下一个音前写力度记号(p pp mp mf f ff sf) +const DIV = 4; +const DURATIONS: Record = { w: [16, "whole"], h: [8, "half"], q: [4, "quarter"], e: [2, "eighth"], s: [1, "16th"] }; + +export interface StaffSpec { + clef: "G" | "F" | "C"; + music: string; +} +export interface PartSpec { + id: string; + name: string; + abbreviation: string; + staves: StaffSpec[]; + /** MIDI 音色编号(1–128),供制谱软件识别,不影响本应用的回放。 */ + program?: number; +} +export interface ScoreSpec { + title: string; + subtitle?: string; + composer: string; + arranger?: string; + rights: string; + fifths: number; + beats: number; + beatType: number; + tempo?: { words: string; bpm: number }; + repeats?: { forward?: number[]; backward?: number[] }; + dynamics?: Record; + parts: PartSpec[]; +} + +const escape = (value: string) => value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + +function noteXml(token: string, state: { dur: string }, staff: number, voice: number, measure: number): { xml: string; ticks: number } { + let rest = token; + const chord = rest.startsWith("+"); + if (chord) rest = rest.slice(1); + const [pitch, given] = rest.split(":"); + if (given) state.dur = given; + const dotted = state.dur.endsWith("."); + const base = DURATIONS[state.dur.replace(".", "")]; + if (!base) throw new Error(`第 ${measure} 小节时值无效:${token}`); + const ticks = dotted ? base[0] * 1.5 : base[0]; + let body: string; + if (pitch === "r") body = ""; + else { + const match = /^([A-G])([#b]?)(\d)$/.exec(pitch); + if (!match) throw new Error(`第 ${measure} 小节音高无效:${token}`); + const alter = match[2] === "#" ? 1 : match[2] === "b" ? -1 : 0; + body = `${match[1]}${alter ? `${alter}` : ""}${match[3]}`; + } + const xml = + `${chord ? "" : ""}${body}${ticks}${voice}` + + `${base[1]}${dotted ? "" : ""}${staff}`; + return { xml, ticks: chord ? 0 : ticks }; +} + +function staffMeasure(source: string, staff: number, measure: number, length: number): string { + const state = { dur: "q" }; + let ticks = 0, + out = "", + pending = ""; + for (const token of source.trim().split(/\s+/).filter(Boolean)) { + if (token.startsWith("!")) { + pending = token.slice(1); + continue; + } + if (pending) { + out += `<${pending}/>${staff}`; + pending = ""; + } + const note = noteXml(token, state, staff, staff === 1 ? 1 : 5, measure); + out += note.xml; + ticks += note.ticks; + } + if (ticks !== length) throw new Error(`第 ${measure} 小节第 ${staff} 谱表时值为 ${ticks / DIV} 拍,应为 ${length / DIV} 拍。`); + return out; +} + +export function buildScore(spec: ScoreSpec): string { + const length = (spec.beats * 16 * DIV) / (spec.beatType * 4); + const measureCount = spec.parts[0].staves[0].music.split("|").length; + const clefLine = { G: 2, F: 4, C: 3 }; + const parts = spec.parts.map((part) => { + const staves = part.staves.map((staff) => staff.music.split("|")); + if (staves.some((lines) => lines.length !== measureCount)) throw new Error(`${part.name} 的小节数与其他声部不同。`); + let xml = ``; + for (let m = 0; m < measureCount; m++) { + xml += ``; + if (spec.repeats?.forward?.includes(m + 1)) xml += 'heavy-light'; + if (m === 0) { + xml += `${DIV}${spec.fifths}`; + if (part.staves.length > 1) xml += `${part.staves.length}`; + part.staves.forEach((staff, i) => { + xml += ` 1 ? ` number="${i + 1}"` : ""}>${staff.clef}${clefLine[staff.clef]}`; + }); + xml += ""; + if (spec.tempo && part === spec.parts[0]) + xml += `${escape(spec.tempo.words)}quarter${spec.tempo.bpm}`; + } + const dynamic = spec.dynamics?.[m + 1]; + part.staves.forEach((_, i) => { + if (i > 0) xml += `${length}`; + const music = dynamic && i === 0 && !staves[i][m].includes("!") ? `!${dynamic} ${staves[i][m]}` : staves[i][m]; + xml += staffMeasure(music, i + 1, m + 1, length); + }); + if (spec.repeats?.backward?.includes(m + 1)) + xml += 'light-heavy'; + else if (m === measureCount - 1) xml += 'light-heavy'; + xml += ""; + } + return xml + ""; + }); + const partList = spec.parts + .map( + (part) => + `${escape(part.name)}${escape(part.abbreviation)}` + + (part.program ? `${escape(part.name)}${part.program}` : "") + + "", + ) + .join(""); + return ( + '\n' + + '\n' + + `${escape(spec.title)}` + + (spec.subtitle ? `${escape(spec.subtitle)}` : "") + + `${escape(spec.composer)}` + + (spec.arranger ? `${escape(spec.arranger)}` : "") + + `${escape(spec.rights)}Floatboat score builder` + + `${partList}${parts.join("")}\n` + ); +} diff --git a/workbenches/55-opensheetmusicdisplay/app/src/contracts/seed.ts b/workbenches/55-opensheetmusicdisplay/app/src/contracts/seed.ts deleted file mode 100644 index f57428d..0000000 --- a/workbenches/55-opensheetmusicdisplay/app/src/contracts/seed.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { bytesToBase64, type ScoreProject } from "./domain.ts"; -const lines = [ - ["C", "E", "G", "E"], - ["D", "F", "A", "F"], - ["E", "G", "B", "G"], - ["F", "A", "C", "A"], - ["G", "B", "D", "B"], - ["A", "C", "E", "C"], - ["G", "F", "D", "B"], - ["C", "E", "G", "C"], -]; -const part = (id: string, low: boolean) => - `${lines.map((steps, index) => `${index === 0 ? `10${low ? "F" : "G"}${low ? 4 : 2}` : ""}${(low ? [steps[0]] : steps).map((step) => `${step}${low ? 3 : 5}${low ? 4 : 1}${low ? "whole" : "quarter"}`).join("")}${index === 7 ? 'light-heavy' : ""}`).join("")}`; -export const seedXml = ` -Morning LinesFloatboat original studyOriginal demonstration score; reusable under the repository license.FluteFl.CelloVc.${part("P1", false)}${part("P2", true)}`; -export const seed: ScoreProject = { - source: { - fileName: "morning-lines.musicxml", - kind: "xml", - originalBase64: bytesToBase64(new TextEncoder().encode(seedXml)), - xml: seedXml, - }, - partId: "", - layout: "page", - zoom: 0.85, - annotations: [], - focus: { partId: "P1", measureIndex: 0 }, -}; diff --git a/workbenches/55-opensheetmusicdisplay/app/src/contracts/sha256.ts b/workbenches/55-opensheetmusicdisplay/app/src/contracts/sha256.ts new file mode 100644 index 0000000..c22088b --- /dev/null +++ b/workbenches/55-opensheetmusicdisplay/app/src/contracts/sha256.ts @@ -0,0 +1,52 @@ +// ABOUTME: 同步 SHA-256(FIPS 180-4),用于在浏览器中为内置示例原件生成与服务端相同的内容标识。 +const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +export function sha256Hex(data: Uint8Array): string { + const length = data.length; + const padded = new Uint8Array(((length + 9 + 63) >> 6) << 6); + padded.set(data); + padded[length] = 0x80; + const view = new DataView(padded.buffer); + view.setUint32(padded.length - 8, Math.floor(length / 0x20000000), false); + view.setUint32(padded.length - 4, (length << 3) >>> 0, false); + const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]); + const w = new Uint32Array(64); + const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n)); + for (let offset = 0; offset < padded.length; offset += 64) { + for (let i = 0; i < 16; i++) w[i] = view.getUint32(offset + i * 4, false); + for (let i = 16; i < 64; i++) { + const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3); + const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10); + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0; + } + let [a, b, c, d, e, f, g, hh] = h; + for (let i = 0; i < 64; i++) { + const t1 = (hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]) >>> 0; + const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0; + hh = g; + g = f; + f = e; + e = (d + t1) >>> 0; + d = c; + c = b; + b = a; + a = (t1 + t2) >>> 0; + } + h[0] += a; + h[1] += b; + h[2] += c; + h[3] += d; + h[4] += e; + h[5] += f; + h[6] += g; + h[7] += hh; + } + return Array.from(h, (x) => x.toString(16).padStart(8, "0")).join(""); +} diff --git a/workbenches/55-opensheetmusicdisplay/app/src/score/ScoreView.tsx b/workbenches/55-opensheetmusicdisplay/app/src/score/ScoreView.tsx index 8a47ea3..a27b2b7 100644 --- a/workbenches/55-opensheetmusicdisplay/app/src/score/ScoreView.tsx +++ b/workbenches/55-opensheetmusicdisplay/app/src/score/ScoreView.tsx @@ -1,95 +1,288 @@ -import { memo, useEffect, useRef, useState } from "react"; +// ABOUTME: 谱面:持有 OSMD 实例,负责排版、光标、点击定位、批注图层与回放;通过 ref 暴露给工作台。 +import { forwardRef, memo, useEffect, useImperativeHandle, useRef, useState, type KeyboardEvent } from "react"; import type { OpenSheetMusicDisplay } from "opensheetmusicdisplay"; -import { configure, loadEngine, serializeScore } from "./engine"; -import type { ScoreProject } from "../contracts/domain"; -export const ScoreView = memo(function ScoreView({ - xml, - partId, - layout, - zoom, - onOutput, -}: { +import type { Beat, ViewSettings } from "../contracts/domain"; +import { + configure, + cursorPosition, + drawOverlay, + hitTest, + loadEngine, + moveCursorTo, + snapshotPages, + toSvgPoint, + type Hit, + type OverlayModel, + type PageSnapshot, +} from "./engine"; +import { Player } from "./playback"; + +export interface ScoreHandle { + step(direction: 1 | -1): void; + stepMeasure(direction: 1 | -1): void; + togglePlay(): Promise; + stop(): void; + snapshot(): PageSnapshot[]; + currentPage(): number; + revealFocus(): void; +} +export interface RenderState { + phase: "loading" | "ready" | "error"; + message: string; + pages: number; +} +interface Props { xml: string; - partId: string; - layout: ScoreProject["layout"]; - zoom: number; - onOutput: (svg: string) => void; -}) { - const host = useRef(null), - instance = useRef(null), - output = useRef(onOutput), - settings = useRef({ partId, layout, zoom }); - output.current = onOutput; - settings.current = { partId, layout, zoom }; - const [ready, setReady] = useState(false), - [error, setError] = useState(""); - useEffect(() => { - let active = true; - setReady(false); - setError(""); - output.current(""); - if (!host.current) return; - const container = document.createElement("div"); - host.current.replaceChildren(container); - let owned: OpenSheetMusicDisplay | undefined; - void loadEngine() - .then(async ({ OpenSheetMusicDisplay }) => { - if (!active) return; - const engine = new OpenSheetMusicDisplay(container, { - backend: "svg", - autoResize: false, - }); - owned = engine; - instance.current = engine; - engine.setLogLevel("error"); - await engine.load(xml); - if (!active) { - engine.clear(); - return; + view: ViewSettings; + overlay: OverlayModel; + onPick: (hit: Hit) => void; + onMarker: (id: string) => void; + onCursor: (position: { measureIndex: number; beat: Beat }) => void; + onPlayhead: (position: { measureIndex: number; beat: Beat } | null) => void; + onRender: (state: RenderState) => void; +} + +const motion = () => (matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth"); +const nextFrame = () => new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0))); + +export const ScoreView = memo( + forwardRef(function ScoreView({ xml, view, overlay, onPick, onMarker, onCursor, onPlayhead, onRender }, ref) { + const host = useRef(null); + const engine = useRef(null); + const player = useRef(null); + const latest = useRef({ view, overlay, onPick, onMarker, onCursor, onPlayhead, onRender }); + latest.current = { view, overlay, onPick, onMarker, onCursor, onPlayhead, onRender }; + const [ready, setReady] = useState(0); + const [playing, setPlaying] = useState(false); + const viewKey = JSON.stringify(view); + const rendered = useRef(""); + + const paint = () => { + const instance = engine.current; + if (instance && host.current) drawOverlay(host.current, instance, latest.current.overlay); + }; + const placeCursor = () => { + const instance = engine.current; + if (!instance?.cursor) return; + instance.cursor.show(); + const { focus } = latest.current.overlay; + moveCursorTo(instance, focus.measureIndex, focus.beat); + }; + const report = (phase: RenderState["phase"], message: string) => + latest.current.onRender({ phase, message, pages: host.current?.querySelectorAll("svg").length ?? 0 }); + + // 原件变化:新建实例并完整排版。 + useEffect(() => { + let active = true; + setReady(0); + if (!host.current) return; + const container = document.createElement("div"); + container.className = "osmd-root"; + host.current.replaceChildren(container); + let owned: OpenSheetMusicDisplay | undefined; + report("loading", "正在载入乐谱引擎…"); + void (async () => { + try { + const { OpenSheetMusicDisplay } = await loadEngine(); + if (!active) return; + owned = new OpenSheetMusicDisplay(container, { backend: "svg", autoResize: false }); + engine.current = owned; + owned.setLogLevel("error"); + report("loading", "正在解析 MusicXML…"); + await nextFrame(); + await owned.load(xml); + if (!active) return; + report("loading", "正在排版…"); + await nextFrame(); + if (!active) return; + await configure(owned, latest.current.view); + owned.render(); + rendered.current = JSON.stringify(latest.current.view); + placeCursor(); + paint(); + setReady((value) => value + 1); + report("ready", ""); + } catch (reason) { + if (active) report("error", reason instanceof Error && reason.message ? `乐谱无法渲染:${reason.message}` : "乐谱无法渲染,请检查文件。"); } - configure(engine, settings.current); - engine.render(); - output.current(serializeScore(container)); - setReady(true); - }) - .catch((reason) => { - if (active) { - setError( - reason instanceof Error - ? reason.message - : "乐谱渲染失败,请检查文件。", - ); - output.current(""); + })(); + return () => { + active = false; + player.current?.dispose(); + player.current = null; + setPlaying(false); + owned?.clear(); + container.remove(); + if (engine.current === owned) engine.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [xml]); + + // 显示设置变化:重排同一实例。 + useEffect(() => { + const instance = engine.current; + if (!ready || !instance || rendered.current === viewKey) return; + rendered.current = viewKey; + let active = true; + player.current?.stop(); + setPlaying(false); + report("loading", "正在重新排版…"); + void (async () => { + await nextFrame(); + if (!active) return; + try { + await configure(instance, latest.current.view); + instance.render(); + placeCursor(); + paint(); + report("ready", ""); + } catch (reason) { + report("error", reason instanceof Error ? `版式更新失败:${reason.message}` : "版式更新失败。"); } - }); - return () => { - active = false; - owned?.clear(); - container.remove(); - if (instance.current === owned) instance.current = null; + })(); + return () => { + active = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [viewKey, ready]); + + // 批注或当前位置变化:只重绘批注层;不在回放中时把光标带到当前位置。 + useEffect(() => { + if (!ready) return; + paint(); + if (!player.current?.playing) placeCursor(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [overlay, ready]); + + const cursorMoved = () => { + const instance = engine.current; + if (!instance) return; + const position = cursorPosition(instance); + if (position) latest.current.onCursor(position); }; - }, [xml]); - useEffect(() => { - if (!ready || !instance.current || !host.current) return; - try { - configure(instance.current, { partId, layout, zoom }); - instance.current.render(); - output.current(serializeScore(host.current)); - setError(""); - } catch (reason) { - setError(reason instanceof Error ? reason.message : "版式更新失败。"); - output.current(""); + + useImperativeHandle( + ref, + () => ({ + step(direction) { + const instance = engine.current; + if (!instance?.cursor) return; + instance.cursor.show(); + if (direction > 0) instance.cursor.next(); + else instance.cursor.previous(); + if (instance.cursor.Iterator.EndReached) instance.cursor.previous(); + cursorMoved(); + }, + stepMeasure(direction) { + const instance = engine.current; + if (!instance?.cursor) return; + const position = cursorPosition(instance); + const target = Math.max(0, Math.min(instance.Sheet.SourceMeasures.length - 1, (position?.measureIndex ?? 0) + direction)); + moveCursorTo(instance, target, null); + cursorMoved(); + }, + async togglePlay() { + const instance = engine.current; + if (!instance) return; + if (player.current?.playing) { + player.current.stop(); + setPlaying(false); + cursorMoved(); + latest.current.onPlayhead(null); + return; + } + player.current ??= new Player(instance, { + tempo: () => latest.current.view.tempo, + transpose: () => latest.current.view.transpose, + hidden: () => latest.current.view.hiddenParts, + onStep: () => { + const position = cursorPosition(instance); + latest.current.onPlayhead(position); + }, + onEnd: () => { + setPlaying(false); + latest.current.onPlayhead(null); + instance.cursor.previous(); + cursorMoved(); + }, + }); + setPlaying(true); + try { + await player.current.start(); + latest.current.onPlayhead(cursorPosition(instance)); + } catch { + setPlaying(false); + report("error", "浏览器拒绝了音频输出,请检查系统声音设置后重试。"); + } + }, + stop() { + if (player.current?.playing) { + player.current.stop(); + setPlaying(false); + latest.current.onPlayhead(null); + cursorMoved(); + } + }, + snapshot() { + const instance = engine.current; + if (!host.current || !instance) throw new Error("请等待乐谱渲染完成。"); + // 导出不带“选中”高亮:临时按未选中重绘批注层,快照后恢复。 + drawOverlay(host.current, instance, { ...latest.current.overlay, selectedId: null }); + try { + return snapshotPages(host.current); + } finally { + paint(); + } + }, + currentPage() { + const pages = Array.from(host.current?.querySelectorAll("svg") ?? []); + const viewport = host.current?.closest(".paper-scroll")?.getBoundingClientRect(); + if (!viewport) return 0; + let best = 0, + area = -1; + pages.forEach((page, i) => { + const rect = page.getBoundingClientRect(); + const w = Math.max(0, Math.min(rect.right, viewport.right) - Math.max(rect.left, viewport.left)); + const h = Math.max(0, Math.min(rect.bottom, viewport.bottom) - Math.max(rect.top, viewport.top)); + if (w * h > area) { + area = w * h; + best = i; + } + }); + return best; + }, + revealFocus() { + host.current?.querySelector(".fb-focus")?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: motion() }); + }, + }), + [], + ); + + function click(event: React.MouseEvent) { + const target = event.target as Element; + const mark = target.closest(".fb-mark"); + if (mark) { + latest.current.onMarker(mark.getAttribute("data-id") ?? ""); + return; + } + const svg = target.closest("svg"); + const instance = engine.current; + if (!svg || !instance || !host.current) return; + const page = Array.from(host.current.querySelectorAll("svg")).indexOf(svg as SVGSVGElement); + const point = toSvgPoint(svg as SVGSVGElement, event.clientX, event.clientY); + if (page < 0 || !point) return; + const hit = hitTest(instance, page, point.x, point.y); + if (hit) latest.current.onPick(hit); + } + function key(event: KeyboardEvent) { + const mark = (event.target as Element).closest?.(".fb-mark"); + if (mark && (event.key === "Enter" || event.key === " ")) { + event.preventDefault(); + latest.current.onMarker(mark.getAttribute("data-id") ?? ""); + } } - }, [partId, layout, zoom, ready]); - return ( - <> -

- {error || (!ready ? "正在排版乐谱…" : "乐谱已更新")} -

-
- - ); -}); + return ( +
+ ); + }), +); diff --git a/workbenches/55-opensheetmusicdisplay/app/src/score/engine.ts b/workbenches/55-opensheetmusicdisplay/app/src/score/engine.ts index ba5a777..4f089b6 100644 --- a/workbenches/55-opensheetmusicdisplay/app/src/score/engine.ts +++ b/workbenches/55-opensheetmusicdisplay/app/src/score/engine.ts @@ -1,5 +1,7 @@ -import type { OpenSheetMusicDisplay } from "opensheetmusicdisplay"; -import type { ScoreProject } from "../contracts/domain"; +// ABOUTME: OpenSheetMusicDisplay 的配置、几何查询(小节/拍位/点击命中)、光标定位、批注图层与导出用的页面快照。 +import type { GraphicalMeasure, IOSMDOptions, OpenSheetMusicDisplay, SourceMeasure } from "opensheetmusicdisplay"; +import type { Beat, Focus, ViewSettings } from "../contracts/domain"; + let pending: Promise | undefined; export function loadEngine() { return (pending ??= import("opensheetmusicdisplay").catch((error) => { @@ -7,79 +9,301 @@ export function loadEngine() { throw error; })); } -export function configure( - engine: OpenSheetMusicDisplay, - settings: Pick, -) { - engine.setOptions({ - pageFormat: settings.layout === "page" ? "A4_P" : "Endless", + +/** OSMD 单位到 SVG 用户坐标:VexFlow SVG 后端以 10 个用户单位表示 1 个 OSMD 单位。 */ +const UNIT = 10; +const SVG_NS = "http://www.w3.org/2000/svg"; +export const ACCENT = "#c2410c"; +const RESOLVED = "#5b6b7d"; +const SELECTED = "#1d4ed8"; + +export function engineOptions(view: ViewSettings): IOSMDOptions { + return { + backend: "svg", autoResize: false, - drawTitle: true, - drawComposer: true, - drawMeasureNumbers: true, + autoBeam: true, + pageFormat: view.layout === "page" ? "A4_P" : view.layout === "landscape" ? "A4_L" : "Endless", + renderSingleHorizontalStaffline: view.layout === "line", + pageBackgroundColor: "#FFFFFF", + drawTitle: view.credits, + drawSubtitle: view.credits, + drawComposer: view.credits, + drawLyricist: view.credits, + drawCredits: view.credits, + drawPartNames: view.partNames, + drawPartAbbreviations: view.partNames, + drawMeasureNumbers: view.measureNumbers !== "off", + drawMeasureNumbersOnlyAtSystemStart: view.measureNumbers === "system", + measureNumberInterval: 1, + useXMLMeasureNumbers: true, + // 保持小节与 MusicXML 一一对应,批注锚点不会落在合并的多小节休止上。 + autoGenerateMultipleRestMeasuresFromRestMeasures: false, + followCursor: true, + cursorsOptions: [{ type: 0, color: "#2563eb", alpha: 0.28, follow: true }], + }; +} + +/** 应用显示设置(移调时重新计算图形);调用方随后 render()。 */ +export async function configure(engine: OpenSheetMusicDisplay, view: ViewSettings): Promise { + engine.setOptions(engineOptions(view)); + engine.Zoom = view.zoom; + for (const instrument of engine.Sheet.Instruments) instrument.Visible = !view.hiddenParts.includes(instrument.IdString); + if (engine.Sheet.Transpose !== view.transpose) { + if (!engine.TransposeCalculator) { + const { TransposeCalculator } = await loadEngine(); + engine.TransposeCalculator = new TransposeCalculator(); + } + engine.Sheet.Transpose = view.transpose; + engine.updateGraphic(); + } +} + +export interface Box { + page: number; + x: number; + y: number; + w: number; + h: number; +} + +function measureIndexMap(engine: OpenSheetMusicDisplay): Map { + const map = new Map(); + engine.Sheet.SourceMeasures.forEach((measure, index) => map.set(measure, index)); + return map; +} +const pageOf = (engine: OpenSheetMusicDisplay, gm: GraphicalMeasure) => engine.GraphicSheet.MusicPages.indexOf(gm.ParentMusicSystem?.Parent); +const partOf = (gm: GraphicalMeasure | undefined) => gm?.ParentStaff?.ParentInstrument?.IdString; + +function partMeasures(engine: OpenSheetMusicDisplay, partId: string, measureIndex: number): GraphicalMeasure[] { + const source = engine.Sheet.SourceMeasures[measureIndex]; + if (!source) return []; + const list = engine.GraphicSheet.MeasureList; + const pick = (row: GraphicalMeasure[]) => row.filter((gm) => gm && partOf(gm) === partId && gm.ParentMusicSystem); + const direct = list[measureIndex]; + if (direct?.find(Boolean)?.parentSourceMeasure === source) return pick(direct); + for (const row of list) if (row.find(Boolean)?.parentSourceMeasure === source) return pick(row); + return []; +} + +/** 某声部某小节所有谱表的外框(SVG 用户坐标)。 */ +export function measureBox(engine: OpenSheetMusicDisplay, partId: string, measureIndex: number): Box | null { + const staves = partMeasures(engine, partId, measureIndex); + if (!staves.length) return null; + const first = staves[0].PositionAndShape; + const top = Math.min(...staves.map((gm) => gm.PositionAndShape.AbsolutePosition.y)); + const bottom = Math.max(...staves.map((gm) => gm.PositionAndShape.AbsolutePosition.y + gm.PositionAndShape.Size.height)); + return { page: pageOf(engine, staves[0]), x: first.AbsolutePosition.x * UNIT, y: top * UNIT, w: first.Size.width * UNIT, h: (bottom - top) * UNIT }; +} + +const sameBeat = (a: { Numerator: number; Denominator: number }, beat: [number, number]) => a.Numerator * beat[1] === beat[0] * a.Denominator; + +/** 拍位的横坐标:取该声部第一谱表上与拍位相同的音符位置;没有则取最近的。 */ +export function beatX(engine: OpenSheetMusicDisplay, partId: string, measureIndex: number, beat: Beat): number | null { + if (!beat) return null; + const gm = partMeasures(engine, partId, measureIndex)[0]; + if (!gm?.staffEntries.length) return null; + const target = beat[0] / beat[1]; + let best = gm.staffEntries[0]; + for (const entry of gm.staffEntries) { + if (sameBeat(entry.relInMeasureTimestamp, beat)) return entry.PositionAndShape.AbsolutePosition.x * UNIT; + if (Math.abs(entry.relInMeasureTimestamp.RealValue - target) < Math.abs(best.relInMeasureTimestamp.RealValue - target)) best = entry; + } + return best.PositionAndShape.AbsolutePosition.x * UNIT; +} + +export interface Hit extends Focus { + /** 点击是否落在音符附近(用于默认按拍位锚定)。 */ + nearNote: boolean; +} +/** 页面 SVG 用户坐标 → 最近的小节、声部与拍位。 */ +export function hitTest(engine: OpenSheetMusicDisplay, page: number, x: number, y: number): Hit | null { + const ux = x / UNIT, + uy = y / UNIT; + const indexes = measureIndexMap(engine); + let best: { gm: GraphicalMeasure; distance: number } | null = null; + for (const row of engine.GraphicSheet.MeasureList) + for (const gm of row) { + if (!gm?.ParentMusicSystem || pageOf(engine, gm) !== page) continue; + const box = gm.PositionAndShape; + const left = box.AbsolutePosition.x, + top = box.AbsolutePosition.y; + if (ux < left || ux > left + box.Size.width) continue; + const distance = uy < top ? top - uy : uy > top + box.Size.height ? uy - top - box.Size.height : 0; + if (distance > 6) continue; + if (!best || distance < best.distance) best = { gm, distance }; + } + if (!best) return null; + const { gm } = best; + const partId = partOf(gm); + const measureIndex = indexes.get(gm.parentSourceMeasure); + if (!partId || measureIndex === undefined) return null; + let beat: Beat = null, + nearNote = false; + if (gm.staffEntries.length) { + let entry = gm.staffEntries[0]; + for (const candidate of gm.staffEntries) + if (Math.abs(candidate.PositionAndShape.AbsolutePosition.x - ux) < Math.abs(entry.PositionAndShape.AbsolutePosition.x - ux)) entry = candidate; + beat = [entry.relInMeasureTimestamp.Numerator, entry.relInMeasureTimestamp.Denominator]; + nearNote = Math.abs(entry.PositionAndShape.AbsolutePosition.x - ux) < 2.5; + } + return { partId, measureIndex, beat, nearNote }; +} + +/** 光标所在小节与拍位。 */ +export function cursorPosition(engine: OpenSheetMusicDisplay): { measureIndex: number; beat: [number, number] } | null { + const iterator = engine.cursor?.Iterator; + if (!iterator || iterator.EndReached) return null; + const rel = iterator.CurrentRelativeInMeasureTimestamp; + return { measureIndex: iterator.CurrentMeasureIndex, beat: [rel.Numerator, rel.Denominator] }; +} + +/** 把光标移到指定小节的拍位(或小节开头);最多迭代 200 000 步。 */ +export function moveCursorTo(engine: OpenSheetMusicDisplay, measureIndex: number, beat: Beat): void { + const cursor = engine.cursor; + if (!cursor) return; + const target = beat ? beat[0] / beat[1] : 0; + const before = () => { + const it = cursor.Iterator; + return it.CurrentMeasureIndex < measureIndex || (it.CurrentMeasureIndex === measureIndex && it.CurrentRelativeInMeasureTimestamp.RealValue < target - 1e-9); + }; + const position = cursorPosition(engine); + if (!position || position.measureIndex > measureIndex || (position.measureIndex === measureIndex && position.beat[0] / position.beat[1] > target + 1e-9)) + cursor.reset(); + for (let step = 0; step < 200_000 && !cursor.Iterator.EndReached && before(); step++) cursor.next(); + if (cursor.Iterator.EndReached) cursor.previous(); +} + +export interface Marker { + id: string; + number: number; + partId: string; + measureIndex: number; + beat: Beat; + resolved: boolean; + label: string; +} +export interface OverlayModel { + focus: Focus; + markers: Marker[]; + selectedId: string | null; +} + +const el = (name: string, attrs: Record) => { + const node = document.createElementNS(SVG_NS, name); + for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, String(value)); + return node; +}; + +/** 在每页 SVG 内绘制批注层:当前位置、编号批注标记。坐标与谱面一致,随缩放与导出保留。 */ +export function drawOverlay(container: HTMLElement, engine: OpenSheetMusicDisplay, model: OverlayModel): void { + const pages = Array.from(container.querySelectorAll("svg")); + for (const svg of pages) svg.querySelector(":scope > g.fb-overlay")?.remove(); + const layers = pages.map((svg) => { + const g = el("g", { class: "fb-overlay" }); + svg.append(g); + return g; }); - engine.Zoom = settings.zoom; - for (const instrument of engine.Sheet.Instruments) - instrument.Visible = - !settings.partId || instrument.IdString === settings.partId; + const focusBox = measureBox(engine, model.focus.partId, model.focus.measureIndex); + if (focusBox && layers[focusBox.page]) { + const layer = layers[focusBox.page]; + layer.append(el("rect", { class: "fb-focus", x: focusBox.x - 4, y: focusBox.y - 12, width: focusBox.w + 8, height: focusBox.h + 24, rx: 6, fill: ACCENT, "fill-opacity": 0.07, stroke: ACCENT, "stroke-opacity": 0.55, "stroke-width": 2 })); + const x = beatX(engine, model.focus.partId, model.focus.measureIndex, model.focus.beat); + if (x !== null) layer.append(el("line", { class: "fb-focus-beat", x1: x + 6, x2: x + 6, y1: focusBox.y - 14, y2: focusBox.y + focusBox.h + 14, stroke: ACCENT, "stroke-width": 3, "stroke-linecap": "round" })); + } + const taken = new Map(); + for (const marker of model.markers) { + const box = measureBox(engine, marker.partId, marker.measureIndex); + if (!box || !layers[box.page]) continue; + const color = marker.id === model.selectedId ? SELECTED : marker.resolved ? RESOLVED : ACCENT; + const beat = beatX(engine, marker.partId, marker.measureIndex, marker.beat); + const baseX = beat !== null ? beat + 6 : box.x + 14; + const key = `${box.page}:${Math.round(baseX)}:${Math.round(box.y)}`; + const stack = taken.get(key) ?? 0; + taken.set(key, stack + 1); + const cx = baseX + stack * 26, + cy = box.y - 34; + const group = el("g", { class: "fb-mark", "data-id": marker.id, tabindex: 0, role: "button", "aria-label": marker.label }); + const title = el("title", {}); + title.textContent = marker.label; + group.append(title); + if (beat === null) + group.append(el("path", { d: `M${box.x} ${box.y - 12} v-6 h${box.w} v6`, fill: "none", stroke: color, "stroke-width": 2, "stroke-opacity": 0.7 })); + else group.append(el("line", { x1: cx, x2: cx, y1: cy + 11, y2: box.y - 4, stroke: color, "stroke-width": 2 })); + group.append(el("circle", { cx, cy, r: 12, fill: color, stroke: "#ffffff", "stroke-width": marker.id === model.selectedId ? 3 : 1.5 })); + const label = el("text", { x: cx, y: cy + 4.5, "text-anchor": "middle", "font-size": 13, "font-weight": 700, "font-family": "Helvetica, Arial, sans-serif", fill: "#ffffff" }); + label.textContent = String(marker.number); + group.append(label); + layers[box.page].append(group); + } +} + +/** 某页 SVG 的客户端坐标 → 用户坐标。 */ +export function toSvgPoint(svg: SVGSVGElement, clientX: number, clientY: number): { x: number; y: number } | null { + const matrix = svg.getScreenCTM(); + if (!matrix) return null; + const point = new DOMPoint(clientX, clientY).matrixTransform(matrix.inverse()); + return { x: point.x, y: point.y }; +} + +export interface PageSnapshot { + svg: string; + width: number; + height: number; +} +/** 复制当前全部谱页(含批注层),白底、无工作台样式依赖。 */ +export function snapshotPages(container: HTMLElement): PageSnapshot[] { + const pages = Array.from(container.querySelectorAll("svg")); + if (!pages.length) throw new Error("请等待乐谱渲染完成。"); + return pages.map((page) => { + const box = page.viewBox.baseVal; + const width = box?.width || Number.parseFloat(page.getAttribute("width") || "0"); + const height = box?.height || Number.parseFloat(page.getAttribute("height") || "0"); + if (!width || !height) throw new Error("乐谱页面尺寸无效。"); + const copy = page.cloneNode(true) as SVGSVGElement; + copy.querySelectorAll(".fb-focus, .fb-focus-beat").forEach((node) => node.remove()); + copy.setAttribute("xmlns", SVG_NS); + copy.setAttribute("width", String(width)); + copy.setAttribute("height", String(height)); + copy.setAttribute("viewBox", `${box?.x ?? 0} ${box?.y ?? 0} ${width} ${height}`); + copy.removeAttribute("style"); + copy.prepend(el("rect", { x: box?.x ?? 0, y: box?.y ?? 0, width, height, fill: "#ffffff" })); + return { svg: new XMLSerializer().serializeToString(copy), width, height }; + }); +} + +/** 把多页纵向拼成一个独立 SVG 文件。 */ +export function stackSvg(pages: PageSnapshot[]): string { + const gap = 24; + const width = Math.max(...pages.map((page) => page.width)); + const height = pages.reduce((sum, page) => sum + page.height, 0) + gap * (pages.length - 1); + let y = 0; + const body = pages + .map((page) => { + const nested = page.svg.replace(/^\n${body}\n`; } -export async function trialRender(xml: string): Promise { + +/** 在屏幕外渲染一份(用于导入前试排与导出 A4 PDF),完成后清理。 */ +export async function renderOffscreen(xml: string, view: ViewSettings, work: (container: HTMLElement, engine: OpenSheetMusicDisplay) => T | Promise, limitMeasures?: number): Promise { const { OpenSheetMusicDisplay } = await loadEngine(); const container = document.createElement("div"); - container.style.cssText = - "position:fixed;left:-10000px;top:0;width:860px;visibility:hidden;pointer-events:none"; + container.setAttribute("aria-hidden", "true"); + container.style.cssText = "position:fixed;left:-20000px;top:0;width:1200px;visibility:hidden;pointer-events:none"; document.body.append(container); - const engine = new OpenSheetMusicDisplay(container, { - backend: "svg", - autoResize: false, - pageFormat: "A4_P", - }); + const engine = new OpenSheetMusicDisplay(container, { ...engineOptions(view), drawUpToMeasureNumber: limitMeasures }); try { engine.setLogLevel("error"); await engine.load(xml); + await configure(engine, view); + if (limitMeasures) engine.setOptions({ drawUpToMeasureNumber: limitMeasures }); engine.render(); - if (!container.querySelector("svg path")) - throw new Error("乐谱没有可显示的音符。"); + return await work(container, engine); } finally { engine.clear(); container.remove(); } } -export function serializeScore(container: HTMLElement): string { - const pages = Array.from(container.querySelectorAll("svg")); - if (!pages.length) throw new Error("请等待乐谱渲染完成。"); - const ns = "http://www.w3.org/2000/svg"; - const output = document.createElementNS(ns, "svg"); - output.setAttribute("xmlns", ns); - output.setAttribute("role", "img"); - output.setAttribute("aria-label", "Reviewed music score"); - let height = 0, - width = 0; - for (const page of pages) { - const box = page.viewBox.baseVal; - const w = box.width || Number.parseFloat(page.getAttribute("width") || "0"), - h = box.height || Number.parseFloat(page.getAttribute("height") || "0"); - if (!w || !h) throw new Error("乐谱页面尺寸无效。"); - const copy = page.cloneNode(true) as SVGSVGElement; - copy.setAttribute("x", "0"); - copy.setAttribute("y", String(height)); - copy.setAttribute("width", String(w)); - copy.setAttribute("height", String(h)); - copy.setAttribute("viewBox", `${box.x} ${box.y} ${w} ${h}`); - copy.style.fontFamily = "Times New Roman, serif"; - copy.style.color = "#111"; - copy.style.backgroundColor = "white"; - output.append(copy); - height += h + 24; - width = Math.max(width, w); - } - output.setAttribute("width", String(width)); - output.setAttribute("height", String(height - 24)); - output.setAttribute("viewBox", `0 0 ${width} ${height - 24}`); - const background = document.createElementNS(ns, "rect"); - background.setAttribute("width", "100%"); - background.setAttribute("height", "100%"); - background.setAttribute("fill", "#ffffff"); - output.prepend(background); - return new XMLSerializer().serializeToString(output); -} diff --git a/workbenches/55-opensheetmusicdisplay/app/src/score/exporter.ts b/workbenches/55-opensheetmusicdisplay/app/src/score/exporter.ts new file mode 100644 index 0000000..c6c19f2 --- /dev/null +++ b/workbenches/55-opensheetmusicdisplay/app/src/score/exporter.ts @@ -0,0 +1,131 @@ +// ABOUTME: 把带批注层的谱页栅格化为 PNG,或连同批注清单页写成 PDF。全部在浏览器内完成。 +import { buildPdf, type PdfPage } from "../contracts/pdf"; +import type { ReviewReport } from "../contracts/report"; +import type { PageSnapshot } from "./engine"; + +const MAX_SIDE = 12000; +const MAX_AREA = 60_000_000; + +function loadSvg(svg: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error("谱页无法栅格化。")); + image.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg); + }); +} + +export async function rasterize(page: PageSnapshot, scale: number): Promise { + let factor = Math.min(scale, MAX_SIDE / page.width, MAX_SIDE / page.height); + if (page.width * page.height * factor * factor > MAX_AREA) factor = Math.sqrt(MAX_AREA / (page.width * page.height)); + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(page.width * factor)); + canvas.height = Math.max(1, Math.round(page.height * factor)); + const context = canvas.getContext("2d"); + if (!context) throw new Error("浏览器无法创建画布。"); + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, canvas.width, canvas.height); + context.drawImage(await loadSvg(page.svg), 0, 0, canvas.width, canvas.height); + return canvas; +} + +const toBlob = (canvas: HTMLCanvasElement, type: string, quality?: number) => + new Promise((resolve, reject) => canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("图像编码失败。"))), type, quality)); + +export async function pagePng(page: PageSnapshot, scale = 2): Promise { + return toBlob(await rasterize(page, scale), "image/png"); +} + +const A4 = { w: 595.28, h: 841.89 }; +const FONT = '"PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC", "Microsoft YaHei", system-ui, sans-serif'; + +function wrap(context: CanvasRenderingContext2D, text: string, width: number): string[] { + const lines: string[] = []; + for (const paragraph of text.split(/\n/)) { + let line = ""; + for (const char of paragraph) { + if (context.measureText(line + char).width > width && line) { + lines.push(line); + line = char; + } else line += char; + } + lines.push(line); + } + return lines; +} + +/** 批注清单页:按编号列出位置、类别、状态与正文;多页自动续排。 */ +function appendixPages(report: ReviewReport): HTMLCanvasElement[] { + const W = 1654, + H = 2339, + margin = 120; + const pages: HTMLCanvasElement[] = []; + let context!: CanvasRenderingContext2D; + let y = 0; + const page = () => { + const canvas = document.createElement("canvas"); + canvas.width = W; + canvas.height = H; + context = canvas.getContext("2d")!; + context.fillStyle = "#ffffff"; + context.fillRect(0, 0, W, H); + context.fillStyle = "#1f2933"; + context.font = `600 40px ${FONT}`; + context.fillText(pages.length ? "审校批注(续)" : "审校批注", margin, margin + 30); + context.font = `26px ${FONT}`; + context.fillStyle = "#52606d"; + context.fillText( + `${report.score.title} · 共 ${report.summary.total} 条,待核对 ${report.summary.open} 条 · 原件 SHA-256 ${report.source.sha256.slice(0, 16)}…`, + margin, + margin + 80, + ); + pages.push(canvas); + y = margin + 140; + }; + page(); + if (!report.annotations.length) { + context.font = `30px ${FONT}`; + context.fillText("没有批注。", margin, y + 40); + } + for (const note of report.annotations) { + context.font = `28px ${FONT}`; + const body = wrap(context, note.text || "(空白)", W - margin * 2 - 80); + const need = 60 + body.length * 40 + 30; + if (y + need > H - margin) page(); + context.fillStyle = note.resolved ? "#5b6b7d" : "#c2410c"; + context.beginPath(); + context.arc(margin + 24, y + 22, 24, 0, Math.PI * 2); + context.fill(); + context.fillStyle = "#ffffff"; + context.font = `700 24px Helvetica, Arial, sans-serif`; + context.textAlign = "center"; + context.fillText(String(note.number), margin + 24, y + 31); + context.textAlign = "left"; + context.fillStyle = "#1f2933"; + context.font = `600 28px ${FONT}`; + const where = `${note.partName} · 第 ${note.measureNumber} 小节${note.beat ? ` 第 ${note.beat} 拍` : ""} · ${note.kindLabel} · ${note.resolved ? "已核对" : "待核对"}`; + context.fillText(where, margin + 80, y + 32); + context.font = `28px ${FONT}`; + context.fillStyle = "#323f4b"; + body.forEach((line, i) => context.fillText(line, margin + 80, y + 82 + i * 40)); + y += need; + } + return pages; +} + +export async function scorePdf(pages: PageSnapshot[], report: ReviewReport, onProgress: (message: string) => void): Promise { + const out: PdfPage[] = []; + for (const [i, page] of pages.entries()) { + onProgress(`正在生成 PDF 第 ${i + 1}/${pages.length} 页…`); + const scale = Math.min(2, 2480 / page.width); + const canvas = await rasterize(page, scale); + const portrait = page.height >= page.width; + const pageWidth = portrait ? A4.w : A4.h; + const pageHeight = Math.min(14000, (pageWidth * canvas.height) / canvas.width); + out.push({ jpeg: new Uint8Array(await (await toBlob(canvas, "image/jpeg", 0.9)).arrayBuffer()), width: canvas.width, height: canvas.height, pageWidth, pageHeight }); + } + for (const canvas of appendixPages(report)) + out.push({ jpeg: new Uint8Array(await (await toBlob(canvas, "image/jpeg", 0.92)).arrayBuffer()), width: canvas.width, height: canvas.height, pageWidth: A4.w, pageHeight: A4.h }); + const bytes = buildPdf(out, { title: report.score.title, subject: `${report.summary.total} 条审校批注` }); + return new Blob([new Uint8Array(bytes)], { type: "application/pdf" }); +} diff --git a/workbenches/55-opensheetmusicdisplay/app/src/score/playback.ts b/workbenches/55-opensheetmusicdisplay/app/src/score/playback.ts new file mode 100644 index 0000000..6c2b587 --- /dev/null +++ b/workbenches/55-opensheetmusicdisplay/app/src/score/playback.ts @@ -0,0 +1,157 @@ +// ABOUTME: 用 Web Audio 合成器按 OSMD 光标逐个时值回放可见声部,并驱动光标移动;不加载任何音色文件。 +import type { Note, OpenSheetMusicDisplay } from "opensheetmusicdisplay"; + +export interface PlaybackOptions { + tempo: () => number; + transpose: () => number; + hidden: () => string[]; + onStep: () => void; + onEnd: () => void; +} + +const LOOKAHEAD = 0.06; + +export class Player { + private context?: AudioContext; + private master?: GainNode; + private timer?: ReturnType; + private time = 0; + private voices = new Set(); + playing = false; + + constructor( + private engine: OpenSheetMusicDisplay, + private options: PlaybackOptions, + ) {} + + static supported(): boolean { + return typeof window !== "undefined" && typeof window.AudioContext === "function"; + } + + async start(): Promise { + if (this.playing) return; + this.context ??= new AudioContext(); + await this.context.resume(); + if (!this.master) { + const compressor = this.context.createDynamicsCompressor(); + compressor.connect(this.context.destination); + this.master = this.context.createGain(); + this.master.gain.value = 0.5; + this.master.connect(compressor); + } + const cursor = this.engine.cursor; + cursor.show(); + if (cursor.Iterator.EndReached) cursor.reset(); + this.playing = true; + this.time = this.context.currentTime + LOOKAHEAD; + this.play(); + } + + stop(): void { + this.playing = false; + if (this.timer) clearTimeout(this.timer); + this.timer = undefined; + const now = this.context?.currentTime ?? 0; + for (const voice of this.voices) + try { + voice.stop(now + 0.03); + } catch { + /* 已停止的振荡器 */ + } + this.voices.clear(); + } + + dispose(): void { + this.stop(); + void this.context?.close(); + this.context = undefined; + this.master = undefined; + } + + private wholeNoteSeconds(): number { + const bpm = this.engine.cursor.Iterator.CurrentBpm || this.engine.Sheet.DefaultStartTempoInBpm || 100; + return 240 / ((bpm * this.options.tempo()) / 100); + } + + /** 在 this.time 发声当前光标下的音符,然后安排下一步。 */ + private play(): void { + if (!this.playing || !this.context) return; + const cursor = this.engine.cursor; + const iterator = cursor.Iterator; + if (iterator.EndReached) { + this.playing = false; + this.options.onEnd(); + return; + } + const whole = this.wholeNoteSeconds(); + const hidden = this.options.hidden(); + const notes: Note[] = []; + for (const instrument of this.engine.Sheet.Instruments) + if (!hidden.includes(instrument.IdString)) notes.push(...cursor.NotesUnderCursor(instrument)); + const sounding = notes.filter((note) => !note.isRest() && note.Pitch && (!note.NoteTie || note.NoteTie.StartNote === note)); + for (const note of sounding) { + const length = (note.NoteTie ? note.NoteTie.Duration : note.Length).RealValue * whole; + const pitch = note.TransposedPitch ?? note.Pitch; + const shift = note.TransposedPitch ? 0 : this.options.transpose(); + this.voice(pitch.Frequency * 2 ** (shift / 12), this.time, Math.max(0.08, length), sounding.length); + } + const start = iterator.CurrentEnrolledTimestamp.RealValue; + const peek = iterator.clone(); + peek.moveToNext(); + while (!peek.EndReached && peek.CurrentEnrolledTimestamp.RealValue <= start) peek.moveToNext(); + const fallback = Math.max(0.25, ...notes.map((note) => note.Length.RealValue)); + const gap = (peek.EndReached ? fallback : peek.CurrentEnrolledTimestamp.RealValue - start) * whole; + const wait = Math.max(0, (this.time + gap - this.context.currentTime - LOOKAHEAD) * 1000); + this.timer = setTimeout(() => this.advance(start, whole), wait); + } + + private advance(previous: number, whole: number): void { + if (!this.playing) return; + const cursor = this.engine.cursor; + cursor.next(); + this.options.onStep(); + if (cursor.Iterator.EndReached) { + this.playing = false; + this.options.onEnd(); + return; + } + const step = cursor.Iterator.CurrentEnrolledTimestamp.RealValue - previous; + this.time += Math.max(0, step) * whole; + // 被浏览器节流(后台标签)后不追赶旧时间。 + if (this.context && this.time < this.context.currentTime) this.time = this.context.currentTime + LOOKAHEAD; + this.play(); + } + + private voice(frequency: number, at: number, length: number, polyphony: number): void { + if (!this.context || !this.master || !Number.isFinite(frequency) || frequency < 20 || frequency > 8000) return; + const context = this.context; + const level = 0.32 / Math.sqrt(Math.max(1, polyphony)); + const gain = context.createGain(); + const filter = context.createBiquadFilter(); + filter.type = "lowpass"; + filter.frequency.value = Math.min(6000, frequency * 6); + const body = context.createOscillator(); + body.type = "triangle"; + body.frequency.value = frequency; + const shine = context.createOscillator(); + shine.type = "sine"; + shine.frequency.value = frequency * 2; + const shineGain = context.createGain(); + shineGain.gain.value = 0.18; + const end = at + length; + gain.gain.setValueAtTime(0, at); + gain.gain.linearRampToValueAtTime(level, at + 0.012); + gain.gain.exponentialRampToValueAtTime(level * 0.45, at + Math.min(0.35, length * 0.6)); + gain.gain.setValueAtTime(level * 0.45, Math.max(at + 0.02, end - 0.04)); + gain.gain.exponentialRampToValueAtTime(0.0001, end + 0.12); + body.connect(filter); + shine.connect(shineGain).connect(filter); + filter.connect(gain).connect(this.master); + for (const oscillator of [body, shine]) { + oscillator.start(at); + oscillator.stop(end + 0.15); + this.voices.add(oscillator); + oscillator.onended = () => this.voices.delete(oscillator); + } + } +} diff --git a/workbenches/55-opensheetmusicdisplay/app/src/server/index.ts b/workbenches/55-opensheetmusicdisplay/app/src/server/index.ts index b6a2382..264b364 100644 --- a/workbenches/55-opensheetmusicdisplay/app/src/server/index.ts +++ b/workbenches/55-opensheetmusicdisplay/app/src/server/index.ts @@ -2,8 +2,20 @@ import { registerAutosaveRoutes } from "./autosave"; import { createWorkbenchServer } from "../../sdk/runtime.mjs"; import { registerRoutes } from "./routes"; import { validateScore } from "../contracts/domain"; -import { verifySource } from "../contracts/archive"; +import { createSourceStore, registerSourceRoutes } from "./sources"; +import { upgradeLegacyRows } from "./legacy"; + const app = await createWorkbenchServer(import.meta.url); -registerRoutes(app, validateScore, verifySource); -registerAutosaveRoutes(app, validateScore, undefined, {verify: async (data) => { await verifySource(data); }}); +const sources = createSourceStore(app.database); +sources.ensureSamples(); +const upgraded = await upgradeLegacyRows(app.database, sources); +if (upgraded.projects || upgraded.drafts) console.info(`已把 ${upgraded.projects} 个旧版审校项目、${upgraded.drafts} 份草稿迁入原件库。`); +const verify = async (value: unknown) => { + const data = validateScore(value); + sources.verify(data); + return data; +}; +registerSourceRoutes(app, sources); +registerRoutes(app, validateScore, verify); +registerAutosaveRoutes(app, validateScore, undefined, { verify: async (data) => void (await verify(data)) }); await app.listen(); diff --git a/workbenches/55-opensheetmusicdisplay/app/src/server/legacy.ts b/workbenches/55-opensheetmusicdisplay/app/src/server/legacy.ts new file mode 100644 index 0000000..ebf5a16 --- /dev/null +++ b/workbenches/55-opensheetmusicdisplay/app/src/server/legacy.ts @@ -0,0 +1,80 @@ +// ABOUTME: 0.3.x 项目(原件 base64 内嵌在项目中)在启动时迁入原件库并改写为 0.9 结构;只处理一次,失败的条目保持原样。 +import type { DatabaseSync } from "node:sqlite"; +import { isRecord } from "../contracts/project.ts"; +import { defaultView, newProject, validateScore, type Annotation, type ScoreProject } from "../contracts/domain.ts"; +import type { SourceStore } from "./sources.ts"; + +const isLegacy = (value: unknown): value is Record & { source: Record } => + isRecord(value) && isRecord(value.source) && typeof value.source.originalBase64 === "string"; + +/** 把一个旧项目转换为新结构;原件写入原件库(按内容去重)。 */ +export async function upgradeLegacy(value: unknown, sources: SourceStore, at: string): Promise { + if (!isLegacy(value)) throw new Error("不是旧版项目。"); + const fileName = typeof value.source.fileName === "string" ? value.source.fileName : "score.musicxml"; + const bytes = new Uint8Array(Buffer.from(String(value.source.originalBase64), "base64")); + const stored = await sources.insert(bytes, fileName); + const project = newProject({ id: stored.id, fileName, kind: stored.kind, size: stored.size }, stored.index); + const parts = stored.index.parts.map((part) => part.id); + const partId = typeof value.partId === "string" ? value.partId : ""; + project.view = { + ...defaultView(), + hiddenParts: partId && parts.includes(partId) ? parts.filter((id) => id !== partId) : [], + layout: value.layout === "continuous" ? "endless" : "page", + zoom: typeof value.zoom === "number" ? value.zoom : 0.85, + }; + if (Array.isArray(value.annotations)) + project.annotations = value.annotations.filter(isRecord).map( + (item): Annotation => ({ + id: String(item.id), + partId: String(item.partId), + measureIndex: Number(item.measureIndex), + beat: null, + kind: "other", + text: typeof item.text === "string" ? item.text : "", + resolved: item.resolved === true, + createdAt: at, + }), + ); + if (isRecord(value.focus)) project.focus = { partId: String(value.focus.partId), measureIndex: Number(value.focus.measureIndex), beat: null }; + return project; +} + +export async function upgradeLegacyRows(db: DatabaseSync, sources: SourceStore): Promise<{ projects: number; drafts: number }> { + let projects = 0, + drafts = 0; + for (const row of db.prepare("SELECT id,payload,updated_at FROM projects").all()) { + try { + const payload: unknown = JSON.parse(String(row.payload)); + if (!isLegacy(payload)) continue; + const next = validateScore(await upgradeLegacy(payload, sources, String(row.updated_at))); + db.prepare("UPDATE projects SET payload=?,revision=revision+1 WHERE id=?").run(JSON.stringify(next), String(row.id)); + projects++; + } catch (error) { + console.warn(`旧项目 ${String(row.id)} 未能迁移:${error instanceof Error ? error.message : error}`); + } + } + // 自动保存草稿:只改写快照里的数据;对应项目的版本号随上面的改写增加,旧窗口重放时会得到冲突而不是覆盖。 + for (const row of db.prepare("SELECT id,snapshot,receipt,project_id,updated_at FROM autosave_drafts").all()) { + try { + const snapshot: unknown = JSON.parse(String(row.snapshot)); + if (!isRecord(snapshot) || !isLegacy(snapshot.data)) continue; + const data = await upgradeLegacy(snapshot.data, sources, String(row.updated_at)); + const current = row.project_id ? db.prepare("SELECT revision,payload FROM projects WHERE id=?").get(String(row.project_id)) : undefined; + const receipt: unknown = JSON.parse(String(row.receipt)); + if (isRecord(receipt) && isRecord(receipt.project) && current) { + receipt.project.data = JSON.parse(String(current.payload)); + receipt.project.revision = Number(current.revision); + } + db.prepare("UPDATE autosave_drafts SET snapshot=?,receipt=?,project_revision=COALESCE(?,project_revision) WHERE id=?").run( + JSON.stringify({ ...snapshot, data }), + JSON.stringify(receipt), + current ? Number(current.revision) : null, + String(row.id), + ); + drafts++; + } catch (error) { + console.warn(`旧草稿 ${String(row.id)} 未能迁移:${error instanceof Error ? error.message : error}`); + } + } + return { projects, drafts }; +} diff --git a/workbenches/55-opensheetmusicdisplay/app/src/server/sources.ts b/workbenches/55-opensheetmusicdisplay/app/src/server/sources.ts new file mode 100644 index 0000000..4991930 --- /dev/null +++ b/workbenches/55-opensheetmusicdisplay/app/src/server/sources.ts @@ -0,0 +1,170 @@ +// ABOUTME: 乐谱原件库:分块上传、SHA-256 校验、MXL 解压与索引入库;项目只保存原件引用与索引。 +import { createHash } from "node:crypto"; +import type { DatabaseSync } from "node:sqlite"; +import type { WorkbenchServer } from "../../sdk/runtime.mjs"; +import { isRecord } from "../contracts/project.ts"; +import { MAX_SOURCE_BYTES, readSource } from "../contracts/archive.ts"; +import { parseIndex, type ScoreProject, type SourceRef } from "../contracts/domain.ts"; +import type { ScoreIndex } from "../contracts/musicxml.ts"; +import { SAMPLES, sampleSource } from "../contracts/samples.ts"; +import { StoreError } from "./store.ts"; + +/** 每块原始字节;base64 后约 1 MB,低于运行时 2 MB 请求上限。 */ +export const CHUNK = 768 * 1024; +const ID = /^[0-9a-f]{64}$/; +const digest = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex"); +const identifier = (value: string) => { + if (!ID.test(value)) throw new StoreError(400, "原文件标识无效。"); + return value; +}; + +interface Row { + id: string; + kind: string; + size: number; + score_index: string; +} + +export function createSourceStore(db: DatabaseSync) { + const row = (id: string) => + db.prepare("SELECT id,kind,size,score_index FROM score_sources WHERE id=?").get(identifier(id)) as Row | undefined; + const describe = (found: Row) => ({ id: found.id, kind: found.kind as SourceRef["kind"], size: Number(found.size), index: parseIndex(JSON.parse(found.score_index)) }); + async function insert(bytes: Uint8Array, fileName: string) { + const id = digest(bytes); + const known = row(id); + if (known) return describe(known); + let parsed: Awaited>; + try { + parsed = await readSource(bytes, fileName); + } catch (error) { + throw new StoreError(422, error instanceof Error ? error.message : "乐谱无法读取。"); + } + const raw = parsed.kind === "xml" && Buffer.from(parsed.xml, "utf8").equals(Buffer.from(bytes)); + db.prepare("INSERT OR IGNORE INTO score_sources(id,kind,size,data,xml,score_index,created_at) VALUES(?,?,?,?,?,?,?)").run( + id, + parsed.kind, + bytes.length, + bytes, + raw ? null : parsed.xml, + JSON.stringify(parsed.index), + new Date().toISOString(), + ); + return { id, kind: parsed.kind, size: bytes.length, index: parsed.index }; + } + return { + insert, + status(id: string) { + const found = row(id); + return found ? { stored: true, ...describe(found) } : { stored: false }; + }, + part(id: string, index: number, raw: unknown) { + identifier(id); + if (!Number.isInteger(index) || index < 0 || index >= Math.ceil(MAX_SOURCE_BYTES / CHUNK)) throw new StoreError(400, "分块序号无效。"); + if (typeof raw !== "string" || raw.length > Math.ceil(CHUNK / 3) * 4) throw new StoreError(413, "分块超过容量。"); + const bytes = Buffer.from(raw, "base64"); + if (!bytes.length || bytes.length > CHUNK || bytes.toString("base64") !== raw) throw new StoreError(400, "分块编码无效。"); + if (row(id)) return { ok: true }; + db.prepare( + "INSERT INTO score_source_parts(source_id,part,data,created_at) VALUES(?,?,?,?) ON CONFLICT(source_id,part) DO UPDATE SET data=excluded.data,created_at=excluded.created_at", + ).run(id, index, bytes, new Date().toISOString()); + return { ok: true }; + }, + async finish(id: string, input: unknown) { + identifier(id); + if (!isRecord(input) || typeof input.fileName !== "string" || !input.fileName.trim() || input.fileName.length > 200 || !Number.isInteger(input.size)) + throw new StoreError(400, "原文件信息无效。"); + const size = Number(input.size); + if (size < 1 || size > MAX_SOURCE_BYTES) throw new StoreError(413, `原文件需要 1 B–${MAX_SOURCE_BYTES / 1048576} MB。`); + const known = row(id); + if (known) return describe(known); + const count = Math.ceil(size / CHUNK); + const parts = db.prepare("SELECT part,data FROM score_source_parts WHERE source_id=? AND part Number(part.part) !== index)) + throw new StoreError(409, "文件尚未完整上传,请重新选择文件。"); + const bytes = Buffer.concat(parts.map((part) => Buffer.from(part.data))); + if (bytes.length !== size || digest(bytes) !== id) throw new StoreError(400, "文件校验失败,请重新上传。"); + const result = await insert(new Uint8Array(bytes), input.fileName); + db.prepare("DELETE FROM score_source_parts WHERE source_id=? OR created_at; +export type StoredSource = { id: string; kind: SourceRef["kind"]; size: number; index: ScoreIndex }; + +export function registerSourceRoutes(app: WorkbenchServer, sources: SourceStore) { + const fail = (error: unknown) => + Response.json( + { error: { message: error instanceof Error ? error.message : "原文件操作失败。" } }, + { status: error instanceof StoreError ? error.status : 400 }, + ); + const guard = async (work: () => T | Promise) => { + try { + return await work(); + } catch (error) { + return fail(error); + } + }; + app.route("GET", "/api/sources/:id", ({ params }) => guard(() => sources.status(params.id))); + app.route("POST", "/api/sources/:id/parts/:part", ({ params, body }) => + guard(() => sources.part(params.id, Number(params.part), isRecord(body) ? body.data : null)), + ); + app.route("POST", "/api/sources/:id/finish", ({ params, body }) => guard(() => sources.finish(params.id, body))); + app.route("GET", "/api/sources/:id/original", ({ params }) => + guard(() => { + const { kind, data } = sources.original(params.id); + return new Response(new Uint8Array(data), { + headers: { + "content-type": kind === "mxl" ? "application/vnd.recordare.musicxml" : "application/vnd.recordare.musicxml+xml", + "cache-control": "private, max-age=31536000, immutable", + "x-content-type-options": "nosniff", + }, + }); + }), + ); + app.route("GET", "/api/sources/:id/musicxml", ({ params }) => + guard( + () => + new Response(sources.musicxml(params.id), { + headers: { + "content-type": "text/plain; charset=utf-8", + "cache-control": "private, max-age=31536000, immutable", + "x-content-type-options": "nosniff", + }, + }), + ), + ); +} diff --git a/workbenches/55-opensheetmusicdisplay/app/src/ui/AnnotationBook.tsx b/workbenches/55-opensheetmusicdisplay/app/src/ui/AnnotationBook.tsx deleted file mode 100644 index 04f3108..0000000 --- a/workbenches/55-opensheetmusicdisplay/app/src/ui/AnnotationBook.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import type { RefObject } from "react"; -import type { Annotation } from "../contracts/domain"; -import type { PartInfo } from "../contracts/musicxml"; -export function AnnotationBook({ - notesRef, - allNotes, - part, - measureIndex, - annotations, - parts, - locked, - addNote, - updateNote, - removeNote, - onSelect, -}: { - notesRef: RefObject; - allNotes: boolean; - part: PartInfo; - measureIndex: number; - annotations: Annotation[]; - parts: PartInfo[]; - locked: boolean; - addNote: () => void; - updateNote: ( - id: string, - patch: { text?: string; resolved?: boolean }, - ) => void; - removeNote: (id: string) => void; - onSelect: (partId: string, measureIndex: number) => void; -}) { - return ( -
-
-

EDITOR'S NOTES

-

- {allNotes - ? "全部审校记录" - : `第 ${part.measures[measureIndex].number} 小节`} -

-

{allNotes ? "点击位置回到对应小节" : part.name}

-
- - {annotations.length === 0 ? ( -
- -

{allNotes ? "还没有审校记录" : "这一小节还没有批注"}

- 记录连线、力度、排版或需要向演奏者确认的细节。 -
- ) : ( - annotations.map((note) => { - const notePart = parts.find((part) => part.id === note.partId)!; - return ( -
-
- - -
- -