dsh-motion-ui 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +63 -3
  3. package/VERIFICATION.md +26 -0
  4. package/client.js +876 -272
  5. package/index.js +43 -20
  6. package/lib/assets/mermaid.js +382 -382
  7. package/lib/assets/standalone-runtime.js +1 -1
  8. package/lib/assets/visual.js +13 -13
  9. package/lib/author-expression.js +7 -7
  10. package/lib/authoring-guide.js +3 -17
  11. package/lib/build-info.js +1 -1
  12. package/lib/canvas-design.js +2 -2
  13. package/lib/canvas-export.js +3 -3
  14. package/lib/canvas-system.js +1 -1
  15. package/lib/catalog.js +1 -2
  16. package/lib/delivery.js +1 -1
  17. package/lib/export-runtime-bundle.js +1 -1
  18. package/lib/fence-feedback.js +8 -2
  19. package/lib/freeform.js +4 -10
  20. package/lib/inspection.js +19 -6
  21. package/lib/motion-craft.js +31 -0
  22. package/lib/motion-design-skill.js +23 -0
  23. package/lib/motion-library.js +5 -4
  24. package/lib/normalize-authoring.js +9 -0
  25. package/lib/project-archive.js +16 -0
  26. package/lib/prompt-contract.js +22 -0
  27. package/lib/protocol.js +3 -3
  28. package/lib/refinement.js +36 -3
  29. package/lib/sandbox.js +4 -1
  30. package/lib/scene-entry.js +41 -0
  31. package/lib/style-system.js +2 -2
  32. package/lib/text-guide.js +9 -13
  33. package/lib/third-party/taste-skill-NOTICE.md +10 -0
  34. package/lib/timeline.js +29 -0
  35. package/lib/tool-json.js +5 -0
  36. package/lib/tool-schema.js +7 -7
  37. package/lib/ui-intent.js +4 -15
  38. package/lib/ui-skill.js +19 -26
  39. package/lib/video-tools.js +21 -0
  40. package/lib/video.js +296 -0
  41. package/lib/visual-audit.js +15 -4
  42. package/lib/visual-styles.js +1 -1
  43. package/lib/voice.js +72 -0
  44. package/package.json +17 -9
  45. package/skills/intelligent-ui/SKILL.md +14 -107
  46. package/skills/motion-design/ADAPTER.md +35 -0
  47. package/skills/motion-design/LICENSE +201 -0
  48. package/skills/motion-design/NOTICE.md +9 -0
  49. package/skills/motion-design/SKILL.md +388 -0
  50. package/skills/motion-design/SOURCES.md +16 -0
  51. package/skills/motion-design/upstream.json +8 -0
@@ -0,0 +1,29 @@
1
+ // Browser-only, dependency-free clock. Serialized into isolated canvases.
2
+ export function createTimelineAPI(animate, capture = false, notify = () => {}) {
3
+ const timelines = new Map();
4
+ function timeline(options) {
5
+ const {id = 'main', duration, draw, loop = false, autoplay = true, chapters = []} = options ?? {};
6
+ if (typeof id !== 'string' || !/^[A-Za-z_][\w-]{0,63}$/.test(id) || timelines.has(id)) throw Error('Timeline id must be unique');
7
+ if (!Number.isFinite(duration) || duration <= 0 || duration > 86400 || typeof draw !== 'function') throw Error('Timeline requires positive duration and draw(time, info)');
8
+ if (!Array.isArray(chapters) || chapters.length > 100 || chapters.some(c => !c || typeof c.label !== 'string' || !Number.isFinite(c.time) || c.time < 0 || c.time >= duration)) throw Error('Invalid timeline chapters');
9
+ let time = 0, playing = !!autoplay && !capture, disposed = false, reduced = false, visible = true;
10
+ const listeners = new Set();
11
+ const state = () => ({id, time, duration, playing, loop: !!loop, reducedMotion: reduced, visible, chapters: chapters.map(c => ({time:c.time,label:c.label}))});
12
+ const render = () => { draw(time, state()); notify(state()); for (const fn of listeners) fn(state()); };
13
+ const seek = value => { if (disposed) throw Error('Timeline disposed'); if (!Number.isFinite(value)) throw Error('Timeline seek requires finite seconds'); time = Math.max(0, Math.min(duration, value)); render(); return state(); };
14
+ const api = Object.freeze({state,seek,play(){if(disposed)throw Error('Timeline disposed');playing=true;render()},pause(){playing=false;render()},replay(){playing=!capture;seek(0)},chapter(index){if(!Number.isInteger(index)||!chapters[index])throw Error('Invalid chapter');playing=false;return seek(chapters[index].time)},subscribe(fn){if(typeof fn!=='function')throw Error('Timeline listener required');listeners.add(fn);fn(state());return()=>listeners.delete(fn)},dispose(){if(disposed)return;disposed=true;stop();listeners.clear();timelines.delete(id)}});
15
+ // Register before animate(), which synchronously supplies the initial frame.
16
+ timelines.set(id, api);
17
+ const stop = animate(info => {
18
+ reduced = !!info.reducedMotion; visible = info.visible !== false;
19
+ if (!capture && playing && !reduced && visible) {
20
+ const next = time + info.delta;
21
+ time = loop ? next % duration : Math.min(duration, next);
22
+ if (!loop && time === duration) playing = false;
23
+ }
24
+ render();
25
+ });
26
+ return api;
27
+ }
28
+ return {timeline, capture: Object.freeze({list:()=>[...timelines.values()].map(t=>t.state()),seek:(id,time)=>{const t=timelines.get(id);if(!t)throw Error('Timeline not found: '+id);t.seek(time);t.pause();return t.state()}})};
29
+ }
package/lib/tool-json.js CHANGED
@@ -24,3 +24,8 @@ export function toolJson(value, field, maxBytes=131072){
24
24
  throw new Error(`DSH UI: ${field} must contain valid JSON${location}. Pass an object directly, not a manually escaped JSON string. Do not use single-quoted keys or raw newlines inside strings. For HTML/SVG use render_canvas with separate html/css/script fields. No partial document was saved.`);
25
25
  }
26
26
  }
27
+ // Some provider adapters emit "" for absent optional JSON fields. This is an
28
+ // omission only at optional boundaries, never a repair for required documents.
29
+ export function optionalToolJson(value,field,fallback){
30
+ return value==null||(typeof value==='string'&&!value.trim())?fallback:toolJson(value,field);
31
+ }
@@ -4,14 +4,14 @@ import {COMPONENTS} from './components.js';
4
4
  const dictionary=description=>({type:'object',additionalProperties:true,description});
5
5
  export const DOCUMENT_PARAMETER={
6
6
  required:true,
7
- description:'Pass one COMPLETE object, never manually serialize or send a partial patch. v1 always needs nodes:[...] beside state/derived; state alone creates no controls. Choose v1 {version:1,title,fallback,nodes} for shared state/formulas/canvas, OR extended {title,items:[components]} for charts/plots/quiz/forms/mermaid/3D. Do not mix the two root languages.',
7
+ description:'Complete v1 {version:1,title,fallback,nodes,state?,derived?} OR extended {title,items:[components]}. No mixed roots, partial patches or serialized JSON. State alone creates no controls.',
8
8
  type:'object',additionalProperties:false,
9
9
  properties:{
10
10
  version:{type:'number',const:1},
11
11
  title:{type:'string',required:true},
12
12
  fallback:{type:'string',description:'Required for v1 nodes: useful plain-text explanation if UI is unavailable.'},
13
- items:{type:'array',description:'Extended visual components: use their compact type-specific fields. See injected guide or ui_catalog topic=visual.',items:{type:'json'}},
14
- nodes:{type:'array',items:{type:'object',additionalProperties:true,properties:{
13
+ items:{type:'array',description:'Extended fields: ui_catalog topic=visual.',items:{type:'json'}},
14
+ nodes:{type:'array',description:'REQUIRED for version:1: all visible nodes and control nodes, in this same call. Never submit only state/derived.',items:{type:'object',additionalProperties:true,properties:{
15
15
  id:{type:'string',required:true,description:'Unique letter/underscore identifier; Chinese supported, max64.'},
16
16
  type:{type:'string',required:true,enum:Object.keys(COMPONENTS)},
17
17
  spec:{type:'json',description:'visual only: extended {title?,items:[components]} composition.'},stateKey:{type:'string',description:'visual only: optional dedicated json state key, auto-created when omitted.'},warnings:{type:'json'},
@@ -32,13 +32,13 @@ export const DOCUMENT_PARAMETER={
32
32
  libraries:{type:'array',items:{type:'string',enum:['mechanism']},description:'Optional local mechanism drawing helper; ui_catalog topic=mechanism.'},stateKeys:{type:'array',items:{type:'string'}},dataKeys:{type:'array',items:{type:'string'}},derivedKeys:{type:'array',items:{type:'string'}},
33
33
  text:{type:'json',description:'Literal scalar or {ref}/{expr}.'},
34
34
  value:{type:'json',description:'metric: number or display string/boolean, or {ref}/{expr}. progress: numeric.'},
35
- rows:{type:'json',description:'table/chart: inline rows with literal or formula cells, e.g. [{label:"餐饮",value:{ref:"derived.food"}}] or [{label:"住宿",value:{expr:"ceil(state.people / 2) * state.room"}}]. This is the simplest live breakdown; no map required. Also accepts {ref:"data.rows"}/{ref:"derived.rows"}, or scoped reference string.'},
35
+ rows:{type:'json',description:'table/chart: inline object rows, including {ref}/{expr} cells, or data/derived rows binding. No map needed for a short breakdown.'},
36
36
  columns:{type:'json',description:'table: column object array; row: optional integer 1..4 for equal grid columns.'},
37
37
  bind:{type:'string',description:'Controls bind state.key.'},children:{type:'array',description:'row/column/card/details: array of registered nodes using the same fields and type-specific rules. steps uses items instead of children.',items:{type:'json'}}
38
38
  }}},
39
- state:dictionary('key -> state specification object, e.g. {people:{type:"number",default:2,min:1,max:12,step:1}}. Numeric defaults/min/max/step are JSON numbers, no units. String/boolean/json state keeps its declared type; options is a direct scalar array. Do not omit numeric limits. json is bounded array/object state for canvas controls.'),
39
+ state:dictionary('Typed states, e.g. {people:{type:"number",default:2,min:1,max:12,step:1}}. Use numeric bounds/defaults, direct options arrays; json stores bounded canvas arrays/objects.'),
40
40
  data:dictionary('Static data; Chinese identifier keys supported. Omit if rows are inline.'),
41
- derived:dictionary('Local formulas, e.g. {food:{expr:"state.people * state.days * 100"}}. Prefer expr shorthand to deeply nested op/args. round(x), ceil(x), floor(x) and abs(x) each take one argument. ceil(state.people / 2) is supported directly; do not search source or switch to a canvas for rounding. op/args uses direct arrays and numeric constants, not item wrappers. Use ui_catalog for protocol questions, not installation source searches. Omit when unused.'),
41
+ derived:dictionary('Local formulas, e.g. {food:{expr:"state.people * state.days * 100"}}. round/ceil/floor/abs take one argument. Other expression forms/maps: ui_catalog topic=native. Omit unused derived.'),
42
42
  actions:dictionary('Declared agent.followup actions. Omit when unused.'),
43
43
  presentation:dictionary('Optional layout/accent/density and preset: reading/brief/comparison/list/callout/scorecard/flow/minimal. Every document owns its own styles.')
44
44
  }
@@ -47,4 +47,4 @@ export const DOCUMENT_PARAMETER={
47
47
  // Keep the native transport schema shallow. A recursively expanded union of
48
48
  // every component makes the lightweight tool harder to call; the shared
49
49
  // runtime validator still enforces exact component fields and limits.
50
- export const TEXT_BLOCKS_PARAMETER={type:'array',required:true,items:{type:'json'},description:'Direct array of text strings or native v1 nodes with optional IDs. Examples: [{type:"text",text:"Answer"}], or [{type:"steps",items:[{title:"First",description:"One sentence"}]}]. Reusable text: {type:"text",copyable:true,title:"Draft",text:"..."}; code: {type:"code",lang:"bash",code:"..."}, always copyable. Layout nodes use children:[nodes]; text may use spans:[{text,weight?,color?}] instead of text. Keep all content/styling inside blocks. Do not serialize the array or wrap it in {item:[...]}. ui_catalog topic=quick supplies exact fields and examples.'};
50
+ export const TEXT_BLOCKS_PARAMETER={type:'array',required:true,items:{type:'json'},description:'Direct array of strings/native nodes with optional IDs, e.g. [{type:"text",text:"Answer"}]. steps uses items:[{title,description?}], layouts use children:[nodes]. Copyable text: copyable:true; code:{type:"code",lang,code}. Keep styling/content inside blocks. No JSON string or {item:[...]} wrapper.'};
package/lib/ui-intent.js CHANGED
@@ -1,15 +1,4 @@
1
- // Shared behavior, not a visual template: free mode keeps the same intent policy.
2
- export const UI_INTENT_POLICY = `## 表达选择与首次交付
3
- 简单公式可用 {expr:"state.V * state.V / state.R"},无需堆叠 op/args。支持显式 state/data/derived/item 引用、四则运算、比较、三元条件及 min/max/round/abs;编译后仍按原协议校验。函数、语句和其他 JavaScript 不受支持,复杂动画使用画布。
4
- 无需等待用户说“UI”“可视化”或指定工具。先用一个简短判断选择表达方式,然后直接回答或提交界面,不向用户复述设计推理:
5
- - 用户明确要求交互界面、图解或动画:交付对应 UI;已要求纯文字、不要界面、只给代码时遵从用户。
6
- - 用户没提 UI,但问题的关键是参数变化如何影响结果、多个方案的权衡、时间趋势、空间结构或流程关系:当图形或操作比文字明显更易懂时主动使用 UI。不能只因出现数字、多个条目或技术名词就触发。
7
- - 简单事实、单次数值换算、短定义、普通代码排错:直接文字。一个静态小表格足够时不必创建交互应用;静态图解、方案对比、流程和看板也都是 UI 的正常用途。不确定有无收益时选更简单的表达,不询问“要不要我做个 UI”。
8
- 对照:问“复利是什么,简短解释”→文字;问“5万元按3%、5%、7%存10年,为什么后期差距更大”→可主动给增长曲线与年数调整,配准确数值和简短解释;问“解释七速自行车各档如何影响踏频和行进”→可主动给传动示意与档位选择;问“1 GiB是多少字节”→文字。例子只指导时机,不规定构图或配色。
9
- 每块界面只解决一个核心关系;一轮回复可按讲解顺序生成多块独立界面。立即选择最小完整方案并调用工具,不穷举设计选项,不先写长篇视觉规划,不等待“完美”再提交。每块默认用一幅静态主图、一个短流程、一个对齐对比或一个简洁看板即可;没有明确交互收益时不建 state、不写 script、不加控件和动画。静态可视化本身就是完整交付;只有用户需要操作时才保证对应交互。用户的必要要求不能省略。
10
- 仅数值试算优先原生组件;计算超出 derived 白名单(例如幂、三角函数)时不要臆造算子或堆复杂表达式,改用小画布中的单一计算函数同步图与数值。常见装配/齿轮/曲柄连杆讲解优先用 render_mechanism 的少量参数直接交付;其他结构或运动讲解选择自由画布,使用 render_canvas 的独立源码字段,按需读取 mechanism 辅助库接口。避免首次创建多页应用、自制组件库或庞大绘图引擎。除非用户或宿主另有要求,对话内的简短交互讲解无需建立项目、编辑文件或制定任务清单。工具提交前检查绑定、初始图可见、改变参数有反馈、标签/单位和 fallback;不为简单任务反复读全部目录。
11
- 必要的布局与排版从首次提交就做好:DSH 以 PC 横屏为主要使用场景,优先保证 1365px/1920px 桌面视口中实际聊天区域的构图与可读性,不假设画布等于整个浏览器宽度。主体突出、文字可读、控件有标签,装饰不挤占内容;手机适配是补充,不为窄屏牺牲桌面主体。Canvas 2D 按实际 CSS 像素布局,不整体缩放到文字难辨;动态数值和关键标签可放 HTML,绘图高度匹配内容,避免大块空白。
12
- 首屏应能看见主要关系、关键操作及当前结果。用户问“为什么变化不同”,优先用能对照变化的图形;发光、粒子或装饰不能代替趋势或关系证据。主图紧邻控件,推导、完整表格和次要说明随后呈现;同一数值不在指标、图注、卡片和正文反复展示。不要把所有可用组件都用一遍,也不为减少滚动而缩小字。比较不同单位的量,分别标轴或明确使用相对同一基准的无量纲倍数,不能把原始数值放在同一未说明尺度上暗示快慢。这些是信息优先级要求,具体图形、构图与风格仍按问题自行设计。
13
- 不把“最小”理解成粗糙或缺功能。交付所有必要内容后简短说明如何操作和假设;针对实际问题再局部修改。主动增强最多一次且确有价值,这是完成用户要求后的额外润色,不限制同一回复的 UI 数量或必要工具次数;用户要求几块不同内容就分别完成,不能第一块成功后提前结束。自动纠错仍最多两轮。无法交付时提供有效解释并如实说明,不声称已展示。
14
- 交付检查:如果决定生成 UI,先实际调用渲染工具并检查成功结果,或写出完整的 UI 源码围栏,然后再结束回复。“现在补上组件”“我会画图”只是承诺,不是交付。工具报错后修正实际参数并重新提交;若无法完成,给出有用的文字答案和真实失败原因,不用另一句承诺收尾。
15
- 这是一项行为要求,不能保证模型推理时长;宿主没有提供真实计时信息时不要虚报交付秒数。`;
1
+ // Shared behavior, without aesthetic rules or provider inference caps.
2
+ export const UI_INTENT_POLICY=`## 表达选择与实际交付
3
+ 用户明确要求UI、图解、动画或交互时,实际交付;要求纯文字或只给代码时遵从。无需用户点名UI:参数与结果、方案权衡、趋势、流程或空间关系用图形明显更易懂时主动使用。简单事实、短定义、一次换算、普通代码排错直接 plain text。例:1 GiB的字节数→文字;比较不同利率的长期增长→可视化;外啮合齿轮的方向和速比→机制图解。静态排版和图解也是完整UI,不强行加交互。用户强调“一眼看懂先后顺序”“并排比较”“参数变化看结果”时,这是明确的表达目标:即使没提UI,也优先用轻量界面承载;短流程可直接render_scene,不额外添加交互。普通简短列表不强制UI。
4
+ 每块围绕一个核心关系,按要求完成全部内容;一轮可有多块独立UI。先交付最小完整作品,不把“最小”理解成缺功能或粗糙,也不展开冗长设计复盘、穷举方案或为对话界面创建工程。首次工具成功不等于整个任务完成,不能用“准备调用”代替实际交付。外部数据正常用任务工具获取,真实数据与示例/假设分清。`;
package/lib/ui-skill.js CHANGED
@@ -1,22 +1,17 @@
1
- import {MOTION_GUIDE,MOTION_INTERFACE} from './motion-library.js';
2
- import { readFileSync } from 'node:fs';
3
- import { PROTOCOL_GUIDE } from './catalog.js';
4
- import { CANVAS_DESIGN_GUIDE } from './canvas-design.js';
5
- import { FREE_UI_CONTRACT } from './freeform.js';
6
- import { UI_INTENT_POLICY } from './ui-intent.js';
7
- import { CANVAS_SYSTEM_GUIDE } from './canvas-system.js';
8
- import {QUICK_EXAMPLES,QUICK_GUIDE} from './quick-examples.js';
9
- import {TEXT_GUIDE,TEXT_INTERFACE} from './text-guide.js';
10
- import {DEFAULT_VISUAL_STYLE,stylePrompt,styleExamples,visualStyle} from './visual-styles.js';
11
- import {VISUAL_GUIDE,VISUAL_INTERFACE} from './visual.js';
12
-
1
+ import {MOTION_GUIDE} from './motion-library.js';
2
+ import {readFileSync} from 'node:fs';
3
+ import {MODEL_CONTRACT} from './prompt-contract.js';
4
+ import {FREE_UI_CONTRACT} from './freeform.js';
5
+ import {UI_INTENT_POLICY} from './ui-intent.js';
6
+ import {DEFAULT_VISUAL_STYLE,stylePrompt,visualStyle} from './visual-styles.js';
7
+
13
8
  // Authored examples are deliberately small enough to be present in every model's context.
14
9
  export const SKILL_EXAMPLES = [
15
10
  {
16
11
  version: 1, title: '示例 · 数量与费用', fallback: '演示:单价 12 元,数量默认 2,总额 24 元。不是报价。',
17
12
  presentation: { layout: 'workbench', accent: 'teal', density: 'comfortable' },
18
13
  state: { quantity: { type: 'number', default: 2, min: 1, max: 10, step: 1 } },
19
- derived: { total: { op: 'mul', args: [{ ref: 'state.quantity' }, { const: 12 }] } },
14
+ derived: { total: { expr: 'state.quantity * 12' } },
20
15
  nodes: [
21
16
  { id: 'quantity', type: 'slider', label: '数量(件)', bind: 'state.quantity' },
22
17
  { id: 'total', type: 'metric', label: '演示总额', value: { ref: 'derived.total' }, unit: '元', decimals: 0 },
@@ -39,16 +34,14 @@ export const SKILL_EXAMPLES = [
39
34
  }
40
35
  ];
41
36
 
42
- const skill = readFileSync(new URL('../skills/intelligent-ui/SKILL.md', import.meta.url), 'utf8');
43
- export const UI_SKILL_SECTIONS = Object.freeze([
44
- { name: 'generative-ui:design-skill', order: 850, interpolate: false, text: TEXT_GUIDE+'\n\n'+MOTION_GUIDE+'\n\n'+UI_INTENT_POLICY + '\n\n' + skill + '\n\nCanvas composition details:\n' + CANVAS_DESIGN_GUIDE + '\n\nConstruction handbook:\n' + CANVAS_SYSTEM_GUIDE },
45
- { name: 'generative-ui:protocol', order: 851, interpolate: false, text: PROTOCOL_GUIDE },
46
- { name: 'generative-ui:worked-examples', order: 852, interpolate: false, text: QUICK_GUIDE+'\nThese valid render_ui examples start with static flow and comparison; the last adds a control only for a calculation. Adapt to the user, not their demo claims. Submit as render_ui({document: ...}). Animation examples remain available via ui_catalog({topic:"canvas"}).\n' + [QUICK_EXAMPLES[0],QUICK_EXAMPLES[1],SKILL_EXAMPLES[0]].map(doc => JSON.stringify(doc)).join('\n') }
47
- ]);
48
- export function promptSections(mode='guided',style=DEFAULT_VISUAL_STYLE){
49
- if(!['guided','free'].includes(mode))throw new Error('DSH UI: designMode must be guided or free');
50
- visualStyle(style);
51
- return mode==='guided'?UI_SKILL_SECTIONS.map((s,i)=>({...s,text:i===0?VISUAL_GUIDE+'\n\n'+s.text+'\n\n'+stylePrompt(style):i===2?QUICK_GUIDE+'\nSelected visual-style examples follow. Each entry names the tool and its args; call that tool with args only. The shared calculation example preserves the same protocol and interaction.\n'+styleExamples(style)+'\n'+JSON.stringify({tool:'render_ui',args:{document:SKILL_EXAMPLES[0]}}):s.text})):UI_SKILL_SECTIONS.map((s,i)=>({...s,text:i===0?VISUAL_INTERFACE+'\n\n'+TEXT_INTERFACE+'\n\n'+MOTION_INTERFACE+'\n\n'+UI_INTENT_POLICY+'\n\n'+FREE_UI_CONTRACT:''}));
52
- }
53
-
54
-
37
+ const workedExamples=style=>'Selected visual-style examples (adapt the composition to the task; no obligation to reproduce every module):\n'+JSON.stringify(visualStyle(style).examples[0])+'\n'+JSON.stringify({tool:'render_ui',args:{document:SKILL_EXAMPLES[0]}});
38
+ const skill=readFileSync(new URL('../skills/intelligent-ui/SKILL.md',import.meta.url),'utf8');
39
+ export const UI_SKILL_SECTIONS=Object.freeze([
40
+ {name:'generative-ui:design-skill',order:850,interpolate:false,text:UI_INTENT_POLICY+'\n\n'+skill+'\n\n'+MOTION_GUIDE},
41
+ {name:'generative-ui:protocol',order:851,interpolate:false,text:MODEL_CONTRACT},
42
+ {name:'generative-ui:worked-examples',order:852,interpolate:false,text:workedExamples(DEFAULT_VISUAL_STYLE)}
43
+ ]);
44
+ export function promptSections(mode='guided',style=DEFAULT_VISUAL_STYLE){
45
+ if(!['guided','free'].includes(mode))throw Error('DSH UI: designMode must be guided or free');visualStyle(style);
46
+ return UI_SKILL_SECTIONS.map((section,i)=>({...section,text:mode==='free'?(i===0?UI_INTENT_POLICY+'\n\n'+FREE_UI_CONTRACT:''):i===0?section.text+'\n\n'+stylePrompt(style):i===2?workedExamples(style):section.text}));
47
+ }
@@ -0,0 +1,21 @@
1
+ import {defineTool} from '@deepseek-ai/dsh-tools';
2
+ import {VideoStudio,mediaResult,mediaRoute,videoCapabilities} from './video.js';
3
+ import {toolJson,optionalToolJson} from './tool-json.js';
4
+ import {join} from 'node:path';
5
+ import {synthesizeVoice,voiceCapabilities} from './voice.js';
6
+ export const VIDEO_PARAMETERS={width:{type:'number',description:'Even pixel width, default1920, range160..3840'},height:{type:'number',description:'Even pixel height, default1080, range160..2160'},fps:{type:'number',description:'Integer1..60, default30'},audioPath:{type:'string',description:'Optional absolute local soundtrack file; replaces original audio. Omit for silent motion or retained clip sound.'},audioVolume:{type:'number',description:'Soundtrack gain0..1, default0.3'},captions:{type:'array',description:'Exact timed captions, not guessed transcription. Omit for no captions.',items:{type:'object',additionalProperties:false,properties:{start:{type:'number',required:true},end:{type:'number',required:true},text:{type:'string',required:true}}}}};
7
+ const output={schema:{type:'json'},render:(_args,value)=>[{type:'text',text:JSON.stringify(value)+'\n'+value.files.map(f=>`[${f.name}](${f.url})`).join(' · ')}]};
8
+ VIDEO_PARAMETERS.audioTracks={type:'array',description:'Optional 1..4 simultaneous voice/music/effect tracks. Replaces original audio; cannot combine with audioPath. Voice must fit measured duration and cannot loop. Source tracks and mixed soundtrack are retained.',items:{type:'object',additionalProperties:false,properties:{path:{type:'string',required:true},role:{type:'string',enum:['voice','music','effect']},volume:{type:'number'},offset:{type:'number'},loop:{type:'boolean'},fadeIn:{type:'number'},fadeOut:{type:'number'}}}};
9
+ export function registerVideoTools(ctx,store){
10
+ const studio=new VideoStudio(join(store.directory,'media'));
11
+ const execContext=exec=>({sessionId:exec.agent?.id,signal:exec.signal});
12
+ const capabilities=async()=>({...await videoCapabilities(),voice:await voiceCapabilities()});
13
+ ctx.effect(()=>ctx.tools.register(defineTool({name:'preview_video',description:'Quickly capture 1..12 exact source-timeline frames/contact sheet BEFORE encoding a film. Default target1920×1080; omit times to sample chapter interiors. Same saved canvas/main timeline/duration as render_video. Read returned PNGs with host image tools, patch original document, then render final MP4. complete=false returns time/location diagnostics; invalid samples have no image. Fix and preview again. This is not a delivered video or audio review.',parameters:{documentId:{type:'string',required:true},nodeId:{type:'string'},duration:{type:'number',required:true},times:{type:'array',items:{type:'number'}},width:VIDEO_PARAMETERS.width,height:VIDEO_PARAMETERS.height,theme:{type:'json'}},output,async execute(args,exec){exec.signal.throwIfAborted();const record=await store.owned(args.documentId,exec.agent?.id),saved=await store.readState(record.documentId,record.sessionId);return mediaResult(await studio.render({...args,width:args.width??1920,height:args.height??1080,theme:optionalToolJson(args.theme,'theme',undefined),previewTimes:optionalToolJson(args.times,'times',null),record,state:saved.state},execContext(exec)))}})));
14
+ ctx.effect(()=>ctx.tools.register(defineTool({name:'synthesize_voice',description:'Generate real MiniMax narration with provider-measured sentence/word timestamps for a user-requested voiced video. Calls a paid API using existing mmx authentication or MINIMAX_API_KEY, never copies DSH credentials. Return audioPath/duration/captions/words; plan film timing AFTER this result. Display copy may differ from pronunciation copy. Do not invent timestamps or repeat failed paid calls blindly.',parameters:{text:{type:'string',required:true,description:'Narration/pronunciation copy, 1..9999 characters. Exclude stage directions.'},voice:{type:'string',description:'MiniMax voice ID. Default Chinese (Mandarin)_Warm_HeartedGirl.'},model:{type:'string',enum:['speech-2.8-hd','speech-2.8-turbo']},speed:{type:'number',description:'0.5..2, default1'}},output,async execute(args,exec){return mediaResult(await synthesizeVoice(studio,args,execContext(exec)))}})));
15
+ ctx.effect(()=>ctx.tools.register(defineTool({name:'render_video',description:'Export ONE saved canvas to real MP4 plus editable HTML/source snapshot. Requires DSHUI.timeline id main, exact duration and #stage. Only for a user-requested video, not ordinary interactive UI. No API key; local Chromium/FFmpeg/FFprobe required. Use preview_video first and resolve complete=false diagnostics. Source retained on failure. Inspect returned files, do not claim unobserved quality.',parameters:{documentId:{type:'string',required:true},nodeId:{type:'string'},duration:{type:'number',required:true,description:'Seconds0.1..600, exactly matching timeline duration'},theme:{type:'json',description:'Optional canvas theme {scheme:light|dark,bg,soft,ink,muted,line,accent}; choose real CSS colors to match the source. Default light/blue.'},...VIDEO_PARAMETERS},output,async execute(args,exec){exec.signal.throwIfAborted();const record=await store.owned(args.documentId,exec.agent?.id),saved=await store.readState(record.documentId,record.sessionId);return mediaResult(await studio.render({...args,theme:optionalToolJson(args.theme,'theme',undefined),audioTracks:optionalToolJson(args.audioTracks,'audioTracks',undefined),captions:optionalToolJson(args.captions,'captions',undefined),record,state:saved.state},execContext(exec)))}})));
16
+ ctx.effect(()=>ctx.tools.register(defineTool({name:'inspect_video',description:'Read real local-video metadata and optional timestamped JPEG samples/contact sheet. Use host image-reading tools on returned paths to assess visuals; no transcription or full-video understanding is claimed. max12 times; omit times for metadata only. Originals unchanged.',parameters:{path:{type:'string',required:true,description:'Absolute local video file path'},times:{type:'array',items:{type:'number'},description:'Seconds within actual duration; max12 samples'}},output,isConcurrencySafe:()=>true,async execute(args,exec){return mediaResult(await studio.inspect({...args,times:toolJson(args.times??[],'times')},execContext(exec)))}})));
17
+ ctx.effect(()=>ctx.tools.register(defineTool({name:'process_video',description:'Trim and join 1..20 existing local clips in order, fit/pad to output dimensions, preserve clip audio, optionally replace soundtrack and burn exact timed captions. Outputs a new verified MP4/recipe; never overwrites inputs. Caption times refer to joined output. No arbitrary FFmpeg flags or shell commands.',parameters:{clips:{type:'array',required:true,items:{type:'object',additionalProperties:false,properties:{path:{type:'string',required:true},start:{type:'number'},end:{type:'number'}}}},...VIDEO_PARAMETERS},output,async execute(args,exec){return mediaResult(await studio.process({...args,clips:toolJson(args.clips,'clips'),captions:optionalToolJson(args.captions,'captions',undefined)},execContext(exec)))}})));
18
+ ctx.effect(()=>ctx.connection.fetch.register({path:'/api/plugins/dsh-generative-ui/media',methods:['GET','HEAD'],requestBody:'buffered',fetch:mediaRoute(studio)}));
19
+ ctx.effect(()=>ctx.connection.fetch.register({path:'/api/plugins/dsh-generative-ui/media-capabilities',methods:['GET'],requestBody:'buffered',fetch:async()=>Response.json(await capabilities(),{headers:{'Cache-Control':'no-store'}})}));
20
+ return {studio,capabilities};
21
+ }
package/lib/video.js ADDED
@@ -0,0 +1,296 @@
1
+ import {spawn} from 'node:child_process';
2
+ import {mkdir,writeFile,readFile,stat,access,unlink,copyFile} from 'node:fs/promises';
3
+ import {createReadStream} from 'node:fs';
4
+ import {Readable} from 'node:stream';
5
+ import {join,isAbsolute,extname,dirname,resolve,basename} from 'node:path';
6
+ import {randomUUID} from 'node:crypto';
7
+ import {standaloneCanvas} from './canvas-export.js';
8
+ import {archiveProject} from './project-archive.js';
9
+
10
+ const executable=name=>process.env['DSH_MOTION_'+name.toUpperCase()] || name;
11
+ export const VIDEO_STAGE_CSS='html,body,#canvas{margin:0!important;padding:0!important;width:100%!important;height:100%!important;overflow:hidden!important}#canvas :not(#stage):not(#stage *){visibility:hidden!important}#stage{visibility:visible!important;position:fixed!important;inset:0!important;margin:0!important;max-width:none!important;max-height:none!important;aspect-ratio:auto!important;border-radius:0!important}*,*::before,*::after{animation:none!important;transition:none!important}';
12
+ const finite=(value,label,min,max)=>{if(!Number.isFinite(value)||value<min||value>max)throw Error(`Motion video: ${label} must be ${min}..${max}`);return value};
13
+ export function videoSpec(args={}) {
14
+ const width=finite(args.width??1920,'width',160,3840),height=finite(args.height??1080,'height',160,2160),fps=finite(args.fps??30,'fps',1,60),duration=finite(args.duration,'duration',.1,600);
15
+ if(!Number.isInteger(width)||!Number.isInteger(height)||width%2||height%2||!Number.isInteger(fps))throw Error('Motion video: dimensions must be even integers; fps must be an integer');
16
+ if(duration*fps>36000)throw Error('Motion video: composition exceeds 36000 frames; split into chapters, never silently truncate');
17
+ return {width,height,fps,duration,frames:Math.ceil(duration*fps)};
18
+ }
19
+ export function runMedia(command,args,{signal,cwd}={}) {
20
+ // A local playlist must not silently turn an offline media task into network
21
+ // access. Apply the input protocol allowlist to every FFmpeg input/probe.
22
+ if(args.includes('-show_streams')||args.includes('-show_frames'))args=['-protocol_whitelist','file,pipe,data',...args];
23
+ else if(args.includes('-i'))args=args.flatMap(arg=>arg==='-i'?['-protocol_whitelist','file,pipe,data',arg]:[arg]);
24
+ return new Promise((resolve,reject)=>{
25
+ signal?.throwIfAborted();let stdout='',stderr='',settled=false;
26
+ const child=spawn(command,args,{shell:false,windowsHide:true,stdio:['ignore','pipe','pipe'],signal,cwd});
27
+ const fail=e=>{if(!settled){settled=true;reject(e)}};
28
+ child.on('error',e=>fail(Error(`Motion video: ${command} ${e.code==='ENOENT'?'is unavailable; install/configure this local executable':e.message}`)));
29
+ child.stdout.on('data',c=>{stdout+=c;if(stdout.length>2*1024*1024){child.kill();fail(Error('Motion video: process output is too large'))}});
30
+ child.stderr.on('data',c=>{stderr=(stderr+c).slice(-12000)});
31
+ child.on('close',code=>{if(settled)return;settled=true;code===0?resolve(stdout):reject(Error(`Motion video: ${command} exited ${code}: ${stderr.slice(-4000)}`))});
32
+ });
33
+ }
34
+ function frameEncoder(spec,directory,signal){
35
+ const child=spawn(executable('ffmpeg'),['-v','error','-nostdin','-y','-f','image2pipe','-framerate',String(spec.fps),'-protocol_whitelist','file,pipe,data','-i','pipe:0','-frames:v',String(spec.frames),'-c:v','libx264','-pix_fmt','yuv420p','-movflags','+faststart','silent.mp4'],{cwd:directory,signal,windowsHide:true,shell:false,stdio:['pipe','ignore','pipe']});
36
+ let error,stderr='';child.stderr.on('data',b=>stderr=(stderr+b).slice(-4000));
37
+ const done=new Promise((resolve,reject)=>{child.on('error',e=>{error=e;reject(e)});child.on('close',code=>{if(code===0)resolve();else{error=Error('Motion video: frame encoder exited '+code+': '+stderr);reject(error)}})});done.catch(()=>{});
38
+ child.stdin.on('error',e=>{error=e});
39
+ return {async write(png){signal?.throwIfAborted();if(error)throw error;await new Promise((resolve,reject)=>child.stdin.write(png,e=>e?reject(e):resolve()));if(error)throw error},async finish(){child.stdin.end();await done},async stop(){child.stdin.destroy();child.kill();await done.catch(()=>{})}};
40
+ }
41
+ export async function localMediaPath(path) {
42
+ if(typeof path!=='string'||!isAbsolute(path)||path.includes('\0')||/^(\\\\|\/\/)/.test(path))throw Error('Motion video: input must be an absolute local file path, not a URL or network share');
43
+ const info=await stat(path);if(!info.isFile())throw Error('Motion video: input is not a regular file');return path;
44
+ }
45
+ export async function probeVideo(path,signal) {
46
+ await localMediaPath(path);
47
+ const value=JSON.parse(await runMedia(executable('ffprobe'),['-v','error','-show_format','-show_streams','-of','json',path],{signal}));
48
+ const video=value.streams?.find(s=>s.codec_type==='video'),audio=value.streams?.filter(s=>s.codec_type==='audio')??[];
49
+ const duration=Number(value.format?.duration??video?.duration);
50
+ if(!video||!Number.isFinite(duration)||duration<=0)throw Error('Motion video: file has no valid video stream/duration');
51
+ return {path,duration,videoDuration:Number(video.duration)||duration,startTime:Number(value.format?.start_time)||0,width:video.width,height:video.height,codec:video.codec_name,frameRate:video.avg_frame_rate,frames:Number(video.nb_frames)||null,audio:audio.map(a=>({codec:a.codec_name,channels:a.channels,sampleRate:Number(a.sample_rate)})),bytes:Number(value.format?.size)||0};
52
+ }
53
+ export async function videoCapabilities(){
54
+ const result={ffmpeg:false,ffprobe:false,browser:false,requirements:'Local FFmpeg/FFprobe and Chrome/Edge or Playwright Chromium. Configure DSH_MOTION_FFMPEG, DSH_MOTION_FFPROBE, DSH_MOTION_BROWSER if needed; no automatic binary download.'};
55
+ for(const name of ['ffmpeg','ffprobe'])try{await runMedia(executable(name),['-version']);result[name]=true}catch(e){result[name+'Error']=e.message}
56
+ try{const {chromium}=await import('playwright-core');result.browser=await browserOptions(chromium)}catch(e){result.browserError=e.message}
57
+ return result;
58
+ }
59
+ async function browserOptions(chromium){
60
+ if(process.env.DSH_MOTION_BROWSER){await access(process.env.DSH_MOTION_BROWSER);return {executablePath:process.env.DSH_MOTION_BROWSER}}
61
+ try{await access(chromium.executablePath());return {}}catch{}
62
+ const candidates=process.platform==='win32'?[
63
+ join(process.env.ProgramFiles??'C:/Program Files','Google/Chrome/Application/chrome.exe'),
64
+ join(process.env['ProgramFiles(x86)']??'C:/Program Files (x86)','Microsoft/Edge/Application/msedge.exe'),
65
+ join(process.env.LOCALAPPDATA??'','Google/Chrome/Application/chrome.exe')
66
+ ]:process.platform==='darwin'?['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome']:['/usr/bin/chromium','/usr/bin/chromium-browser','/usr/bin/google-chrome'];
67
+ for(const path of candidates)try{await access(path);return {executablePath:path}}catch{}
68
+ throw Error('Motion video: no local Chromium/Chrome/Edge detected; configure DSH_MOTION_BROWSER');
69
+ }
70
+ function walk(nodes){return nodes.flatMap(n=>[n,...walk(n.children??[])])}
71
+ export function findVideoCanvas(document,nodeId){
72
+ const nodes=walk(document.nodes??[]).filter(n=>n.type==='sandbox');
73
+ if(!nodeId&&nodes.length!==1)throw Error('Motion video: specify nodeId when the document has multiple/no canvases');
74
+ const node=nodeId?nodes.find(n=>n.id===nodeId):nodes[0];if(!node)throw Error('Motion video: sandbox nodeId not found');return node;
75
+ }
76
+ export function captionSrt(captions,duration){
77
+ if(!Array.isArray(captions)||captions.length>500)throw Error('Motion video: captions must be an array of at most 500 exact timed cues');
78
+ const stamp=t=>{const ms=Math.round(t*1000);return [Math.floor(ms/3600000),Math.floor(ms/60000)%60,Math.floor(ms/1000)%60].map(n=>String(n).padStart(2,'0')).join(':')+','+String(ms%1000).padStart(3,'0')};
79
+ let end=0;return captions.map((c,i)=>{if(!c||typeof c.text!=='string'||!c.text.trim()||c.text.length>3000||/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(c.text)||c.text.includes('-->'))throw Error('Motion video: invalid caption text');finite(c.start,'caption.start',0,duration);finite(c.end,'caption.end',0,duration);if(c.end<=c.start||c.start<end)throw Error('Motion video: captions must have ordered non-overlapping start/end');end=c.end;return `${i+1}\n${stamp(c.start)} --> ${stamp(c.end)}\n${c.text.replace(/\r/g,'').replace(/\n{2,}/g,'\n')}\n`}).join('\n');
80
+ }
81
+ export function audioTrackSpec(tracks,duration){
82
+ if(!Array.isArray(tracks)||!tracks.length||tracks.length>4)throw Error('Motion video: audioTracks requires 1..4 tracks');
83
+ return tracks.map(t=>{if(!t||typeof t.path!=='string'||!['voice','music','effect'].includes(t.role??'music')||typeof (t.loop??false)!=='boolean')throw Error('Motion video: invalid audio track path/role/loop');return {path:t.path,role:t.role??'music',volume:finite(t.volume??(t.role==='voice'?1:.15),'track.volume',0,2),offset:finite(t.offset??0,'track.offset',0,duration),fadeIn:finite(t.fadeIn??0,'track.fadeIn',0,duration),fadeOut:finite(t.fadeOut??0,'track.fadeOut',0,duration),loop:t.loop??false}});
84
+ }
85
+ export class VideoStudio {
86
+ constructor(directory){this.directory=directory;this.busy=false}
87
+ async job(sessionId,kind){if(typeof sessionId!=='string'||!sessionId)throw Error('Motion video: conversation required');const id=randomUUID(),directory=join(this.directory,id);await mkdir(directory,{recursive:true});return {id,directory,sessionId,kind,createdAt:new Date().toISOString(),status:'preparing',files:[]}}
88
+ async save(job){if(job.recipe){await writeFile(join(job.directory,'recipe.json'),JSON.stringify(job.recipe,null,2),'utf8');if(!job.files.includes('recipe.json'))job.files.push('recipe.json')}await writeFile(join(job.directory,'manifest.json'),JSON.stringify(job,null,2),'utf8');return job}
89
+ async exclusive(task){if(this.busy)throw Error('Motion video: another media job is running; wait for its real result before retrying');this.busy=true;try{return await task()}finally{this.busy=false}}
90
+ async prepareAudio(job,args,signal){
91
+ if(args.audioTracks&&args.audioPath)throw Error('Motion video: choose audioTracks or legacy audioPath, not both');
92
+ if(!args.audioTracks)return args;
93
+ const tracks=audioTrackSpec(args.audioTracks,job.spec.duration),cmd=['-v','error','-nostdin','-y'],filters=[];
94
+ const saved=[];
95
+ for(let i=0;i<tracks.length;i++){
96
+ const t=tracks[i];await localMediaPath(t.path);
97
+ const probe=JSON.parse(await runMedia(executable('ffprobe'),['-v','error','-show_format','-show_streams','-of','json',t.path],{signal}));
98
+ const seconds=Number(probe.format?.duration);if(!probe.streams?.some(s=>s.codec_type==='audio')||!Number.isFinite(seconds)||seconds<=0)throw Error('Motion video: audio track has no decodable duration/audio');
99
+ if(t.role==='voice'&&(t.loop||t.offset+seconds>job.spec.duration+.1))throw Error('Motion video: composition would truncate/repeat narration; extend timeline to fit measured voice duration');
100
+ const ext=extname(t.path).toLowerCase();if(!['.wav','.mp3','.m4a','.aac','.ogg','.flac'].includes(ext))throw Error('Motion video: unsupported audio track extension');
101
+ const file=`audio-${i}${ext}`;await copyFile(t.path,join(job.directory,file));job.files.push(file);saved.push({...t,path:file,duration:seconds});
102
+ const voiceId=basename(dirname(t.path));
103
+ if(t.role==='voice'&&basename(t.path)==='narration.mp3'&&/^[a-f0-9-]{36}$/.test(voiceId)&&resolve(dirname(t.path))===resolve(this.directory,voiceId)){
104
+ const sourceJob=JSON.parse(await readFile(join(dirname(t.path),'manifest.json'),'utf8'));
105
+ if(sourceJob.kind==='voice'&&sourceJob.status==='completed'&&sourceJob.sessionId===job.sessionId){
106
+ for(const [source,suffix]of [['narration.txt','.txt'],['timings.json','-timings.json'],['subtitles.raw.json','-subtitles.json']]){const target=`voice-${i}${suffix}`;await copyFile(join(dirname(t.path),source),join(job.directory,target));job.files.push(target)}
107
+ }
108
+ }
109
+ if(t.loop)cmd.push('-stream_loop','-1');cmd.push('-i',file);
110
+ filters.push(`[${i}:a]aresample=48000,aformat=channel_layouts=stereo,volume=${t.volume},adelay=${Math.round(t.offset*1000)}:all=1,apad,atrim=duration=${job.spec.duration}${t.fadeIn?`,afade=t=in:st=${t.offset}:d=${t.fadeIn}`:''}${t.fadeOut?`,afade=t=out:st=${job.spec.duration-t.fadeOut}:d=${t.fadeOut}`:''}[a${i}]`);
111
+ }
112
+ filters.push(tracks.map((_,i)=>`[a${i}]`).join('')+`amix=inputs=${tracks.length}:normalize=0:duration=longest,alimiter=limit=0.95:level=false[mix]`);
113
+ cmd.push('-filter_complex',filters.join(';'),'-map','[mix]','-t',String(job.spec.duration),'-c:a','pcm_s16le','soundtrack.wav');
114
+ await runMedia(executable('ffmpeg'),cmd,{signal,cwd:job.directory});job.audioTracks=saved;
115
+ return {...args,audioPath:join(job.directory,'soundtrack.wav'),audioVolume:1};
116
+ }
117
+ async render({record,state,nodeId,...args},{signal}={}){return this.exclusive(async()=>{
118
+ const preview=Object.hasOwn(args,'previewTimes'),spec=videoSpec({...args,...(preview?{fps:30}:{})}),node=findVideoCanvas(record.document,nodeId),job=await this.job(record.sessionId,preview?'preview':'render');
119
+ job.spec=spec;job.documentId=record.documentId;job.documentRevision=record.documentRevision;job.nodeId=node.id;
120
+ const theme=args.theme??{scheme:'light',bg:'#fff',soft:'#eef5ff',ink:'#182537',muted:'#526174',line:'#cbd5e1',accent:'#527ef7'};
121
+ let browser,encoder;const warnings=[];job.files=['source.html','document.json'];
122
+ await writeFile(join(job.directory,'source.html'),standaloneCanvas(record.document,node,state,theme));await writeFile(join(job.directory,'document.json'),JSON.stringify({document:record.document,state,theme},null,2));await this.save(job);
123
+ try{
124
+ if(args.captions?.length)captionSrt(args.captions,spec.duration);
125
+ args=await this.prepareAudio(job,args,signal);let soundtrack;
126
+ if(args.audioPath){await localMediaPath(args.audioPath);const ext=extname(args.audioPath).toLowerCase();if(!['.wav','.mp3','.m4a','.aac','.ogg','.flac'].includes(ext))throw Error('Motion video: soundtrack must be WAV/MP3/M4A/AAC/OGG/FLAC');const file='soundtrack'+ext;if(args.audioPath!==join(job.directory,file))await copyFile(args.audioPath,join(job.directory,file));soundtrack={file,volume:finite(args.audioVolume??.3,'audioVolume',0,1)}}
127
+ const source=standaloneCanvas(record.document,node,state,theme,soundtrack,args.captions);
128
+ await writeFile(join(job.directory,'source.html'),source);await writeFile(join(job.directory,'document.json'),JSON.stringify({document:record.document,state},null,2));
129
+ job.files.push(...(soundtrack?[soundtrack.file]:[]));if(soundtrack)job.soundtrack=soundtrack;job.recipe={duration:spec.duration,width:spec.width,height:spec.height,fps:spec.fps,theme,captions:args.captions??[],...(soundtrack?{soundtrack}:{}),...(job.audioTracks?{audioTracks:job.audioTracks}:{})};await this.save(job);
130
+ signal?.throwIfAborted();await runMedia(executable('ffmpeg'),['-version'],{signal});
131
+ const {chromium}=await import('playwright-core');browser=await chromium.launch({...await browserOptions(chromium),headless:true,chromiumSandbox:true});
132
+ const context=await browser.newContext({viewport:{width:spec.width,height:spec.height},deviceScaleFactor:1,serviceWorkers:'block',reducedMotion:'no-preference'});
133
+ const abort=()=>{void browser?.close().catch(()=>{})};signal?.addEventListener('abort',abort,{once:true});
134
+ try{
135
+ await context.route('**/*',route=>route.abort());await context.addInitScript(()=>{window.__DSHMotionRender=true});
136
+ const page=await context.newPage(),errors=[];page.on('pageerror',e=>errors.push(e.message));
137
+ page.on('console',m=>{if(m.type()==='error')warnings.push(m.text())});
138
+ const capture=await context.newCDPSession(page);await capture.send('Runtime.enable');
139
+ const invalidPreview=async diagnostic=>{job.complete=false;job.samples=[];job.diagnostics=[diagnostic];job.repair='Patch the saved documentId/documentRevision using patch_ui (canvas source fields) or update_ui. source.html is an editable snapshot; editing that file alone does NOT update the saved canvas used by render_video.';job.warnings=[...new Set(warnings)].slice(0,8).map(message=>({kind:'browser-resource',message:message.slice(0,1200)}));job.status='completed';job.observation='Initialization diagnostics only; no valid frame, MP4 or audio review. Fix the reported source and preview again.';return this.save(job)};
140
+ // Compile only, never execute authored code in the parent/Host context.
141
+ // Chromium reports author-source coordinates, unlike append(script)'s
142
+ // bootstrap stack or a later generic timeline-readiness timeout.
143
+ const compiled=await capture.send('Runtime.compileScript',{expression:node.script??'',sourceURL:'motion-canvas.js',persistScript:false});
144
+ if(compiled.exceptionDetails){const e=compiled.exceptionDetails,line=e.lineNumber+1,column=e.columnNumber+1,lines=(node.script??'').split('\n'),diagnostic={kind:'script-syntax',line,column,message:(e.exception?.description??e.text).slice(0,1200),excerpt:lines.slice(Math.max(0,line-2),line+1).map((s,i)=>`${Math.max(1,line-1)+i}: ${s}`).join('\n').slice(0,1800)};if(preview)return await invalidPreview(diagnostic);throw Error(`Motion video: script syntax at line ${line}, column ${column}: ${diagnostic.message}\n${diagnostic.excerpt}`)}
145
+ // setContent's parent lifecycle wait can race srcdoc/data-image startup.
146
+ // This wrapper is generated by us; authored code stays in its opaque frame.
147
+ // Readiness is checked explicitly below (#stage, timeline, fonts, images,
148
+ // layout/paint), rather than relying on the parent's DOMContentLoaded.
149
+ await page.evaluate(html=>{document.open();document.write(html);document.close()},source);
150
+ try{await page.frameLocator('iframe').locator('#stage').waitFor({state:'attached',timeout:15000})}catch(e){if(preview)return await invalidPreview({kind:'missing-stage',message:errors[0]??'Define exactly one #stage as the video composition.'});throw e}const frame=page.frames().find(f=>f!==page.mainFrame());
151
+ if(!frame)throw Error('Motion video: source iframe unavailable');
152
+ if(preview&&errors.length)return await invalidPreview({kind:'script-initialization',message:errors[0]});
153
+ try{await frame.waitForFunction(()=>window.__DSHMotionCapture?.list().length>0,{},{timeout:15000})}catch(e){if(preview)return await invalidPreview({kind:'missing-timeline',message:errors[0]??'No timeline registered. Initialize DSHUI.timeline({id:"main",duration,draw}) synchronously; do not wait for clicks or an unavailable resource.'});throw e}
154
+ const timelines=await frame.evaluate(()=>window.__DSHMotionCapture.list()),main=timelines.find(t=>t.id==='main');
155
+ if(!main||timelines.length!==1||Math.abs(main.duration-spec.duration)>.001){const message='Motion video: exactly one timeline id main is required and duration must match export duration';if(preview)return await invalidPreview({kind:'timeline-contract',message,timelines});throw Error(message)}
156
+ const stage=frame.locator('#stage');if(await stage.count()!==1)throw Error('Motion video: define exactly one #stage as the video composition');
157
+ // Freeze browser-driven clocks; each output frame is explicitly sought.
158
+ await frame.evaluate(async()=>{await document.fonts.ready;await Promise.all([...document.images].map(image=>image.decode().catch(()=>{})))});await page.addStyleTag({content:'body{margin:0!important;padding:0!important}main{max-width:none!important}main>h1,main>p,main>details{display:none!important}iframe{position:fixed!important;inset:0!important;width:100vw!important;height:100vh!important;border:0!important}'});
159
+ // Keep ancestor nodes: authors may legitimately wrap #stage in a themed
160
+ // container. Hiding every root sibling used to hide the stage's ancestor.
161
+ await stage.evaluate(el=>{for(let p=el.parentElement;p&&p!==document.body;p=p.parentElement){p.style.setProperty('transform','none','important');p.style.setProperty('filter','none','important');p.style.setProperty('perspective','none','important');p.style.setProperty('contain','none','important')}});
162
+ await frame.addStyleTag({content:VIDEO_STAGE_CSS});
163
+ // Staging changes iframe size. Let resize/ResizeObserver handlers settle
164
+ // before seeking: otherwise the first surface can still contain the old
165
+ // 1100px export preview and transient markup from its previous layout.
166
+ await frame.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))));
167
+ job.warnings=await frame.evaluate(()=>[...document.querySelectorAll('[style]')].filter(el=>el.namespaceURI==='http://www.w3.org/1999/xhtml'&&/url\(\s*["']?#/.test(el.style.backgroundImage)).slice(0,12).map(el=>({kind:'html-background-svg-paint',target:el.id?'#'+el.id:el.tagName.toLowerCase(),message:'HTML background-image cannot use a local SVG paint server url(#id). Use CSS linear-gradient()/radial-gradient() for HTML, or SVG fill=url(#id) on SVG geometry.'})));
168
+ job.status='rendering';await this.save(job);
169
+ if(preview){
170
+ const chapters=[...main.chapters].sort((a,b)=>a.time-b.time);
171
+ const times=args.previewTimes??[...new Set([0,...(chapters.length?chapters.slice(0,10).map((c,i)=>Math.min(spec.duration-.001,c.time+Math.min(1.5,((chapters[i+1]?.time??spec.duration)-c.time)/2))):[spec.duration*.1,spec.duration*.5,spec.duration*.9]),spec.duration-.001])];
172
+ if(!Array.isArray(times)||!times.length||times.length>12)throw Error('Motion video: preview times requires 1..12 timestamps');
173
+ for(const t of times)finite(t,'preview time',0,spec.duration-.001);
174
+ job.samples=[];job.diagnostics=[];if(errors.length)job.diagnostics.push({time:0,phase:'mount',message:errors.join('; ').slice(0,1800)});
175
+ let validCount=0;for(const time of times){
176
+ signal?.throwIfAborted();errors.length=0;
177
+ try{
178
+ await frame.evaluate(t=>window.__DSHMotionCapture.seek('main',t),time);
179
+ await frame.evaluate(()=>new Promise(resolve=>requestAnimationFrame(resolve)));
180
+ if(errors.length)throw Error(errors[0]);
181
+ const file=`sample-${validCount}.png`,shot=await capture.send('Page.captureScreenshot',{format:'png',fromSurface:true,captureBeyondViewport:false,optimizeForSpeed:true});await writeFile(join(job.directory,file),Buffer.from(shot.data,'base64'));validCount++;job.files.push(file);job.samples.push({time,file,valid:true});
182
+ }catch(e){signal?.throwIfAborted();job.diagnostics.push({time,chapter:[...chapters].reverse().find(c=>c.time<=time)?.label,message:e.message.slice(0,1800)});job.samples.push({time,valid:false,error:e.message.slice(0,1200)})}
183
+ }
184
+ if(validCount){await runMedia(executable('ffmpeg'),['-v','error','-y','-i','sample-%d.png','-vf',`scale=480:270:out_range=pc,tile=3x${Math.ceil(validCount/3)},format=yuvj420p`,'-frames:v','1','contact.jpg'],{signal,cwd:job.directory});job.files.push('contact.jpg')}job.chapters=main.chapters;job.status='completed';job.observation='Fast source-frame preview only. Invalid samples have NO image and are explicitly labelled valid=false. complete=false requires fixing diagnostics before final export. No MP4 or audio review was performed.';
185
+ }else{
186
+ encoder=frameEncoder(spec,job.directory,signal);
187
+ for(let i=0;i<spec.frames;i++){
188
+ signal?.throwIfAborted();await frame.evaluate(t=>window.__DSHMotionCapture.seek('main',t),i/spec.fps);
189
+ await frame.evaluate(()=>new Promise(resolve=>requestAnimationFrame(resolve)));
190
+ if(errors.length)throw Error('Motion video: canvas runtime error: '+errors[0]);
191
+ const status=await page.locator('#status').textContent();if(status?.startsWith('画布运行异常'))throw Error('Motion video: '+status);
192
+ // A legitimate transparent first frame must not fail a visibility wait.
193
+ // Same lossless viewport PNG, but skip Playwright's redundant animation/
194
+ // stability waits: all clocks have already been frozen and explicitly sought.
195
+ const shot=await capture.send('Page.captureScreenshot',{format:'png',fromSurface:true,captureBeyondViewport:false,optimizeForSpeed:true});
196
+ await encoder.write(Buffer.from(shot.data,'base64'));
197
+ if(i%Math.max(30,spec.fps*2)===0){job.progress={frames:i+1,totalFrames:spec.frames,seconds:(i+1)/spec.fps};await this.save(job)}
198
+ }
199
+ await encoder.finish();encoder=null;
200
+ }
201
+ }finally{signal?.removeEventListener('abort',abort);await browser.close();browser=null}
202
+ job.warnings=[...(job.warnings??[]),...[...new Set(warnings)].slice(0,8).map(message=>({kind:'browser-resource',message:message.slice(0,1200)}))];
203
+ if(preview){job.complete=job.diagnostics.length===0&&job.warnings.length===0;job.repair=job.complete?'Source frame checks passed; final MP4/audio review remains.':'Patch the saved documentId/documentRevision with patch_ui/update_ui, then preview again. Editing an exported snapshot alone does not change the saved canvas.';return await this.save(job)}
204
+ job.status='encoding';job.progress={frames:spec.frames,totalFrames:spec.frames,seconds:spec.duration};await this.save(job);await this.finishVideo(job,join(job.directory,'silent.mp4'),args,signal);
205
+ const projectFiles=job.files.filter(f=>!['video.mp4','poster.jpg'].includes(f));
206
+ // Shareable projects contain relative asset names, never internal session
207
+ // ownership, absolute profile paths or runtime job manifests.
208
+ await writeFile(join(job.directory,'project.json'),JSON.stringify({format:'dsh-motion-project',version:1,composition:spec,source:'source.html',document:'document.json',recipe:'recipe.json',files:projectFiles},null,2));job.files.push('project.json');
209
+ await this.save(job);await archiveProject(job.directory,[...projectFiles,'project.json']);job.files.push('project.zip');
210
+ await unlink(join(job.directory,'silent.mp4'));
211
+ return await this.save(job);
212
+ }catch(e){job.status='failed';job.error=e.message;await this.save(job);throw Error(`${e.message}; editable source retained: ${join(job.directory,'source.html')}; manifest: ${join(job.directory,'manifest.json')}`)}finally{await encoder?.stop();await browser?.close().catch(()=>{})}
213
+ })}
214
+ async finishVideo(job,input,args,signal){
215
+ const output=join(job.directory,'video.mp4'),cmd=['-v','error','-nostdin','-y','-i',input],filters=[];
216
+ if(args.audioPath){await localMediaPath(args.audioPath);finite(args.audioVolume??.3,'audioVolume',0,1);cmd.push('-i',args.audioPath);filters.push(`[1:a]volume=${args.audioVolume??.3},apad,atrim=duration=${job.spec.duration}[sound]`)}
217
+ if(args.captions?.length){await writeFile(join(job.directory,'captions.srt'),captionSrt(args.captions,job.spec.duration));job.files.push('captions.srt');filters.push("[0:v]subtitles=captions.srt:force_style='FontSize=13,Outline=0.7,Shadow=0,MarginV=14,Alignment=2'[v]")}
218
+ if(filters.length)cmd.push('-filter_complex',filters.join(';'));
219
+ cmd.push('-map',args.captions?.length?'[v]':'0:v:0','-map',args.audioPath?'[sound]':'0:a?','-t',String(job.spec.duration),'-c:v',args.captions?.length?'libx264':'copy','-c:a','aac','-pix_fmt','yuv420p','-movflags','+faststart',output);
220
+ await runMedia(executable('ffmpeg'),cmd,{signal,cwd:job.directory});
221
+ const probe=await probeVideo(output,signal);
222
+ const [rateNum,rateDen]=probe.frameRate.split('/').map(Number);
223
+ if(probe.width!==job.spec.width||probe.height!==job.spec.height||Math.abs(rateNum/rateDen-job.spec.fps)>.01||Math.abs(probe.duration-job.spec.duration)>Math.max(.15,2/job.spec.fps))throw Error('Motion video: encoded output dimensions/fps/duration do not match the composition');
224
+ if(args.audioPath&&!probe.audio.length)throw Error('Motion video: requested soundtrack was not encoded');
225
+ await runMedia(executable('ffmpeg'),['-v','error','-nostdin','-i',output,'-f','null','-'],{signal});
226
+ await runMedia(executable('ffmpeg'),['-v','error','-nostdin','-y','-ss',String(Math.min(job.spec.duration*.5,job.spec.duration-1/job.spec.fps)),'-i',output,'-frames:v','1','-vf','scale=out_range=pc,format=yuvj420p','-pix_fmt','yuvj420p','-color_range','pc','poster.jpg'],{signal,cwd:job.directory});
227
+ job.files.push('video.mp4','poster.jpg');job.output=probe;job.audio=job.audioTracks?'mixed voice/music/effects':args.audioPath?'supplied soundtrack replaces original audio':probe.audio.length?'original clip audio':'silent';job.status='completed';
228
+ }
229
+ async inspect({path,times=[]},{sessionId,signal}={}){
230
+ const probe=await probeVideo(path,signal);if(!Array.isArray(times)||times.length>12)throw Error('Motion video: times must contain at most 12 sample timestamps');
231
+ for(const time of times)finite(time,'sample time',0,Math.max(0,probe.duration-.001));
232
+ const job=await this.job(sessionId,'inspect');job.input=probe;job.samples=[];await this.save(job);
233
+ try{
234
+ const [n,d]=probe.frameRate.split('/').map(Number),fps=n/d,step=Number.isFinite(fps)&&fps>0?1/fps:.05;
235
+ let lastFrame;
236
+ for(let i=0;i<times.length;i++){
237
+ signal?.throwIfAborted();const requestedTime=times[i],file=`sample-${i}.jpg`;let time=Math.min(requestedTime,Math.max(0,probe.videoDuration-step-.000001));
238
+ const capture=async()=>runMedia(executable('ffmpeg'),['-v','error','-nostdin','-y','-ss',String(time),'-i',path,'-frames:v','1','-vf','scale=960:540:force_original_aspect_ratio=decrease:out_range=pc,pad=960:540:(ow-iw)/2:(oh-ih)/2,format=yuvj420p','-pix_fmt','yuvj420p','-color_range','pc',file],{signal,cwd:job.directory});
239
+ await capture();
240
+ if(!await stat(join(job.directory,file)).then(s=>s.size>0,()=>false)){
241
+ // VFR/long final-frame holds cannot be located from average FPS alone.
242
+ // Ask the decoder for actual tail timestamps, never invent a last image.
243
+ if(lastFrame===undefined){const tail=JSON.parse(await runMedia(executable('ffprobe'),['-v','error','-read_intervals',`${Math.max(0,probe.duration-1)}%`,'-select_streams','v:0','-show_frames','-show_entries','frame=best_effort_timestamp_time','-of','json',path],{signal}));lastFrame=Math.max(...(tail.frames??[]).map(f=>Number(f.best_effort_timestamp_time)-probe.startTime).filter(Number.isFinite))}
244
+ if(!Number.isFinite(lastFrame)||lastFrame<0)throw Error('Motion video: no decodable frame at requested sample time '+requestedTime);
245
+ time=Math.max(0,lastFrame-.000001);await capture();
246
+ }
247
+ if(!await stat(join(job.directory,file)).then(s=>s.size>0,()=>false))throw Error('Motion video: decoder produced no sample image at '+requestedTime);
248
+ job.files.push(file);job.samples.push({time,requestedTime,file,boundaryAdjusted:Math.abs(time-requestedTime)>.00001});
249
+ }
250
+ if(times.length>1){await runMedia(executable('ffmpeg'),['-v','error','-nostdin','-y','-i','sample-%d.jpg','-vf',`scale=320:180:out_range=pc,tile=3x${Math.ceil(times.length/3)},format=yuvj420p`,'-frames:v','1','-pix_fmt','yuvj420p','-color_range','pc','contact.jpg'],{signal,cwd:job.directory});job.files.push('contact.jpg')}
251
+ job.status='completed';job.observation='Metadata and sparse frames only, not ASR/full-video understanding. time is the actual seek position; decoded frame PTS may be slightly later. requestedTime is preserved; near-EOF samples adjust to a decodable video frame.';return this.save(job);
252
+ }catch(e){job.status='failed';job.error=e.message;await this.save(job);throw e}
253
+ }
254
+ async process(args,{sessionId,signal}={}){return this.exclusive(async()=>{
255
+ if(!Array.isArray(args.clips)||!args.clips.length||args.clips.length>20)throw Error('Motion video: clips must contain 1..20 local videos');
256
+ const clips=[];for(const c of args.clips){const probe=await probeVideo(c.path,signal),start=c.start??0,end=c.end??probe.duration;finite(start,'clip.start',0,probe.duration);finite(end,'clip.end',0,probe.duration);if(end<=start)throw Error('Motion video: clip end must follow start');clips.push({...c,start,end,probe})}
257
+ const duration=clips.reduce((n,c)=>n+c.end-c.start,0),spec=videoSpec({...args,duration}),job=await this.job(sessionId,'process');job.spec=spec;job.clips=clips.map(({probe,...c})=>c);job.recipe={...args,clips:job.clips};await this.save(job);
258
+ try{
259
+ args=await this.prepareAudio(job,args,signal);if(job.audioTracks)job.recipe.audioTracks=job.audioTracks;
260
+ const anyAudio=clips.some(c=>c.probe.audio.length);
261
+ for(let i=0;i<clips.length;i++){
262
+ const c=clips[i],seconds=c.end-c.start,cmd=['-v','error','-nostdin','-y','-ss',String(c.start),'-i',c.path];
263
+ if(anyAudio&&!c.probe.audio.length)cmd.push('-f','lavfi','-i','anullsrc=r=48000:cl=stereo');
264
+ cmd.push('-map','0:v:0');if(anyAudio)cmd.push('-map',c.probe.audio.length?'0:a:0':'1:a:0');cmd.push('-t',String(seconds),'-vf',`scale=${spec.width}:${spec.height}:force_original_aspect_ratio=decrease,pad=${spec.width}:${spec.height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${spec.fps}`,'-c:v','libx264','-pix_fmt','yuv420p','-c:a','aac','-ar','48000','-ac','2',`clip-${i}.mp4`);
265
+ await runMedia(executable('ffmpeg'),cmd,{signal,cwd:job.directory});
266
+ }
267
+ // Only generated constant filenames enter the concat list.
268
+ await writeFile(join(job.directory,'clips.txt'),clips.map((_,i)=>`file 'clip-${i}.mp4'`).join('\n'));
269
+ await runMedia(executable('ffmpeg'),['-v','error','-nostdin','-y','-f','concat','-safe','1','-i','clips.txt','-c','copy','joined.mp4'],{signal,cwd:job.directory});
270
+ await this.finishVideo(job,join(job.directory,'joined.mp4'),args,signal);
271
+ for(let i=0;i<clips.length;i++)await unlink(join(job.directory,`clip-${i}.mp4`));await unlink(join(job.directory,'joined.mp4'));await unlink(join(job.directory,'clips.txt'));
272
+ return this.save(job);
273
+ }catch(e){job.status='failed';job.error=e.message;await this.save(job);throw Error(`${e.message}; retained job manifest: ${join(job.directory,'manifest.json')}`)}
274
+ })}
275
+ }
276
+ export function mediaResult(job){
277
+ const base='/api/plugins/dsh-generative-ui/media?assetId='+job.id+'&sessionId='+encodeURIComponent(job.sessionId)+'&file=';
278
+ // The Host requires lossless JSON, not merely a JSON.stringify-able object.
279
+ // Optional internal undefined properties must be omitted from tool output.
280
+ return JSON.parse(JSON.stringify({...job,files:job.files.map(file=>({name:file,path:join(job.directory,file),url:base+encodeURIComponent(file)})),manifestPath:join(job.directory,'manifest.json')},(_key,value)=>{if(typeof value==='number'&&!Number.isFinite(value))throw Error('Motion video: non-finite result metadata');return value}));
281
+ }
282
+ export function mediaRoute(studio){return async request=>{
283
+ try{
284
+ const url=new URL(request.url),id=url.searchParams.get('assetId'),file=url.searchParams.get('file');
285
+ if(!/^[a-f0-9-]{36}$/.test(id??'')||!file||!/^([\w-]+\.(mp4|html|json|jpg|png|srt|wav|mp3|m4a|aac|ogg|flac|zip|txt))$/.test(file))return new Response('Invalid media asset',{status:400});
286
+ const job=JSON.parse(await readFile(join(studio.directory,id,'manifest.json'),'utf8'));
287
+ if(job.sessionId!==url.searchParams.get('sessionId'))return new Response('Media belongs to another conversation',{status:403});
288
+ if(!job.files.includes(file))return new Response('Unknown media file',{status:404});
289
+ const path=join(studio.directory,id,file),info=await stat(path),type=file.endsWith('.mp4')?'video/mp4':file.endsWith('.jpg')?'image/jpeg':file.endsWith('.png')?'image/png':'application/octet-stream';
290
+ const headers={'Content-Type':type,'Content-Length':String(info.size),'X-Content-Type-Options':'nosniff','Content-Security-Policy':"sandbox; default-src 'none'",'Cache-Control':'private, no-cache','Accept-Ranges':'bytes'};
291
+ if(!['video/mp4','image/jpeg','image/png'].includes(type))headers['Content-Disposition']=`attachment; filename="${file}"`;
292
+ const range=request.headers.get('range');let start=0,end=info.size-1,status=200;
293
+ if(range){const match=/^bytes=(\d+)-(\d*)$/.exec(range);if(!match)return new Response(null,{status:416,headers:{'Content-Range':`bytes */${info.size}`}});start=Number(match[1]);end=match[2]?Math.min(end,Number(match[2])):end;if(start>end||start>=info.size)return new Response(null,{status:416,headers:{'Content-Range':`bytes */${info.size}`}});status=206;headers['Content-Range']=`bytes ${start}-${end}/${info.size}`;headers['Content-Length']=String(end-start+1)}
294
+ return new Response(request.method==='HEAD'?null:Readable.toWeb(createReadStream(path,{start,end})),{status,headers});
295
+ }catch(e){return new Response(e.code==='ENOENT'?'Media not found':'Media unavailable',{status:e.code==='ENOENT'?404:500})}
296
+ }}
@@ -1,18 +1,29 @@
1
1
  // Serialized into the opaque frame; it never reads host content.
2
2
  export function measureVisual(root, nodeId='') {
3
3
  const issues=[],seen=new Set(),coverage={nodeId,elements:0,truncated:false,contrastChecked:0,contrastUncovered:0};
4
- const add=(code,value,el)=>{const id=el?.closest('[data-ui-node]')?.getAttribute('data-ui-node')??nodeId;const k=id+':'+code+':'+Math.round(value??0);if(!seen.has(k)&&issues.length<40){seen.add(k);issues.push({code,nodeId:id,...(value===undefined?{}:{value:Number(value.toFixed(2))})});}};
4
+ const styleCache=new WeakMap(),styleOf=el=>{let css=styleCache.get(el);if(!css){css=getComputedStyle(el);styleCache.set(el,css)}return css};
5
+ const target=el=>{if(!el)return undefined;const tag=el.tagName.toLowerCase(),id=el.id;return{selector:id&&id.length<=64?'#'+CSS.escape(id):tag,text:(el.textContent??'').trim().replace(/\s+/g,' ').slice(0,120),tag}};
6
+ const add=(code,value,el)=>{if(issues.length>=40)return;const id=el?.closest('[data-ui-node]')?.getAttribute('data-ui-node')??nodeId;const location=target(el);const k=id+':'+code+':'+(location?.selector??'')+':'+(location?.text??'')+':'+Math.round(value??0);if(!seen.has(k)&&issues.length<40){seen.add(k);issues.push({code,nodeId:id,...(location?{target:location}:{}),...(value===undefined?{}:{value:Number(value.toFixed(2))})});}};
5
7
  const color=s=>{let m=s.match(/^rgba?\(([^)]+)\)/);if(m){const a=m[1].split(/[\s,\/]+/).map(Number);return[a[0]/255,a[1]/255,a[2]/255,a[3]??1]}m=s.match(/^color\(srgb ([^)]+)\)/);if(m){const a=m[1].split(/[\s\/]+/).map(Number);return[a[0],a[1],a[2],a[3]??1]}return null;};
6
8
  const luminance=c=>c.slice(0,3).map(v=>v<=.04045?v/12.92:((v+.055)/1.055)**2.4).reduce((s,v,i)=>s+v*[.2126,.7152,.0722][i],0);
9
+ const blend=(front,back)=>front.slice(0,3).map((v,i)=>v*front[3]+back[i]*(1-front[3])).concat(1);
10
+ const background=el=>{const layers=[];let n=el;for(let i=0;n&&i<64;i++,n=n.parentElement){const c=styleOf(n);if(c.backgroundImage!=='none')return null;const colorValue=color(c.backgroundColor);if(!colorValue)return null;if(colorValue[3]>0)layers.push(colorValue);if(colorValue[3]===1){let out=layers.pop();while(layers.length)out=blend(layers.pop(),out);return out;}}return null;};
11
+ let contentBottom=root.getBoundingClientRect().top;
7
12
  const widthRoot=root.id==='canvas'?document.documentElement:root;if(widthRoot.scrollWidth>widthRoot.clientWidth+2)add('overflow',widthRoot.scrollWidth-widthRoot.clientWidth);
8
13
  const walker=document.createTreeWalker(root,NodeFilter.SHOW_ELEMENT);let el;while((el=walker.nextNode())){if(coverage.elements>=2000){coverage.truncated=true;break}coverage.elements++;if(el.matches('[data-canvas-phase=error]'))add('runtimeError',undefined,el);
9
- const css=getComputedStyle(el),box=el.getBoundingClientRect();if(!box.width||!box.height||css.visibility==='hidden'||css.display==='none')continue;
14
+ const css=styleOf(el),box=el.getBoundingClientRect();if(!box.width||!box.height||css.visibility==='hidden'||css.display==='none')continue;
10
15
  const text=[...el.childNodes].some(n=>n.nodeType===3&&n.textContent.trim());
11
16
  if(text){let size=parseFloat(css.fontSize);if(el instanceof SVGElement&&el.getScreenCTM){const m=el.getScreenCTM();if(m)size*=Math.hypot(m.c,m.d)}if(size<11.9)add('smallText',size,el);
12
- const fg=color(css.color);let ancestor=el,bg=null,depth=0;while(ancestor&&depth++<64){const style=getComputedStyle(ancestor);if(style.backgroundImage!=='none')break;const c=color(style.backgroundColor);if(c?.[3]===1){bg=c;break}if(c?.[3]>0)break;ancestor=ancestor.parentElement;}
13
- if(!(el instanceof SVGElement)&&fg?.[3]===1&&bg){coverage.contrastChecked++;const a=luminance(fg),b=luminance(bg),ratio=(Math.max(a,b)+.05)/(Math.min(a,b)+.05);if(ratio<(size>=24||(size>=18.66&&parseFloat(css.fontWeight)>=700)?3:4.5))add('contrast',ratio,el);}else coverage.contrastUncovered++;
17
+ if(!(el instanceof SVGElement)){
18
+ const fg=color(css.color),bg=background(el);let opacitySafe=true,opacityDepth=0;for(let a=el;a;a=a.parentElement){if(++opacityDepth>64||parseFloat(styleOf(a).opacity)<1){opacitySafe=false;break}}
19
+ if(fg&&bg&&opacitySafe){coverage.contrastChecked++;const a=luminance(blend(fg,bg)),b=luminance(bg),ratio=(Math.max(a,b)+.05)/(Math.min(a,b)+.05);if(ratio<(size>=24||(size>=18.66&&parseFloat(css.fontWeight)>=700)?3:4.5))add('contrast',ratio,el);}else coverage.contrastUncovered++;
20
+ const range=document.createRange();range.selectNodeContents(el);const bounds=range.getBoundingClientRect();let a=el,depth=0;while(a&&a!==root.parentElement&&depth++<24){const c=styleOf(a),clip=a.getBoundingClientRect();if((['hidden','clip'].includes(c.overflowX)&&(bounds.left<clip.left-2||bounds.right>clip.right+2))||(['hidden','clip'].includes(c.overflowY)&&(bounds.top<clip.top-2||bounds.bottom>clip.bottom+2))){add('clippedText',undefined,el);break}a=a.parentElement}
21
+ }else coverage.contrastUncovered++;
22
+
14
23
  }
24
+ if(text||el.matches('svg,canvas,img,video,input,select,textarea,button'))contentBottom=Math.max(contentBottom,box.bottom);
15
25
  if(el.matches('input:not([type=hidden]),select,textarea,button,[role=button]')&&!el.getAttribute('aria-label')&&!el.getAttribute('aria-labelledby')&&!el.labels?.length&&!el.textContent.trim()&&!el.getAttribute('title'))add('missingLabel',undefined,el);
16
26
  }
27
+ const rect=root.getBoundingClientRect(),gap=rect.bottom-contentBottom;if(!coverage.truncated&&root.id==='canvas'&&rect.height>320&&gap>Math.max(96,rect.height*.25))add('bottomWhitespace',gap,root);
17
28
  Object.defineProperty(issues,'coverage',{value:coverage});return issues;
18
29
  }