dsh-motion-ui 0.7.1 → 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.
package/lib/freeform.js CHANGED
@@ -1,8 +1,8 @@
1
- import { toolJson } from './tool-json.js';
1
+ import { optionalToolJson } from './tool-json.js';
2
2
  import { validateDocument } from './protocol.js';
3
3
  import {MODEL_CONTRACT} from './prompt-contract.js';
4
4
  export function canvasDocument({title,fallback,html,css,script,height,libraries,state,data,derived}){
5
- const values={state:toolJson(state??{},'state'),data:toolJson(data??{},'data'),derived:toolJson(derived??{},'derived')};
5
+ const values={state:optionalToolJson(state,'state',{}),data:optionalToolJson(data,'data',{}),derived:optionalToolJson(derived,'derived',{})};
6
6
  for(const [key,value] of Object.entries(values))if(!value||typeof value!=='object'||Array.isArray(value))throw new Error('DSH UI: '+key+' must be an object');
7
7
  return validateDocument({version:1,title,fallback,...values,nodes:[{id:'canvas',type:'sandbox',title,fallback,html,...(css===undefined?{}:{css}),...(script===undefined?{}:{script}),...(height===undefined?{}:{height}),...(libraries===undefined?{}:{libraries}),stateKeys:Object.keys(values.state),dataKeys:Object.keys(values.data),derivedKeys:Object.keys(values.derived)}]});
8
8
  }
package/lib/inspection.js CHANGED
@@ -1,6 +1,14 @@
1
1
  import {componentFields} from './components.js';
2
+ import {toolJson} from './tool-json.js';
3
+ export function normalizeNodeIds(value){
4
+ if(typeof value==='string'&&!/^[\s\uFEFF]*[\[{"]/.test(value))return[value];
5
+ value=toolJson(value,'nodeIds');
6
+ if(value&&typeof value==='object'&&!Array.isArray(value)&&Object.keys(value).length===1&&Object.hasOwn(value,'item'))value=Array.isArray(value.item)?value.item:[value.item];
7
+ return value;
8
+ }
2
9
  // Keep the default observation small; request exact node source only when editing it.
3
10
  export function inspectRecord(record,state,derived,audits,{detail='summary',nodeIds=[],query=''}={}){
11
+ nodeIds=normalizeNodeIds(nodeIds);
4
12
  if(!['summary','nodes','full'].includes(detail))throw new Error('DSH UI: invalid inspection detail');
5
13
  if(typeof query!=='string'||query.length>200)throw new Error('DSH UI: inspection query must be text of at most200 characters');
6
14
  const locations=new Map();const flat=(list,parentId=null,path='nodes')=>list.flatMap((n,index)=>{locations.set(n.id,{path:path+'['+index+']',parentId,position:index+1});return[n,...flat(n.children??[],n.id,path+'['+index+'].children')]});const nodes=flat(record.document.nodes);
@@ -0,0 +1,31 @@
1
+ import {validateDocument} from './protocol.js';
2
+
3
+ export const MOTION_CRAFT_GUIDE = `Motion craft: give movement a purpose (feedback, spatial continuity, causality or an occasional expressive moment). Choose one focal action; let connected objects react, then hold long enough to read. Keep identity/direction continuous across stages. Visual style controls composition/material/type; motion grammar controls how change is explained, across every style. Precise mechanisms need readable relationships, not arbitrary bounce. Quiet reading moments may be still. Use short interruptible transitions for frequent controls; never delay keyboard/click feedback. Avoid restarting entrances on state updates. Retrieve a targeted motion recipe for timelines, stages, path drawing or text; do not load the whole library.`;
4
+ export const TIMELINE_CONTRACT = `Optional DSHUI.timeline({id?:"main",duration:seconds,draw(time,info),loop?:false,autoplay?:true,chapters?:[{time,label}]}) returns state(),seek(seconds),play(),pause(),replay(),chapter(index),subscribe(fn)->cleanup,dispose(). draw(time,info) receives seconds, not a Canvas context. For Canvas2D use const ctx=canvas.getContext("2d"); draw(t){drawFrame(ctx,t)}. draw uses ABSOLUTE time and current parameters: no accumulating phase, Date.now, random-per-frame, independent CSS animation or asynchronous draw for exportable films. Seed/cache procedural detail. Timelines pause offscreen/background and honor reduced motion; seeking remains available. Build labelled controls when requested, outside the exported stage if appropriate. Read an input value before pause/seek, since subscribers may synchronize its value. Transient time is not persisted each frame. ui_catalog topic=motion scene=timeline/stages/path/typography returns one example. Video export captures a single saved sandbox with timeline id main; author its duration to match export duration.`;
5
+ export const VIDEO_GUIDE = `Video tools: preview_video captures exact source frames/contact sheet before encoding; read them and patch first. inspect_video reads an actual local video and optionally extracts timestamped JPEG samples/contact sheet; frames are sparse observations, not full semantic analysis or ASR. Read returned images with the host image tool. render_video exports one saved canvas documentId/nodeId to editable source + MP4; call render_canvas first, author DSHUI.timeline id main with #stage, then render_video only for an actual video request. process_video trims/joins local clips. synthesize_voice produces real narration, duration and provider-measured captions/words using configured MiniMax authentication (paid); it does not transcribe input video. Fit the timeline AFTER measuring voice duration. render_video/process_video audioTracks:[{path,role:voice|music|effect,volume,offset,loop,fadeIn,fadeOut}] mixes up to four tracks and replaces original audio. Voice cannot repeat or be cut; music can loop/fade at low volume. Alternatively legacy audioPath/audioVolume replaces soundtrack. captions:[{start,end,text}] uses exact output seconds; do not stretch captions across silent gaps. Sources/audio/recipe stay editable. No API key is needed for HTML motion, local Chrome/Edge + FFmpeg/FFprobe required. No invented assets or guessed timestamps. For a promotional-film brief query ui_catalog topic=video scene=promo for the production workflow. Failed export retains assets; never label queued/HTML-only output as a finished film.`;
6
+
7
+ export const PROMO_GUIDE = `One-brief film workflow (on demand, not everyday UI):
8
+ Choose the film language first. An explainer follows narration and gives examples time to teach. A product launch/reel follows musical beats and showcases authored visual capability: oversized kinetic type, bold art direction, object morphs, spatial/exploded diagrams and a curated montage. Do not turn every brief into a narrated pastel-card feature list. Read user reference frames before choosing the treatment; distinguish the actual film from a social video's surrounding title/border. Respect explicit reference direction over default styling. Music-led films can use concise on-stage copy without narration or subtitle tracks; voice is not mandatory. Preserve readable holds between energetic cuts and derive motion/cuts from one seekable timeline. Use measured/declared musical timing, label estimated BPM, and never claim to have listened when only audio metadata/spectra were inspected.
9
+ 1. Read the user's creative intent: explanatory film or expressive launch montage. For an explainer extract audience, promise and benefits; for a visual reel define palette, typography, spatial/material language, energy arc and brand ending. A visual reel may be abstract and need not explain features scene by scene or retain one object for the entire film. Use sensible defaults for unspecified creative choices; deliver rather than stopping at a plan. Never invent release availability, screenshots, benchmark claims or links.
10
+ 2. Only when narration is requested, write concise copy about problems solved and benefits; demonstrations support it. For music-led reels skip narration and use only the necessary brand/type. Keep displayed titles short and pronunciation copy separate. If voice is requested and configured, call synthesize_voice ONCE with the complete copy, then use returned measured duration/captions/words. No fabricated speech or time interpolation. If voice is unavailable report it and deliver the supported silent version; do not hunt for credentials.
11
+ 3. Choose timing for the chosen film language. Narrated explainers follow measured sentences and benefits. Music-led reels follow musical accents and visual contrast. Vary compositions: oversized kinetic type, morphing abstract charts, mechanical assembly, rotating volumes, organized particle fields and light paths are valid main visuals without explanatory captions. Choose motifs suited to the brief, not a mandatory checklist. Connect shots through motion direction, shape matching, occlusion and camera/scale hand-offs; allow energetic cuts and contrasting palettes with readable brand holds. Do not reduce a reel to repeated cards, a small dashboard or literal feature demonstrations. Prefer original HTML/SVG/Canvas artwork; keep abstract graphics free of invented benchmark claims and label schematic product UI as illustrative.
12
+ 4. Author one render_canvas #stage and one absolute-time DSHUI.timeline({id:'main',duration,draw,chapters}). Scope all graphics to the stage. Use a fixed 1920×1080 design coordinate system scaled to the viewport, minimum readable type about32px for key body copy, safe margins, subtitle clearance (reserve the bottom140px at1080p when captions are enabled; keep labels/footnotes above that area). Avoid CSS-only animations/random clocks; draw every property from t so seeking backwards recreates the frame. Gate flash/mask overlays to their actual active interval: a clamped fade-out is still1 BEFORE its start, so explicitly return alpha0 outside the interval. Restore Canvas drawing state between groups. Vary typography/space/shape while following the selected guided style; free mode chooses its own design. No glass unless explicitly requested.
13
+ 5. Use preview_video to inspect the first and last frames plus all scene interiors BEFORE full MP4 encoding; actually read returned contact sheet and individual frames with the host image tool. It costs only a few captures rather than rendering the whole film. complete=false reports location/time-specific diagnostics; patch those and preview again until complete=true. Valid frames are retained even when another frame fails; never treat an invalid sample as a checked image. Resolve returned location-specific warnings; HTML backgrounds use CSS gradients, SVG paint url(#id) belongs on SVG fill/stroke, not an HTML background-image. For moving numerical examples derive results from the same rounded inputs shown. Syntax diagnostics include author-script line/column without executing it in the Host. Patch the saved documentId with patch_ui/update_ui; exported HTML is a snapshot and editing it alone does not update render_video input; avoid generating another UI for the same film. Render final requested dimensions/fps, mix measured voice at volume1 with quieter music (roughly0.05–0.15); music-led reels may use music at normal level (roughly0.7–1), with fadeOut; do not overwrite sources or claim an unlicensed soundtrack is royalty-free. Include exact captions or authored on-stage copy; captions must disappear in gaps. Never truncate voice to fit an arbitrary round duration.
14
+ 6. Delivery gate: readable text, varied compositions, no visible overflow/blank scenes, requested audio/captions fit, clean source preview, real MP4 and editable assets. Evaluate the requested film language: explainers need visible causal demonstrations; visual launch reels need strong art direction, coherent spatial motion, smooth shot hand-offs and musical energy. Abstract graphics and expressive montage are complete outcomes; no slider or literal feature demonstration is mandatory. Replacing static card colors is insufficient. Inspect opening, at least one transition pair and the finale as well as scene interiors. Once these are met, deliver; do not keep re-rendering an otherwise usable film for minor aesthetic preferences. This is an outcome criterion, not a thinking/output/time limit. Inspect final metadata plus scene samples. If the same unexpected blank appears in the source preview AND an actually decoded MP4 frame at that time, inspect authored masks/opacity and time conditions; a valid frame or balanced save/restore does not prove the drawing is correct. Deliver verified MP4 and editable source/audio/recipe links, briefly state any missing assets or coverage limitations. Do not claim sparse-frame inspection is complete perceptual/audio review. Video requests require a real rendered file, not just HTML or a screenplay.
15
+ Film architecture example: scene boundaries come from measured narration OR music/visual accents; draw(t) selects a scene, computes local progress = clamp((t-start)/entranceDuration), applies eased transforms, then holds. Use shared brand tokens across scenes, distinct layouts, and persistent object identity across transitions. Transport controls live outside #stage and are not captured.`;
16
+
17
+ const baseCSS='*{box-sizing:border-box}body{margin:0}#stage{position:relative;width:100%;aspect-ratio:16/9;background:var(--canvas-soft,#eef5ff);overflow:hidden;color:var(--canvas-ink,#182537);font:16px system-ui}svg{width:100%;height:100%;display:block}#transport{display:flex;gap:8px;align-items:center;padding:10px}button{font:inherit;padding:8px 14px;border-radius:14px;border:1px solid var(--canvas-line,#cbd5e1);background:var(--canvas-bg,#fff);color:inherit}input{flex:1;min-width:80px}';
18
+ const transport=`const tl=DSHUI.timeline({duration:6,loop:true,autoplay:false,chapters:[{time:0,label:'建立关系'},{time:2,label:'传递变化'},{time:4,label:'观察结果'}],draw});const play=document.getElementById('play'),seek=document.getElementById('seek'),label=document.getElementById('time');play.addEventListener('click',()=>tl.state().playing?tl.pause():tl.play());seek.addEventListener('input',()=>{const next=Number(seek.value);tl.pause();tl.seek(next)});tl.subscribe(s=>{seek.value=s.time;label.textContent=s.time.toFixed(1)+' / '+s.duration+' 秒';play.textContent=s.playing?'暂停':'播放'});document.querySelectorAll('[data-chapter]').forEach(b=>b.addEventListener('click',()=>tl.chapter(Number(b.dataset.chapter))));addEventListener('pagehide',()=>tl.dispose(),{once:true});`;
19
+ const controls='<div id="transport"><button id="play">播放</button><input id="seek" aria-label="动画时间(秒)" type="range" min="0" max="6" step="0.01"><output id="time"></output></div>';
20
+ const recipes = [
21
+ {id:'timeline',name:'可定位时间轴',category:'clock',description:'绝对时间绘制,暂停、拖动和重播共享同一画面状态',html:'<div id="stage"><svg viewBox="0 0 640 360" role="img" aria-label="位置随时间变化的圆点"><path d="M80 180H560" stroke="var(--canvas-line,#cbd5e1)" stroke-width="4"/><circle id="dot" cy="180" r="24" fill="var(--canvas-accent,#527ef7)"/></svg></div>'+controls,script:'function draw(t){document.getElementById("dot").setAttribute("cx",80+480*t/6)}'+transport},
22
+ {id:'stages',name:'分步因果讲解',category:'explanation',description:'同一对象依次经历输入、传递和输出,章节可定位',html:'<div id="stage"><svg viewBox="0 0 640 360" role="img" aria-label="输入传递到输出"><path d="M100 180H540" stroke="var(--canvas-line,#cbd5e1)" stroke-width="6"/><g id="signal"><circle r="22" fill="var(--canvas-accent,#527ef7)"/></g><text id="caption" x="60" y="70" fill="currentColor" font-size="26"></text></svg></div>'+controls+'<button data-chapter="0">输入</button><button data-chapter="1">传递</button><button data-chapter="2">输出</button>',script:'function draw(t){const p=Math.max(0,Math.min(1,(t-1)/4));document.getElementById("signal").setAttribute("transform","translate("+(100+440*p)+",180)");document.getElementById("caption").textContent=t<2?"先建立输入":t<4?"沿连接传递变化":"观察输出结果"}'+transport},
23
+ {id:'path',name:'路径与方向',category:'diagram',description:'依次画出连接路径,保留起点、终点与方向',html:'<div id="stage"><svg viewBox="0 0 640 360" role="img" aria-label="沿路径传播的变化"><path id="route" d="M80 260C180 260 180 100 320 100S460 260 560 100" fill="none" stroke="var(--canvas-accent,#527ef7)" stroke-width="8" stroke-linecap="round"/><circle id="dot" r="14" fill="var(--canvas-ink,#182537)"/></svg></div>'+controls,script:'const path=document.getElementById("route"),length=path.getTotalLength();path.style.strokeDasharray=length;function draw(t){const p=Math.min(1,t/5),point=path.getPointAtLength(length*p);path.style.strokeDashoffset=length*(1-p);document.getElementById("dot").setAttribute("cx",point.x);document.getElementById("dot").setAttribute("cy",point.y)}'+transport},
24
+ {id:'typography',name:'语义文字动效',category:'text',description:'短句整体入场,保留阅读时间,避免逐字打断理解',html:'<div id="stage" style="display:grid;place-items:center"><h2 id="phrase" style="font-size:clamp(24px,5vw,52px);margin:24px">把复杂关系讲清楚</h2></div>'+controls,script:'function draw(t){const p=Math.min(1,t/.7),ease=1-Math.pow(1-p,3),el=document.getElementById("phrase");el.style.opacity=ease;el.style.transform="translateY("+((1-ease)*24)+"px)"}'+transport}
25
+ ];
26
+ export function craftCatalog(id){
27
+ if(!id)return {guide:MOTION_CRAFT_GUIDE,interface:TIMELINE_CONTRACT,recipes:recipes.map(({id,name,category,description})=>({id,name,category,description}))};
28
+ if(id==='reference')return{guide:'Reference-video analysis: inspect_video metadata first; select sparse times around the relevant transition, then READ returned JPEG paths/contact sheet with the host image tool. Compare focal-object position/scale/opacity, motion direction, reading holds and start/end continuity at the original timestamp intervals. Ask for denser samples only around an observed change (max12 per call). Report approximate duration/easing as visual estimates, not measured spring constants or fitted curves. No ASR is supplied. Adapt the mechanism into an absolute-time timeline, respect the current style and user content; do not duplicate the whole reference site or invent unobserved details.'};
29
+ const r=recipes.find(r=>r.id===id);if(!r)return null;
30
+ return {id:r.id,name:r.name,description:r.description,interface:TIMELINE_CONTRACT,document:validateDocument({version:1,title:r.name,fallback:r.description,nodes:[{id:'canvas',type:'sandbox',title:r.name,fallback:r.description,html:r.html,css:baseCSS,script:r.script,height:420}]}),guidance:'Original educational sample, adapt content and selected guided style. For MP4 export #stage is captured and transport controls excluded. Frame drawing must be synchronous and depend on absolute time. Do not copy illustrative relationships as real measurements.'};
31
+ }
@@ -0,0 +1,23 @@
1
+ import {readFile} from 'node:fs/promises';
2
+ import {createHash} from 'node:crypto';
3
+
4
+ export const MOTION_DESIGN_REFERENCE=Object.freeze({
5
+ id:'motion-design',name:'Motion Design — complete OpenDesign skill',
6
+ source:'https://github.com/nexu-io/open-design/tree/14fee234e6eb268058fed17ba979d8e22798dd34/skills/motion-design',
7
+ usage:'ui_catalog({topic:"motion",scene:"motion-design"}) or ui_catalog({topic:"video",scene:"promo"}) loads the complete original skill and separate DSH adapter on demand.'
8
+ });
9
+
10
+ // No source body is read or injected at import/registration time.
11
+ export async function loadMotionDesignSkill(){
12
+ const base=new URL('../skills/motion-design/',import.meta.url);
13
+ const [raw,adapter,provenance,sources]=await Promise.all([
14
+ readFile(new URL('SKILL.md',base)),readFile(new URL('ADAPTER.md',base),'utf8'),
15
+ readFile(new URL('upstream.json',base),'utf8'),readFile(new URL('SOURCES.md',base),'utf8')
16
+ ]);
17
+ const metadata=JSON.parse(provenance.replace(/^\uFEFF/,''));
18
+ const sha256=createHash('sha256').update(raw).digest('hex');
19
+ if(sha256!==metadata.sha256)throw Error('Motion UI: bundled motion-design skill integrity mismatch; restore the complete packaged skill.');
20
+ return {...MOTION_DESIGN_REFERENCE,complete:true,modified:false,license:'Apache-2.0',
21
+ original:raw.toString('utf8'),sources,provenance:{...metadata,sha256},adapter,
22
+ precedence:'Current user requirements and the DSH adapter govern host execution; apply the full upstream design craft.'};
23
+ }
@@ -1,5 +1,6 @@
1
1
  import {EXPRESSIVE_EXAMPLES} from './expressive-examples.js';
2
2
  import {validateDocument} from './protocol.js';
3
+ import {craftCatalog,MOTION_CRAFT_GUIDE} from './motion-craft.js';
3
4
  const ref=key=>({ref:key}),constant=value=>({const:value});
4
5
  export const MOTION_RECIPES=Object.freeze([
5
6
  ...EXPRESSIVE_EXAMPLES,
@@ -9,9 +10,10 @@ export const MOTION_RECIPES=Object.freeze([
9
10
  {id:'plot',name:'参数图解',description:'安全函数表达式,本地实时重绘',document:{title:'振幅探索',items:[{type:'plot',title:'y = a sin(x)',xMin:-6.28,xMax:6.28,series:[{expr:'a*sin(x)',label:'正弦',params:[{name:'a',value:1,min:0,max:3,step:0.1}]}]},{type:'text',content:'调整 a 改变振幅。函数在浏览器本地绘制,无需再次调用模型。'}]}}
10
11
  ]);
11
12
  export function motionCatalog(id){
12
- if(!id)return{recipes:MOTION_RECIPES.map(({id,name,description})=>({id,name,description})),usage:'ui_catalog({topic:"motion",scene:"plan"}) returns one complete example; adapt real content and selected guided style. Do not load every recipe.'};
13
+ if(!id)return{...craftCatalog(),recipes:[...MOTION_RECIPES.map(({id,name,description})=>({id,name,description})),...craftCatalog().recipes],usage:'ui_catalog({topic:"motion",scene:"timeline"}) returns one complete example; adapt real content and selected guided style. Do not load every recipe.'};
14
+ const craft=craftCatalog(id);if(craft)return craft;
13
15
  const recipe=MOTION_RECIPES.find(r=>r.id===id);if(!recipe)throw Error('Unknown motion recipe; choose '+MOTION_RECIPES.map(r=>r.id).join(', '));
14
16
  return{...recipe,document:validateDocument(recipe.document),guidance:'All feedback follows actual state/results. Native controls and graph transitions are built in; no extra JS is needed. Preview is read-only until turn end. Do not copy sample prices as real data or simulate success with a timer.'};
15
17
  }
16
- export const MOTION_GUIDE='Native controls already provide local feedback: segmented select for2..8 short options, slider fill, numeric stepper, elastic toggle, button/link hover and real copy confirmation. Values stay exact immediately; related graphics may transition. No custom script is required for native feedback. For richer motion retrieve one motion recipe; choose animation that explains a real change, honor reduced motion, never simulate task success.';
18
+ export const MOTION_GUIDE='Native controls already provide local feedback: segmented select for2..8 short options, slider fill, numeric stepper, elastic toggle, button/link hover and real copy confirmation. Values stay exact immediately; related graphics may transition. No custom script is required for native feedback. For richer motion retrieve one motion recipe; choose animation that explains a real change, honor reduced motion, never simulate task success.\n'+MOTION_CRAFT_GUIDE;
17
19
  export const MOTION_INTERFACE='Native control feedback is built in and respects reduced motion; canvas animation is model-authored. No palette or layout is imposed.';
@@ -0,0 +1,16 @@
1
+ import {readFile,writeFile} from 'node:fs/promises';
2
+ import {join} from 'node:path';
3
+ import {crc32} from 'node:zlib';
4
+ // Small, standard ZIP (stored entries). Only this job's generated filenames
5
+ // enter the archive. No shell/archiver dependency or user-directory traversal.
6
+ export async function archiveProject(directory,files){
7
+ const locals=[],central=[];let offset=0;
8
+ for(const name of [...new Set(files)]){
9
+ if(!/^[\w-]+\.[\w]+$/.test(name))throw Error('Motion video: invalid project filename');
10
+ const body=await readFile(join(directory,name)),n=Buffer.from(name),crc=crc32(body),h=Buffer.alloc(30);
11
+ h.writeUInt32LE(0x04034b50);h.writeUInt16LE(20,4);h.writeUInt16LE(0x800,6);h.writeUInt32LE(crc,14);h.writeUInt32LE(body.length,18);h.writeUInt32LE(body.length,22);h.writeUInt16LE(n.length,26);
12
+ locals.push(h,n,body);const c=Buffer.alloc(46);c.writeUInt32LE(0x02014b50);c.writeUInt16LE(20,4);c.writeUInt16LE(20,6);c.writeUInt16LE(0x800,8);c.writeUInt32LE(crc,16);c.writeUInt32LE(body.length,20);c.writeUInt32LE(body.length,24);c.writeUInt16LE(n.length,28);c.writeUInt32LE(offset,42);central.push(c,n);offset+=h.length+n.length+body.length;
13
+ }
14
+ const size=central.reduce((n,b)=>n+b.length,0),end=Buffer.alloc(22);end.writeUInt32LE(0x06054b50);end.writeUInt16LE(central.length/2,8);end.writeUInt16LE(central.length/2,10);end.writeUInt32LE(size,12);end.writeUInt32LE(offset,16);
15
+ await writeFile(join(directory,'project.zip'),Buffer.concat([...locals,...central,end]));
16
+ }
@@ -19,4 +19,4 @@ export const DELIVERY_CONTRACT=`Presentation mode: inline. Complete content prev
19
19
  Never invent IDs or repeat the same UI source/content elsewhere. Extended JSON dsh-ui/motion-ui source fences or standalone svg fences are alternative paths; choose one path per UI. Do not end with only a promise.
20
20
  Edit: inspect_ui for revision/state/derived/observations; absent report means unobserved, not passed. Do not poll empty reports or claim unperformed checks. Find the requested part with query:text/label/type (returns IDs/paths/positions); use detail:"nodes" with query OR nodeIds for source edits. Patch that node instead of rebuilding the whole UI. patch_ui changes existing scalar/source props; update_ui changes structure/parameters with stable operationId. Preserve original documentId/marker and compatible values. Conflicts require reread; identical retries reuse operationId.
21
21
  Correct the named field in the complete original document, not probes/partial renders. Automatic correction is at most two rounds per user request; then simplify or explain remaining failure with a useful answer. New user edits start a new request. This is a behavior budget, not a reasoning/output/token/time limit. Never invent measurements, sources, progress or action success. UI/animation is not tool execution evidence; host/external actions follow normal DSH permissions.`;
22
- export const MODEL_CONTRACT=[AUTHORING_GUIDE,NATIVE_CONTRACT,CANVAS_CONTRACT,DELIVERY_CONTRACT,TOOL_DIRECTORY].join('\n\n');
22
+ export const MODEL_CONTRACT=[AUTHORING_GUIDE,NATIVE_CONTRACT,CANVAS_CONTRACT,'DSHUI.timeline supports seek/play/pause; query motion:timeline. Requested films: render_video/inspect_video/process_video; synthesize_voice adds speech. Query ui_catalog topic=video scene=promo for the complete motion-design skill and DSH adapter.',DELIVERY_CONTRACT,TOOL_DIRECTORY].join('\n\n');
package/lib/refinement.js CHANGED
@@ -1,8 +1,40 @@
1
1
  import { componentFields } from './components.js';
2
2
  import { validateDocument } from './protocol.js';
3
+ import {toolJson} from './tool-json.js';
3
4
  export const AUDIT_MESSAGES = Object.freeze({ overflow: '内容超出视口宽度', smallText: '文字实际显示尺寸小于 12px', contrast: '文字与背景对比度偏低', missingLabel: '交互控件缺少可访问名称',runtimeError:'画布报告运行异常',clippedText:'文字可能被隐藏或裁切',bottomWhitespace:'内容下方存在明显空白' });
4
- export function refineDocument(document, changes) {
5
+ export function normalizeNodeEdits(changes) {
6
+ changes=toolJson(changes,'changes');
7
+ // Some providers serialize a JSON array as {item:{id,props}}. Only unwrap
8
+ // an unambiguous transport envelope; ordinary document/data objects stay opaque.
9
+ if(changes&&typeof changes==='object'&&!Array.isArray(changes)&&Object.keys(changes).length===1&&Object.hasOwn(changes,'item')){
10
+ const item=changes.item;
11
+ if(Array.isArray(item))changes=item;
12
+ else if(item&&typeof item==='object'&&!Array.isArray(item)&&Object.hasOwn(item,'id')&&Object.hasOwn(item,'props'))changes=[item];
13
+ }
14
+ if(changes&&typeof changes==='object'&&!Array.isArray(changes)){
15
+ const keys=Object.keys(changes);
16
+ if(keys.length===2&&keys.includes('id')&&keys.includes('props'))changes=[changes];
17
+ else if(keys.length&&keys.length<=32&&keys.every(id=>changes[id]&&typeof changes[id]==='object'&&!Array.isArray(changes[id])))changes=keys.map(id=>({id,props:changes[id]}));
18
+ }
5
19
  if (!Array.isArray(changes) || !changes.length || changes.length > 32) throw new Error('DSH UI: changes requires 1..32 node edits');
20
+ const merged=new Map();
21
+ for(let change of changes){
22
+ if(!change||typeof change!=='object'||Array.isArray(change))throw Error('DSH UI: each edit must be {id,props}');
23
+ // A few typed-array adapters leak the PARENT parameter as an empty field
24
+ // on its item. Remove only this empty mirror, never opaque props/source,
25
+ // nonempty nested edits, arbitrary extra fields or missing identity.
26
+ if(Object.hasOwn(change,'changes')&&(change.changes==null||(typeof change.changes==='string'&&!change.changes.trim())||(Array.isArray(change.changes)&&!change.changes.length))){const {changes:emptyMirror,...rest}=change;change=rest}
27
+ const extra=Object.keys(change).find(k=>!['id','props'].includes(k));
28
+ if(extra)throw Error('DSH UI: unknown edit field '+extra+'; each edit allows id,props. Put node properties inside props.');
29
+ const props=toolJson(change.props,'props');if(!props||typeof props!=='object'||Array.isArray(props)||!Object.keys(props).length)throw Error('DSH UI: edit props required');
30
+ const previous=merged.get(change.id);
31
+ if(previous){for(const key of Object.keys(props))if(Object.hasOwn(previous.props,key)&&JSON.stringify(previous.props[key])!==JSON.stringify(props[key]))throw Error('DSH UI: conflicting edits at node.'+change.id+'.'+key);previous.props={...previous.props,...props}}
32
+ else merged.set(change.id,{id:change.id,props:{...props}});
33
+ }
34
+ return [...merged.values()];
35
+ }
36
+ export function refineDocument(document, changes) {
37
+ changes=normalizeNodeEdits(changes);
6
38
  const next = structuredClone(document), nodes = new Map();
7
39
  const walk = list => list.forEach(n => { nodes.set(n.id,n); if(n.children)walk(n.children); });walk(next.nodes);
8
40
  const seen = new Set();
package/lib/sandbox.js CHANGED
@@ -2,6 +2,7 @@ import {MECHANISM_RUNTIME} from './mechanism-runtime-bundle.js';
2
2
  import { measureVisual } from './visual-audit.js';
3
3
  import { formatNumber } from './format-number.js';
4
4
  import { CANVAS_SYSTEM_CSS } from './canvas-system.js';
5
+ import {createTimelineAPI} from './timeline.js';
5
6
  // Shared bounds and document construction. Generated code runs only in an opaque-origin frame.
6
7
  export const SANDBOX_LIMITS = Object.freeze({ source: 49152, height: 1000, message: 16384, rate: 30 });
7
8
  export function canvasSnapshot(node, state, data, derived, disabled, theme) {
@@ -29,13 +30,15 @@ export function sandboxDocument(node, channel, nonce) {
29
30
  const animations=new Set(), motion=matchMedia('(prefers-reduced-motion: reduce)');
30
31
  let raf=0,last=0,time=0;
31
32
  const visible=()=>snapshot.visible!==false&&!document.hidden;
32
- const canAnimate=()=>visible()&&!snapshot.disabled&&!motion.matches;
33
+ const canAnimate=()=>visible()&&!snapshot.disabled&&!motion.matches&&!window.__DSHMotionRender;
33
34
  const draw=delta=>{for(const fn of animations)try{fn({time,delta,reducedMotion:motion.matches,visible:visible()})}catch(e){animations.delete(fn);report(e)}};
34
35
  const tick=t=>{raf=0;if(!canAnimate()||!animations.size){last=0;draw(0);return}const delta=last?Math.min(.05,(t-last)/1000):0;last=t;time+=delta;draw(delta);if(animations.size)raf=requestAnimationFrame(tick)};
35
36
  const refresh=()=>{draw(0);if(canAnimate()&&animations.size){if(!raf){last=0;raf=requestAnimationFrame(tick)}}else{cancelAnimationFrame(raf);raf=0;last=0}};
36
37
  motion.addEventListener('change',refresh);document.addEventListener('visibilitychange',refresh);
37
38
  const formatNumber=${formatNumber.toString()};
38
39
  const api={formatNumber,get snapshot(){return structuredClone(snapshot)},subscribe(fn){if(typeof fn!=='function')throw Error('Listener required');listeners.add(fn);fn(api.snapshot);return()=>listeners.delete(fn)},setState(patch){if(!snapshot.disabled)send('patch',patch)},animate(fn){if(typeof fn!=='function')throw Error('Animation callback required');animations.add(fn);refresh();return()=>{animations.delete(fn);if(!animations.size){cancelAnimationFrame(raf);raf=0;last=0}}}};
40
+ let soundTick=0,soundPlaying,soundTime,soundVisible;const timelineAPI=(${createTimelineAPI.toString()})(api.animate,!!window.__DSHMotionRender,s=>{if(snapshot.motionSound){const now=performance.now();if(now-soundTick>=100||s.playing!==soundPlaying||s.visible!==soundVisible||(!s.playing&&s.time!==soundTime)){soundTick=now;soundPlaying=s.playing;soundTime=s.time;soundVisible=s.visible;send('timeline',s)}}});api.timeline=timelineAPI.timeline;
41
+ Object.defineProperty(window,'__DSHMotionCapture',{value:timelineAPI.capture,writable:false});
39
42
  ${node.libraries?.includes('mechanism')?MECHANISM_RUNTIME.replace(/<\/script/gi,'<\\/script')+';api.mechanism=__MotionMechanism.createMechanismAPI(api);':''}
40
43
  Object.defineProperty(window,'DSHUI',{value:Object.freeze(api),writable:false});
41
44
  for(const type of ['click','input','change','keydown','pointerdown'])document.addEventListener(type,e=>{if(snapshot.disabled){e.preventDefault();e.stopImmediatePropagation()}},true);
@@ -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
+ }
@@ -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
+ }