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/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
+ }}
package/lib/voice.js ADDED
@@ -0,0 +1,72 @@
1
+ import {readFile,writeFile} from 'node:fs/promises';
2
+ import {join} from 'node:path';
3
+ import {homedir} from 'node:os';
4
+ import {runMedia} from './video.js';
5
+
6
+ // Reuse an explicitly configured MiniMax credential; never copy DSH providers,
7
+ // return keys/signed URLs, or include them in an editable project.
8
+ export async function voiceConfig(){
9
+ let saved={};try{saved=JSON.parse(await readFile(join(homedir(),'.mmx','config.json'),'utf8'))}catch{}
10
+ return {key:process.env.MINIMAX_API_KEY||saved.api_key,region:process.env.MINIMAX_REGION||saved.region||'cn'};
11
+ }
12
+ export async function voiceCapabilities(){const c=await voiceConfig();return {configured:!!c.key,provider:'MiniMax',setup:'Configure official mmx authentication (~/.mmx/config.json), or MINIMAX_API_KEY and optional MINIMAX_REGION=cn/global. Paid API is used only when synthesize_voice is explicitly called.'}}
13
+ export function voiceTimings(raw,duration){
14
+ const entries=Array.isArray(raw)?raw:raw?.subtitles;
15
+ if(!Array.isArray(entries)||!entries.length)throw Error('Motion voice: provider returned no timed subtitles; audio retained');
16
+ let previous=0;
17
+ const captions=entries.map(e=>{
18
+ const start=e.time_begin/1000,end=e.time_end/1000;
19
+ if(!Number.isFinite(start)||!Number.isFinite(end)||start<previous||end<=start||end>duration+.1||typeof e.text!=='string'||!e.text.trim())throw Error('Motion voice: invalid provider subtitle timing; audio retained');
20
+ previous=end;return {start,end:Math.min(end,duration),text:e.text};
21
+ });
22
+ // Numerals/version strings can have several spoken syllables all pointing to
23
+ // the SAME source word range. Merge those measured spans, not repeated text
24
+ // at distinct source positions (e.g. a deliberately repeated phrase).
25
+ const timed=entries.map(e=>{
26
+ const result=[];for(const w of e.timestamped_words??[]){if(!Number.isFinite(w.time_begin)||!Number.isFinite(w.time_end)||w.time_begin<0||w.time_end<w.time_begin)throw Error('Motion voice: invalid word timing; audio retained');const p=result.at(-1);if(p&&Number.isInteger(w.word_begin)&&Number.isInteger(w.word_end)&&p.word_begin===w.word_begin&&p.word_end===w.word_end&&p.word===w.word){p.time_end=Math.max(p.time_end,w.time_end)}else result.push({...w})}return result;
27
+ });
28
+ const words=timed.flat().map(w=>({start:w.time_begin/1000,end:w.time_end/1000,text:w.word}));
29
+ let last=-1;for(const w of words){if(!Number.isFinite(w.start)||!Number.isFinite(w.end)||w.start<last||w.end<w.start||w.start<0||w.end>duration+.1||typeof w.text!=='string')throw Error('Motion voice: invalid word timing; audio retained');last=w.start}
30
+ // The service may group a whole paragraph in one sentence entry. Split only
31
+ // when every source character is covered by actual returned word timings.
32
+ const clean=s=>s.replace(/\s/g,'');
33
+ const complete=entries.every((e,i)=>timed[i].length&&clean(timed[i].map(w=>w.word).join(''))===clean(e.text));
34
+ if(!complete)return {captions,words};
35
+ // Provider "words" can be phonetic fragments such as M / ot / ion / U / I.
36
+ // Restore ONLY the original sentence's whitespace after verifying complete
37
+ // character coverage; guessing a space between Latin fragments corrupts names.
38
+ const pieces=timed.flatMap((group,i)=>{const source=entries[i].text;let cursor=0;return group.map(w=>{const start=cursor,needed=clean(w.word).length;let consumed=0;while(cursor<source.length&&consumed<needed){if(!/\s/.test(source[cursor]))consumed++;cursor++}return source.slice(start,cursor)})});
39
+ const readable=[];let cue;
40
+ const flush=()=>{if(cue&&cue.end>cue.start&&cue.text.trim())readable.push({...cue,text:cue.text.trim()});cue=undefined};
41
+ for(const [index,w] of words.entries()){
42
+ if(!w.text.trim())continue;
43
+ if(cue&&(w.start-cue.end>.3||[...cue.text].length>=26))flush();
44
+ if(!cue)cue={start:w.start,end:w.end,text:''};
45
+ cue.text+=pieces[index];cue.end=Math.min(duration,Math.max(cue.end,w.end));
46
+ if(/[。!?;,!?;,]$/.test(w.text))flush();
47
+ }
48
+ flush();
49
+ return {captions:readable,providerCaptions:captions,words};
50
+ }
51
+ export async function synthesizeVoice(studio,args,{sessionId,signal,fetchImpl=fetch,config}={}){
52
+ if(typeof args.text!=='string'||!args.text.trim()||args.text.length>=10000)throw Error('Motion voice: text must contain 1..9999 characters');
53
+ const voice=args.voice??'Chinese (Mandarin)_Warm_HeartedGirl',model=args.model??'speech-2.8-hd',speed=args.speed??1;
54
+ if(typeof voice!=='string'||!voice||voice.length>200||!['speech-2.8-hd','speech-2.8-turbo'].includes(model)||!Number.isFinite(speed)||speed<.5||speed>2)throw Error('Motion voice: invalid voice/model/speed');
55
+ const c=config??await voiceConfig();if(!c.key)throw Error('Motion voice: configure mmx authentication or MINIMAX_API_KEY first; no credential was copied from DSH');
56
+ if(!['cn','global'].includes(c.region))throw Error('Motion voice: region must be cn or global');
57
+ const job=await studio.job(sessionId,'voice');job.files=['narration.txt'];await writeFile(join(job.directory,'narration.txt'),args.text);await studio.save(job);
58
+ try{
59
+ const response=await fetchImpl(`https://api.minimax.${c.region==='cn'?'cn':'io'}/v1/t2a_v2`,{method:'POST',signal,headers:{Authorization:'Bearer '+c.key,'Content-Type':'application/json'},body:JSON.stringify({model,text:args.text,stream:false,output_format:'hex',subtitle_enable:true,subtitle_type:'word',language_boost:'auto',voice_setting:{voice_id:voice,speed,vol:1,pitch:0},audio_setting:{format:'mp3',sample_rate:32000,bitrate:128000,channel:1}})});
60
+ if(!response.ok)throw Error('MiniMax speech HTTP '+response.status);
61
+ const result=await response.json();if(result.base_resp?.status_code)throw Error('MiniMax speech API '+result.base_resp.status_code+': '+result.base_resp.status_msg);
62
+ if(typeof result.data?.audio!=='string'||!/^([a-f0-9]{2})+$/i.test(result.data.audio))throw Error('MiniMax returned no valid hex audio');
63
+ const audio=join(job.directory,'narration.mp3');await writeFile(audio,Buffer.from(result.data.audio,'hex'));job.files.push('narration.mp3');await studio.save(job);
64
+ const probe=JSON.parse(await runMedia(process.env.DSH_MOTION_FFPROBE||'ffprobe',['-v','error','-show_format','-show_streams','-of','json',audio],{signal}));
65
+ const duration=Number(probe.format?.duration);if(!probe.streams?.some(s=>s.codec_type==='audio')||!Number.isFinite(duration)||duration<=0)throw Error('Motion voice: returned audio failed verification');
66
+ const url=new URL(result.data.subtitle_file);if(url.protocol!=='https:')throw Error('Motion voice: provider must supply HTTPS timed subtitles');
67
+ const subtitles=await fetchImpl(url,{signal,credentials:'omit',referrerPolicy:'no-referrer'});if(!subtitles.ok)throw Error('Subtitle download HTTP '+subtitles.status);
68
+ const raw=await subtitles.json();await writeFile(join(job.directory,'subtitles.raw.json'),JSON.stringify(raw,null,2));job.files.push('subtitles.raw.json');const timings=voiceTimings(raw,duration);
69
+ await writeFile(join(job.directory,'timings.json'),JSON.stringify(timings,null,2));
70
+ job.files.push('timings.json');job.duration=duration;job.captions=timings.captions;job.words=timings.words;job.audioPath=audio;job.recipe={provider:'MiniMax',model,voice,speed};job.status='completed';return await studio.save(job);
71
+ }catch(e){const message=String(e.message).replaceAll(c.key,'[REDACTED]').replace(/https?:\/\/\S+/g,'[URL]');job.status='failed';job.error=message;await studio.save(job);throw Error(message+'; retained assets: '+job.directory)}
72
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-motion-ui",
3
- "version": "0.7.1",
4
- "description": "Interactive UI, HTML/SVG animation and local calculations for DeepSeek Harness Web, with injected design guidance.",
3
+ "version": "0.8.0",
4
+ "description": "Readable conversational UI, interactive HTML/SVG, editable motion and local MP4 production for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {
@@ -37,16 +37,21 @@
37
37
  "platform": "web"
38
38
  },
39
39
  "compatibility": {
40
- "dsh": "0.2.0-rc.2",
40
+ "dsh": "0.2.0-rc.2 || 0.2.1-rc.1",
41
41
  "node": ">=22.19",
42
42
  "profiles": [
43
- "web"
44
- ]
43
+ "web",
44
+ "desktop"
45
+ ],
46
+ "dshReleases": {
47
+ "0.2.0-rc.2": "compatible",
48
+ "0.2.1-rc.1": "compatible"
49
+ }
45
50
  }
46
51
  },
47
52
  "peerDependencies": {
48
- "@deepseek-ai/dsh-home-paths": "0.2.0-rc.2",
49
- "@deepseek-ai/dsh-tools": "0.2.0-rc.2",
53
+ "@deepseek-ai/dsh-home-paths": "0.2.0-rc.2 || 0.2.1-rc.1",
54
+ "@deepseek-ai/dsh-tools": "0.2.0-rc.2 || 0.2.1-rc.1",
50
55
  "react": "^18.2.0 || ^19.0.0",
51
56
  "react-dom": "^18.2.0 || ^19.0.0"
52
57
  },
@@ -59,10 +64,13 @@
59
64
  "mermaid": "^11.16.1",
60
65
  "react": "^18.3.1",
61
66
  "react-dom": "^18.3.1",
62
- "three": "^0.180.0"
67
+ "three": "^0.180.0",
68
+ "@deepseek-ai/dsh-tools": "0.2.1-rc.1",
69
+ "@deepseek-ai/dsh-home-paths": "0.2.1-rc.1"
63
70
  },
64
71
  "dependencies": {
65
- "@deepseek-ai/schemastery": "^3.18.4"
72
+ "@deepseek-ai/schemastery": "^3.18.4",
73
+ "playwright-core": "1.64.1"
66
74
  },
67
75
  "license": "MIT",
68
76
  "repository": {
@@ -0,0 +1,35 @@
1
+ # Motion UI host adaptation
2
+
3
+ Read this adapter together with the complete, unmodified upstream SKILL.md. Apply its full art direction, choreography, typography, visual grammar, sound planning and quality guidance. This adapter maps OpenDesign-specific execution details to the actual DSH host; it does not replace the design craft with a summary.
4
+
5
+ ## Host contract takes precedence
6
+
7
+ - User requirements and the current DSH host permissions, tools and task scope govern execution. The upstream OD Core, stage markers and CLI paths are OpenDesign references, not additional tools or instructions to install OpenDesign.
8
+ - User-requested films require an actual MP4 and editable source. Interactive explanations remain runnable UI; loading this reference alone does not request a video.
9
+ - Upstream fixed inspection/repair counts are OpenDesign orchestration defaults. In this host they do not cap model reasoning, output, time or necessary defect repair. Never cancel inference to satisfy those counts. Stop when the requested result is verified or report a real unresolved failure.
10
+ - Keep the user's actual reference, approved copy and chosen material language. Free mode leaves visual authorship to the model; guided mode uses the chosen style unless the user supplies a specific overriding direction. Honor explicit material exclusions; do not introduce glass as a default material.
11
+
12
+ ## Execution mapping
13
+
14
+ | OpenDesign reference | Motion UI execution |
15
+ | --- | --- |
16
+ | Scaffold / HyperFrames HTML | `render_canvas` saves an isolated HTML/CSS/script document. Use one unique `#stage`, designed at the actual output aspect. |
17
+ | `window.__timelines.main` / GSAP clock | `DSHUI.timeline({id:'main',duration,draw(time,info),chapters})`. Seconds are the first argument. Acquire Canvas2D context yourself; e.g. `const ctx=canvas.getContext('2d');` and `draw(t){drawFrame(ctx,t)}`. |
18
+ | `data-width`, `data-height`, `data-fps`, `data-duration` | Keep source dimensions, timeline duration and `preview_video` / `render_video` arguments consistent. Scale the authored composition uniformly. |
19
+ | OD source player | Connect controls outside `#stage` to the same DSHUI timeline. Exported standalone source already synchronizes its soundtrack. Do not create another playback clock. |
20
+ | OD capture flag | `window.__DSHMotionRender` is supplied during capture; the host seeks the registered timeline. Drawing must synchronously reproduce any absolute time. |
21
+ | GSAP-specific SVG origin examples | Preserve their design intent with grouped geometry and explicit shared pivots. GSAP is not bundled; arbitrary CDN/network imports and eval are blocked in the canvas. Native SVG, Canvas and transform math work directly. |
22
+ | Audio DOM metadata / OD audio policy | `render_video` takes local `audioTracks:[{path,role:'voice'|'music'|'effect',volume,offset,loop,fadeIn,fadeOut}]`. This produces actual mixed audio and bundles source assets. DOM audio tags or Web Audio alone are not exported tracks. |
23
+ | Speech / transcript timing | `synthesize_voice` returns real measured timing using configured MiniMax authentication. Narration is optional. Do not invent ASR or claim audio analysis from an arbitrary pulse. |
24
+ | OD media generation / wait | `preview_video` returns actual source frames and diagnostics; read its images before encoding. `render_video` exports the MP4 and project ZIP. `inspect_video` checks existing media and samples decoded frames. `process_video` handles clips and soundtrack changes. |
25
+ | Modify existing composition | `inspect_ui` then `patch_ui` with current documentRevision; `update_ui` for structural changes. Do not create duplicate documents for a repair. Editing an exported HTML snapshot alone does not update the saved rendering input. |
26
+
27
+ ## Reliable drawing and review
28
+
29
+ - For Canvas, save/restore local drawing state; derive transformations and all values from time. Seed/cache texture rather than regenerating unrelated random marks.
30
+ - Gate flash/mask opacity to its active interval. An inverse clamped fade equals 1 before the fade begins; explicitly keep overlays transparent outside their intended interval.
31
+ - Inspect the opening, focal transformation, readable hold, transition hand-off and ending. Check actual label bounds, contact points and data units at intended resolution. Sparse frames are not a full perceptual/audio review.
32
+ - A successful capture proves valid execution, not design quality. If an unexpected blank is present in both the source preview and a decoded MP4 at the same time, inspect authored masks and time conditions before blaming the renderer.
33
+ - Preserve inputs, earlier films and editable assets. Cite supplied/downloaded media accurately and include required credits; never invent assets, screenshots, rights or measurements.
34
+
35
+ The unmodified upstream skill, provenance and Apache-2.0 license are shipped alongside this adapter.