@ninkch/dsh-infinite-canvas 0.18.7 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +47 -10
  2. package/README.zh-CN.md +46 -9
  3. package/assets/canvas-craft/SKILL.md +169 -0
  4. package/assets/canvas-studio/SKILL.md +11 -0
  5. package/assets/director/engine.js +5177 -0
  6. package/assets/director/models/bld_a.json +1 -0
  7. package/assets/director/models/bld_b.json +1 -0
  8. package/assets/director/models/bld_c.json +1 -0
  9. package/assets/director/models/bld_d.json +1 -0
  10. package/assets/director/models/bld_e.json +1 -0
  11. package/assets/director/models/bld_f.json +1 -0
  12. package/assets/director/models/bld_g.json +1 -0
  13. package/assets/director/models/bld_h.json +1 -0
  14. package/assets/director/models/bld_i.json +1 -0
  15. package/assets/director/models/bld_j.json +1 -0
  16. package/assets/director/models/bld_skyscraper_a.json +1 -0
  17. package/assets/director/models/bld_skyscraper_b.json +1 -0
  18. package/assets/director/models/bld_skyscraper_c.json +1 -0
  19. package/assets/director/models/bld_skyscraper_d.json +1 -0
  20. package/assets/director/models/cesiumman.json +1 -0
  21. package/assets/director/models/furn_bedDouble.json +1 -0
  22. package/assets/director/models/furn_bench.json +1 -0
  23. package/assets/director/models/furn_bookcaseClosed.json +1 -0
  24. package/assets/director/models/furn_cabinetTelevision.json +1 -0
  25. package/assets/director/models/furn_chair.json +1 -0
  26. package/assets/director/models/furn_kitchenBar.json +1 -0
  27. package/assets/director/models/furn_lampRoundFloor.json +1 -0
  28. package/assets/director/models/furn_rugRectangle.json +1 -0
  29. package/assets/director/models/furn_stoolBar.json +1 -0
  30. package/assets/director/models/furn_tableCross.json +1 -0
  31. package/assets/director/models/furn_televisionModern.json +1 -0
  32. package/assets/director/models/index.json +285 -0
  33. package/assets/director/models/soldier.json +1 -0
  34. package/assets/director/models/tree_blocks.json +1 -0
  35. package/assets/director/models/tree_cone.json +1 -0
  36. package/assets/director/models/tree_default.json +1 -0
  37. package/assets/director/models/tree_detailed.json +1 -0
  38. package/assets/director/models/tree_fat.json +1 -0
  39. package/assets/director/models/tree_oak.json +1 -0
  40. package/assets/director/models/tree_palm.json +1 -0
  41. package/assets/director/models/tree_palmDetailedTall.json +1 -0
  42. package/assets/director/models/tree_palmTall.json +1 -0
  43. package/assets/director/models/tree_pineDefaultA.json +1 -0
  44. package/assets/director/models/tree_pineRoundA.json +1 -0
  45. package/assets/director/models/tree_thin.json +1 -0
  46. package/assets/director/models/xbot.json +1 -0
  47. package/client.js +4265 -187
  48. package/docs/UI-RULES.md +4 -1
  49. package/docs/director-3d.md +578 -0
  50. package/docs/guide.en.md +84 -8
  51. package/docs/guide.zh-CN.md +85 -9
  52. package/docs/images/director-assets-buildings-en.png +0 -0
  53. package/docs/images/director-assets-buildings-zh.png +0 -0
  54. package/docs/images/director-assets-characters-en.png +0 -0
  55. package/docs/images/director-assets-characters-zh.png +0 -0
  56. package/docs/images/director-assets-furniture-en.png +0 -0
  57. package/docs/images/director-assets-furniture-zh.png +0 -0
  58. package/docs/images/director-assets-primitives-en.png +0 -0
  59. package/docs/images/director-assets-primitives-zh.png +0 -0
  60. package/docs/images/director-assets-trees-en.png +0 -0
  61. package/docs/images/director-assets-trees-zh.png +0 -0
  62. package/docs/images/director-daylight-en.png +0 -0
  63. package/docs/images/director-dynamic-en.png +0 -0
  64. package/docs/images/director-dynamic-zh.png +0 -0
  65. package/docs/images/director-live-render-zh.png +0 -0
  66. package/docs/images/director-static-en.png +0 -0
  67. package/docs/images/director-static-zh.png +0 -0
  68. package/docs/images/help-en.png +0 -0
  69. package/docs/images/help-zh.png +0 -0
  70. package/docs/images/overview-en.png +0 -0
  71. package/docs/images/overview-zh.png +0 -0
  72. package/docs/images/panel-assets-en.png +0 -0
  73. package/docs/images/panel-assets-zh.png +0 -0
  74. package/docs/images/panel-bible-zh.png +0 -0
  75. package/docs/images/panel-projects-zh.png +0 -0
  76. package/docs/images/panel-settings-en.png +0 -0
  77. package/docs/images/panel-settings-zh.png +0 -0
  78. package/docs/images/scene1-zh.png +0 -0
  79. package/docs/images/shortcuts-en.png +0 -0
  80. package/docs/images/shortcuts-zh.png +0 -0
  81. package/docs/images/video-node-refs.png +0 -0
  82. package/docs/storyboard-segmentation.md +446 -0
  83. package/index.js +155 -1
  84. package/package.json +5 -2
  85. package/docs/images/panel-bible-en.png +0 -0
  86. package/docs/images/panel-projects-en.png +0 -0
  87. package/docs/images/pipeline-zh.png +0 -0
  88. package/docs/images/provider-detail.png +0 -0
package/index.js CHANGED
@@ -966,6 +966,9 @@ const MIME_BY_EXT = {
966
966
  webm: 'video/webm', mp4: 'video/mp4', mov: 'video/quicktime', mkv: 'video/x-matroska',
967
967
  wav: 'audio/wav', mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg',
968
968
  json: 'application/json', srt: 'text/plain; charset=utf-8', txt: 'text/plain; charset=utf-8',
969
+ // js/mjs:浏览器动态 import() 只认 text/javascript —— 导演台引擎模块(assets/director/engine.js)
970
+ // 走 plugin-asset 路由加载,没有这个 MIME 会被浏览器拒载("Failed to fetch dynamically imported module")
971
+ js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8',
969
972
  };
970
973
  function mimeOf(p) {
971
974
  const m = /\.([a-z0-9]+)$/i.exec(p);
@@ -2922,6 +2925,17 @@ export function apply(ctx) {
2922
2925
  );
2923
2926
  }
2924
2927
 
2928
+ /** 懒加载导演台引擎(纯 ESM 函数/常量,无 DOM 依赖),供导演台工具读取姿态/动作清单。 */
2929
+ let _directorEngine = null;
2930
+ const loadDirectorEngine = async () => {
2931
+ if (_directorEngine) return _directorEngine;
2932
+ try {
2933
+ const url = new URL('./assets/director/engine.js', import.meta.url).href;
2934
+ _directorEngine = await import(url);
2935
+ } catch { _directorEngine = null; }
2936
+ return _directorEngine;
2937
+ };
2938
+
2925
2939
  const methods = {
2926
2940
  async ping() { return { ok: true, version: VERSION }; },
2927
2941
  async llmOptions() { return listLlmOptions(); },
@@ -2958,6 +2972,30 @@ export function apply(ctx) {
2958
2972
  * 客户端知道自己要删哪个项目,Host 按项目物理文件夹定位(与 projectDirGet 同一基准),
2959
2973
  * 只删 projects/<folder>/craft 这一个子目录——绝不碰项目镜像、其他项目、数据根其他内容。
2960
2974
  * 幂等:目录不存在也是成功;失败可见(removed=false + reason),不静默吞。 */
2975
+ /** 导演台场景规格:AI / 脚本写入,客户端打开导演台时自动套用(存 <projectDir>/craft/director-stage.json)。 */
2976
+ async directorStageSave(args) {
2977
+ const dir = await methods.projectDirGet({ projectId: args && args.projectId });
2978
+ if (!dir || !dir.ok) return { ok: false, error: (dir && dir.error) || '项目目录不可用' };
2979
+ const craftDir = path.join(dir.abs, 'craft');
2980
+ try { mkdirSync(craftDir, { recursive: true }); } catch { /* 交给写入报错 */ }
2981
+ const file = path.join(craftDir, 'director-stage.json');
2982
+ const spec = (args && args.spec) || null;
2983
+ if (!spec || typeof spec !== 'object') return { ok: false, error: '缺少 spec 对象' };
2984
+ const payload = { ok: true, savedAt: Date.now(), spec };
2985
+ try { writeFileSync(file, JSON.stringify(payload, null, 2), 'utf8'); }
2986
+ catch (e) { return { ok: false, error: '写入失败: ' + String((e && e.message) || e) }; }
2987
+ return { ok: true, projectId: String(args.projectId), file, bytes: Buffer.byteLength(JSON.stringify(payload)) };
2988
+ },
2989
+ async directorStageLoad(args) {
2990
+ const dir = await methods.projectDirGet({ projectId: args && args.projectId });
2991
+ if (!dir || !dir.ok) return { ok: false, error: (dir && dir.error) || '项目目录不可用' };
2992
+ const file = path.join(dir.abs, 'craft', 'director-stage.json');
2993
+ if (!existsSync(file)) return { ok: true, found: false, file };
2994
+ try {
2995
+ const doc = JSON.parse(readFileSync(file, 'utf8'));
2996
+ return { ok: true, found: true, file, savedAt: doc.savedAt || null, spec: doc.spec || null };
2997
+ } catch (e) { return { ok: false, found: false, file, error: '读取失败: ' + String((e && e.message) || e) }; }
2998
+ },
2961
2999
  async projectCraftRemove(args) {
2962
3000
  const root = dataRoot();
2963
3001
  const pid = String((args && args.projectId) || '');
@@ -3500,6 +3538,76 @@ export function apply(ctx) {
3500
3538
  return { ok: true, projectId: r.projectId, text: '✅ 已重排 ' + r.moved + ' 个节点(' + r.rows + ' 行,无重叠)。' };
3501
3539
  },
3502
3540
  });
3541
+ reg({
3542
+ name: 'canvas_director_stage',
3543
+ description: '3D 导演台(预演场景)的总控工具:用一段 JSON 规格直接生成/更新整场预演,无需手动搭场景。\n' +
3544
+ 'action=apply:把 spec 写入项目并落盘(客户端打开导演台时自动套用;再点「导出视频」即得白模预演片)。\n' +
3545
+ 'action=get:读回当前项目的规格。\n' +
3546
+ 'action=help:返回规格 schema 与可用姿态/预设清单。\n' +
3547
+ 'spec 结构:{ title, duration, env{preset,bg,ground,grid,exposure,tonemap}, ' +
3548
+ 'characters[{name,pos:[x,y,z],rotY,h,pose,color,material{roughness,metallic,specular},path:[[x,z]...],pathFace,poseAnim[{t,pose}],loop,loopPeriod,keys[{t,pos,rotY}]}], ' +
3549
+ 'props[{preset,pos,scale,color}], cameras[{name,pos,target,fov,keys[{t,pos,target}]}], shots[{camera,start,end}] }。\n' +
3550
+ 'pose 取值见 action=help 的 poses;道具 preset 见 presets。坐标两维数组 [x,z] 自动补 y=0。',
3551
+ parameters: {
3552
+ type: 'object',
3553
+ properties: {
3554
+ action: { type: 'string', enum: ['apply', 'get', 'help', 'brief'], description: '操作类型' },
3555
+ brief: { type: 'string', description: '一句话描述(action=brief 时用,如「两人在巷子里打斗,8秒」);会套用打斗/追逐/对话模板生成规格并落盘' },
3556
+ template: { type: 'string', enum: ['fight', 'chase', 'dialogue'], description: '强制指定模板(不传则按 brief 关键词自动判断)' },
3557
+ duration: { type: 'number', description: '时长秒数(不传则从 brief 里抓「N秒」,否则用模板默认值)' },
3558
+ projectId: { type: 'string', description: '项目 id' },
3559
+ spec: { type: 'object', description: '场景规格(action=apply 时必填)' },
3560
+ },
3561
+ required: ['action'],
3562
+ additionalProperties: false,
3563
+ },
3564
+ output: textOutput,
3565
+ timeoutMs: 30000,
3566
+ async execute(args) {
3567
+ const action = String((args && args.action) || 'get');
3568
+ if (action === 'help') {
3569
+ const eng = await loadDirectorEngine();
3570
+ return {
3571
+ ok: true,
3572
+ text: '✅ 导演台规格说明\n' +
3573
+ '· 姿态 id:none, arms_up, punch, kick, flip, fly(轻功), crouch(下蹲), fight, walk, run, jump, hit, fall, walk_a, walk_b, run_a, run_b\n' +
3574
+ '· 道具 preset:box, sphere, cylinder, cone, plane, capsule, torus, pyramid, disc, arc\n' +
3575
+ '· 循环动作:poseAnim 首尾 pose 相同即可循环(如 walk_a→walk_b→walk_a)\n' +
3576
+ '· 路径:path 为二维地面点数组,物体沿路径运动并按切线自动转向(pathFace:false 关闭)\n' +
3577
+ '· 角色一律使用资产库的真实关节人偶(assetId=xbot):characters[].preset 可填 actors 列表里的体型,未填默认 m_standard',
3578
+ poses: (eng && eng.BONE_POSES ? eng.BONE_POSES.map((p) => ({ id: p.id, zh: p.zh, en: p.en })) : []),
3579
+ presets: ['box', 'sphere', 'cylinder', 'cone', 'plane', 'capsule', 'torus', 'pyramid', 'disc', 'arc'],
3580
+ actors: (eng && eng.ACTOR_PRESETS ? eng.ACTOR_PRESETS.map((p) => ({ preset: p.id, zh: p.zh, en: p.en, h: p.h })) : []),
3581
+ sequences: (eng && eng.ACTION_SEQUENCES ? eng.ACTION_SEQUENCES.map((s2) => ({ id: s2.id, zh: s2.zh, loop: !!s2.loop })) : []),
3582
+ };
3583
+ }
3584
+ if (action === 'brief') {
3585
+ const brief = String((args && args.brief) || '').trim();
3586
+ if (!brief) throw new Error('action=brief 需要 brief 一句话描述');
3587
+ const eng2 = await loadDirectorEngine();
3588
+ if (!eng2 || typeof eng2.specFromBrief !== 'function') throw new Error('引擎未就绪');
3589
+ const spec2 = eng2.specFromBrief(brief, { template: args.template, duration: args.duration });
3590
+ const r4 = await methods.directorStageSave({ projectId: args.projectId, spec: spec2 });
3591
+ if (!r4.ok) throw new Error(r4.error || '保存失败');
3592
+ return {
3593
+ ok: true, projectId: String(args.projectId || ''), spec: spec2,
3594
+ text: '✅ 已按「' + (spec2.title || brief) + '」生成预演规格并写入项目(' + (spec2.characters || []).length + ' 角色 / ' + (spec2.cameras || []).length + ' 机位 / ' + (spec2.shots || []).length + ' 分镜 / ' + spec2.duration + 's)。打开或刷新 3D 导演台即可看到;点「导出视频」出片。',
3595
+ };
3596
+ }
3597
+ if (action === 'get') {
3598
+ const r2 = await methods.directorStageLoad({ projectId: args.projectId });
3599
+ return { ok: !!r2.ok, projectId: String(args.projectId || ''), spec: r2.spec || undefined, text: r2.found ? '✅ 已读取导演台规格(savedAt=' + new Date(r2.savedAt || Date.now()).toISOString() + ')' : '(该项目还没有导演台规格)' };
3600
+ }
3601
+ if (!args || !args.spec || typeof args.spec !== 'object') throw new Error('action=apply 需要 spec 对象');
3602
+ const r3 = await methods.directorStageSave({ projectId: args.projectId, spec: args.spec });
3603
+ if (!r3.ok) throw new Error(r3.error || '保存失败');
3604
+ return {
3605
+ ok: true, projectId: String(args.projectId || ''), file: r3.file,
3606
+ text: '✅ 导演台规格已写入(' + r3.bytes + ' 字节)。打开/刷新画布的 3D 导演台会自动套用该场景;点「导出视频」即可产出白模预演片。',
3607
+ };
3608
+ },
3609
+ });
3610
+
3503
3611
  reg({
3504
3612
  name: 'canvas_delete_project',
3505
3613
  description: '删除无限画布上的一个项目(含其全部节点)。用户明确要求删除项目、或清理误创建的空项目时使用;删除前请先用 canvas_list_projects 确认 id。',
@@ -3806,7 +3914,7 @@ export function apply(ctx) {
3806
3914
  path: `/${NAME}/api/file`,
3807
3915
  async handler(req, res) {
3808
3916
  try {
3809
- if (req.method !== 'GET') { res.statusCode = 405; res.end('仅接受 GET'); return; }
3917
+ if (req.method !== 'GET' && req.method !== 'HEAD') { res.statusCode = 405; res.end('仅接受 GET/HEAD'); return; }
3810
3918
  const root = dataRoot();
3811
3919
  if (!root) { res.statusCode = 404; res.end('未设置数据根目录'); return; }
3812
3920
  const u = new URL(req.url, 'http://localhost');
@@ -3819,6 +3927,13 @@ export function apply(ctx) {
3819
3927
  res.setHeader('content-type', mimeOf(abs));
3820
3928
  res.setHeader('accept-ranges', 'bytes');
3821
3929
  res.setHeader('cache-control', 'no-cache');
3930
+ // HEAD:只回应元信息(node fetch HEAD 探针用),不读文件、不占解码/内存
3931
+ if (req.method === 'HEAD') {
3932
+ res.statusCode = 200;
3933
+ res.setHeader('content-length', String(st.size));
3934
+ res.end();
3935
+ return;
3936
+ }
3822
3937
  const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || ''));
3823
3938
  if (m && (m[1] !== '' || m[2] !== '')) {
3824
3939
  let start;
@@ -3845,6 +3960,45 @@ export function apply(ctx) {
3845
3960
  }
3846
3961
  },
3847
3962
  }),
3963
+ // 视频静态封面(poster)落盘:body 为 jpeg/png 字节,写入 `<rel>.poster.jpg`。
3964
+ // 有 poster 后画布视频节点默认只画一张 img(浏览器磁盘缓存、零视频解码),播放时才挂 <video>。
3965
+ ctx.webServer.register({
3966
+ kind: 'exact',
3967
+ path: `/${NAME}/api/poster`,
3968
+ async handler(req, res) {
3969
+ res.setHeader('content-type', 'application/json; charset=utf-8');
3970
+ try {
3971
+ if (req.method !== 'POST' && req.method !== 'PUT') {
3972
+ res.statusCode = 405;
3973
+ res.end(JSON.stringify({ error: '仅接受 POST/PUT' }));
3974
+ return;
3975
+ }
3976
+ const root = dataRoot();
3977
+ if (!root) throw new Error('未设置数据根目录');
3978
+ const u = new URL(req.url, 'http://localhost');
3979
+ const rawRel = String(u.searchParams.get('rel') || '').trim();
3980
+ if (!rawRel) throw new Error('缺少 rel');
3981
+ if (/\.poster\.jpg$/i.test(rawRel)) throw new Error('rel 不应包含 poster 后缀');
3982
+ const rel = physicalRel(rawRel) || rawRel; // 逻辑路径(pid)→ 物理路径(项目名文件夹)
3983
+ const { abs, clean } = safeJoin(root, rel + '.poster.jpg'); // safeJoin 会拒绝 ../ 与绝对路径
3984
+ mkdirSync(path.dirname(abs), { recursive: true });
3985
+ const chunks = [];
3986
+ let size = 0;
3987
+ const MAX_POSTER = 8 * 1024 * 1024; // 封面只应是缩略 JPEG,8MB 足够且防无界写盘
3988
+ for await (const c of req) {
3989
+ size += c.length;
3990
+ if (size > MAX_POSTER) { res.statusCode = 413; res.end(JSON.stringify({ error: 'poster 过大(上限 8MB)' })); return; }
3991
+ chunks.push(c);
3992
+ }
3993
+ if (!size) throw new Error('poster 内容为空');
3994
+ await fs.writeFile(abs, Buffer.concat(chunks));
3995
+ res.end(JSON.stringify({ ok: true, rel: clean, size }));
3996
+ } catch (e) {
3997
+ res.statusCode = 400;
3998
+ res.end(JSON.stringify({ error: String((e && e.message) || e) }));
3999
+ }
4000
+ },
4001
+ }),
3848
4002
  // CAS 对象库读路径(0.18):0.18.0–0.18.4 上传的媒体仍在 objects/ 里,必须继续可读。
3849
4003
  // IC-08 起写路径已退役(POST/PUT 一律 405),只有 GET。
3850
4004
  ctx.webServer.register({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninkch/dsh-infinite-canvas",
3
- "version": "0.18.7",
3
+ "version": "0.19.0",
4
4
  "description": "Node-based short-drama studio for DeepSeek Harness: script → storyboard → assets → shots → voiced, subtitled final cut on an infinite canvas.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -31,7 +31,8 @@
31
31
  ".": "./index.js",
32
32
  "./client": "./client.js",
33
33
  "./package.json": "./package.json",
34
- "./locale/*.json": "./locale/*.json"
34
+ "./locale/*.json": "./locale/*.json",
35
+ "./assets/director/*.js": "./assets/director/*.js"
35
36
  },
36
37
  "files": [
37
38
  "index.js",
@@ -41,6 +42,8 @@
41
42
  "locale/*.json",
42
43
  "assets/*/SKILL.md",
43
44
  "assets/support/*",
45
+ "assets/director/*.js",
46
+ "assets/director/models/*.json",
44
47
  "README.md",
45
48
  "README.zh-CN.md",
46
49
  "docs/*.md",
Binary file
Binary file
Binary file
Binary file