@ninkch/dsh-infinite-canvas 0.18.7 → 0.19.1

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 +4475 -228
  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 +223 -5
  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
@@ -421,6 +421,10 @@ function dbAssetDelete(id) {
421
421
  const mapped = physicalRel(row.rel) || row.rel;
422
422
  const { abs } = safeJoin(root, mapped);
423
423
  unlinkSync(abs);
424
+ // 派生预览文件随原文件一起删(不存在则忽略)
425
+ for (const suffix of ['.thumb.jpg', '.poster.jpg']) {
426
+ try { unlinkSync(abs + suffix); } catch { /* 没有就忽略 */ }
427
+ }
424
428
  } catch { /* 已不存在视为成功 */ }
425
429
  }
426
430
  return { ok: true, deleted: true };
@@ -880,7 +884,8 @@ function dataRootImport() {
880
884
  }
881
885
  try {
882
886
  const list = JSON.parse(readFileSync(path.join(dir, 'assets.json'), 'utf8'));
883
- if (Array.isArray(list)) assets[id] = list;
887
+ // 派生预览文件不是资产:历史 data 里混入也要剔除
888
+ if (Array.isArray(list)) assets[id] = list.filter((a) => !isDerivedMedia(a && a.rel));
884
889
  } catch { /* 无资产索引 */ }
885
890
  // 多画布:画布文档镜像在 projects/<项目文件夹>/canvases/*.json(updatedAt 新者胜)
886
891
  try {
@@ -966,12 +971,30 @@ const MIME_BY_EXT = {
966
971
  webm: 'video/webm', mp4: 'video/mp4', mov: 'video/quicktime', mkv: 'video/x-matroska',
967
972
  wav: 'audio/wav', mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg',
968
973
  json: 'application/json', srt: 'text/plain; charset=utf-8', txt: 'text/plain; charset=utf-8',
974
+ // js/mjs:浏览器动态 import() 只认 text/javascript —— 导演台引擎模块(assets/director/engine.js)
975
+ // 走 plugin-asset 路由加载,没有这个 MIME 会被浏览器拒载("Failed to fetch dynamically imported module")
976
+ js: 'text/javascript; charset=utf-8', mjs: 'text/javascript; charset=utf-8',
969
977
  };
970
978
  function mimeOf(p) {
971
979
  const m = /\.([a-z0-9]+)$/i.exec(p);
972
980
  return m ? (MIME_BY_EXT[m[1].toLowerCase()] || 'application/octet-stream') : 'application/octet-stream';
973
981
  }
974
982
 
983
+ // ============================================================
984
+ // 派生预览文件(系统内部用,**不是资产**)
985
+ // <rel>.thumb.jpg 节点展示用的极小压缩图(图片/截图/视频首帧)
986
+ // <rel>.poster.jpg 视频首帧(历史口径,视作同一类派生文件)
987
+ // 规则:不进资产库 / assets.json / 导入扫描;随原文件删除;不分配资产 ID。
988
+ // ============================================================
989
+ const THUMB_MAX_SIDE = 512; // 缩略图最长边(px)
990
+ function isDerivedMedia(rel) {
991
+ return /\.(?:thumb|poster)\.jpg$/i.test(String(rel || '').trim());
992
+ }
993
+ function thumbRelOf(rel) {
994
+ const t = String(rel || '').trim();
995
+ return t ? t + '.thumb.jpg' : null;
996
+ }
997
+
975
998
  /**
976
999
  * 弹出系统"选择目录"对话框(阻塞至用户选择/取消)。
977
1000
  * Windows = PowerShell + WinForms FolderBrowserDialog(TopMost,可新建文件夹);
@@ -2772,7 +2795,7 @@ async function updateInstall() {
2772
2795
  export const inject = ['webServer', 'tools'];
2773
2796
 
2774
2797
  // 仅供诊断 / 自动化测试(不属于 RPC 面,运行时行为不受影响)
2775
- export const _internals = { updateCheck, updateInstall, npmLatestVersion, cmpVer, findPnpm, getProfileDir: () => PROFILE_DIR, getVersion: () => VERSION, dataRoot, setDataRoot, dataRootMigrate, dataRootImport, ensureProjectFolder, physicalRel, mirrorProjectToDataRoot, writeAssetIndex, removeAssetIndexEntry, readDataRootMeta, removeDataRootProject, sqlDeleteProjectRows, deletedProjectIds, addDeletedProjectId, clearDeletedProjectId, fetchProxyForward, storyboardConsistencyWarnings, readAssetIndexNames, safeJoin, mimeOf, pipeFile, getIcConfigPath: () => CONFIG_PATH, canvasCreateProject, canvasWriteNode, canvasConnect, canvasDeleteProject, canvasDeleteNode, canvasRunNode, canvasNodeStatus, canvasGetBible, canvasUpdateBible, canvasCreateNode, canvasOpen, canvasesList, canvasCreate, canvasRename, canvasDelete, canvasDocId, isCanvasDocId, splitCanvasDocId, normalizeCanvasId, canvasRegistry, sanitizeCanvases, readCanvasDoc, writeCanvasDoc, deleteCanvasDocsForProject, projectsList, projectsPut, projectsGet, projectsDelete, projectsSync, sqliteAvailable: () => !!SQLITE, getDb, closeDb, dbAssetsList, dbAssetsGet, dbAssetNames, dbAssetCreate, dbAssetUpdate, dbAssetDelete, objectAbsFor, ftsReconcile, readDocOrNull, docExists, sqlActive, rpcMethods: () => _rpcMethods, canvasUpdateNode, canvasGenerateNode };
2798
+ export const _internals = { THUMB_MAX_SIDE, thumbRelOf, isDerivedMedia, updateCheck, updateInstall, npmLatestVersion, cmpVer, findPnpm, getProfileDir: () => PROFILE_DIR, getVersion: () => VERSION, dataRoot, setDataRoot, dataRootMigrate, dataRootImport, ensureProjectFolder, physicalRel, mirrorProjectToDataRoot, writeAssetIndex, removeAssetIndexEntry, readDataRootMeta, removeDataRootProject, sqlDeleteProjectRows, deletedProjectIds, addDeletedProjectId, clearDeletedProjectId, fetchProxyForward, storyboardConsistencyWarnings, readAssetIndexNames, safeJoin, mimeOf, pipeFile, getIcConfigPath: () => CONFIG_PATH, canvasCreateProject, canvasWriteNode, canvasConnect, canvasDeleteProject, canvasDeleteNode, canvasRunNode, canvasNodeStatus, canvasGetBible, canvasUpdateBible, canvasCreateNode, canvasOpen, canvasesList, canvasCreate, canvasRename, canvasDelete, canvasDocId, isCanvasDocId, splitCanvasDocId, normalizeCanvasId, canvasRegistry, sanitizeCanvases, readCanvasDoc, writeCanvasDoc, deleteCanvasDocsForProject, projectsList, projectsPut, projectsGet, projectsDelete, projectsSync, sqliteAvailable: () => !!SQLITE, getDb, closeDb, dbAssetsList, dbAssetsGet, dbAssetNames, dbAssetCreate, dbAssetUpdate, dbAssetDelete, objectAbsFor, ftsReconcile, readDocOrNull, docExists, sqlActive, rpcMethods: () => _rpcMethods, canvasUpdateNode, canvasGenerateNode };
2776
2799
 
2777
2800
  function resolveModel(providers, want) {
2778
2801
  const byProvider = want && want.provider
@@ -2922,6 +2945,17 @@ export function apply(ctx) {
2922
2945
  );
2923
2946
  }
2924
2947
 
2948
+ /** 懒加载导演台引擎(纯 ESM 函数/常量,无 DOM 依赖),供导演台工具读取姿态/动作清单。 */
2949
+ let _directorEngine = null;
2950
+ const loadDirectorEngine = async () => {
2951
+ if (_directorEngine) return _directorEngine;
2952
+ try {
2953
+ const url = new URL('./assets/director/engine.js', import.meta.url).href;
2954
+ _directorEngine = await import(url);
2955
+ } catch { _directorEngine = null; }
2956
+ return _directorEngine;
2957
+ };
2958
+
2925
2959
  const methods = {
2926
2960
  async ping() { return { ok: true, version: VERSION }; },
2927
2961
  async llmOptions() { return listLlmOptions(); },
@@ -2958,6 +2992,30 @@ export function apply(ctx) {
2958
2992
  * 客户端知道自己要删哪个项目,Host 按项目物理文件夹定位(与 projectDirGet 同一基准),
2959
2993
  * 只删 projects/<folder>/craft 这一个子目录——绝不碰项目镜像、其他项目、数据根其他内容。
2960
2994
  * 幂等:目录不存在也是成功;失败可见(removed=false + reason),不静默吞。 */
2995
+ /** 导演台场景规格:AI / 脚本写入,客户端打开导演台时自动套用(存 <projectDir>/craft/director-stage.json)。 */
2996
+ async directorStageSave(args) {
2997
+ const dir = await methods.projectDirGet({ projectId: args && args.projectId });
2998
+ if (!dir || !dir.ok) return { ok: false, error: (dir && dir.error) || '项目目录不可用' };
2999
+ const craftDir = path.join(dir.abs, 'craft');
3000
+ try { mkdirSync(craftDir, { recursive: true }); } catch { /* 交给写入报错 */ }
3001
+ const file = path.join(craftDir, 'director-stage.json');
3002
+ const spec = (args && args.spec) || null;
3003
+ if (!spec || typeof spec !== 'object') return { ok: false, error: '缺少 spec 对象' };
3004
+ const payload = { ok: true, savedAt: Date.now(), spec };
3005
+ try { writeFileSync(file, JSON.stringify(payload, null, 2), 'utf8'); }
3006
+ catch (e) { return { ok: false, error: '写入失败: ' + String((e && e.message) || e) }; }
3007
+ return { ok: true, projectId: String(args.projectId), file, bytes: Buffer.byteLength(JSON.stringify(payload)) };
3008
+ },
3009
+ async directorStageLoad(args) {
3010
+ const dir = await methods.projectDirGet({ projectId: args && args.projectId });
3011
+ if (!dir || !dir.ok) return { ok: false, error: (dir && dir.error) || '项目目录不可用' };
3012
+ const file = path.join(dir.abs, 'craft', 'director-stage.json');
3013
+ if (!existsSync(file)) return { ok: true, found: false, file };
3014
+ try {
3015
+ const doc = JSON.parse(readFileSync(file, 'utf8'));
3016
+ return { ok: true, found: true, file, savedAt: doc.savedAt || null, spec: doc.spec || null };
3017
+ } catch (e) { return { ok: false, found: false, file, error: '读取失败: ' + String((e && e.message) || e) }; }
3018
+ },
2961
3019
  async projectCraftRemove(args) {
2962
3020
  const root = dataRoot();
2963
3021
  const pid = String((args && args.projectId) || '');
@@ -3500,6 +3558,76 @@ export function apply(ctx) {
3500
3558
  return { ok: true, projectId: r.projectId, text: '✅ 已重排 ' + r.moved + ' 个节点(' + r.rows + ' 行,无重叠)。' };
3501
3559
  },
3502
3560
  });
3561
+ reg({
3562
+ name: 'canvas_director_stage',
3563
+ description: '3D 导演台(预演场景)的总控工具:用一段 JSON 规格直接生成/更新整场预演,无需手动搭场景。\n' +
3564
+ 'action=apply:把 spec 写入项目并落盘(客户端打开导演台时自动套用;再点「导出视频」即得白模预演片)。\n' +
3565
+ 'action=get:读回当前项目的规格。\n' +
3566
+ 'action=help:返回规格 schema 与可用姿态/预设清单。\n' +
3567
+ 'spec 结构:{ title, duration, env{preset,bg,ground,grid,exposure,tonemap}, ' +
3568
+ '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}]}], ' +
3569
+ 'props[{preset,pos,scale,color}], cameras[{name,pos,target,fov,keys[{t,pos,target}]}], shots[{camera,start,end}] }。\n' +
3570
+ 'pose 取值见 action=help 的 poses;道具 preset 见 presets。坐标两维数组 [x,z] 自动补 y=0。',
3571
+ parameters: {
3572
+ type: 'object',
3573
+ properties: {
3574
+ action: { type: 'string', enum: ['apply', 'get', 'help', 'brief'], description: '操作类型' },
3575
+ brief: { type: 'string', description: '一句话描述(action=brief 时用,如「两人在巷子里打斗,8秒」);会套用打斗/追逐/对话模板生成规格并落盘' },
3576
+ template: { type: 'string', enum: ['fight', 'chase', 'dialogue'], description: '强制指定模板(不传则按 brief 关键词自动判断)' },
3577
+ duration: { type: 'number', description: '时长秒数(不传则从 brief 里抓「N秒」,否则用模板默认值)' },
3578
+ projectId: { type: 'string', description: '项目 id' },
3579
+ spec: { type: 'object', description: '场景规格(action=apply 时必填)' },
3580
+ },
3581
+ required: ['action'],
3582
+ additionalProperties: false,
3583
+ },
3584
+ output: textOutput,
3585
+ timeoutMs: 30000,
3586
+ async execute(args) {
3587
+ const action = String((args && args.action) || 'get');
3588
+ if (action === 'help') {
3589
+ const eng = await loadDirectorEngine();
3590
+ return {
3591
+ ok: true,
3592
+ text: '✅ 导演台规格说明\n' +
3593
+ '· 姿态 id:none, arms_up, punch, kick, flip, fly(轻功), crouch(下蹲), fight, walk, run, jump, hit, fall, walk_a, walk_b, run_a, run_b\n' +
3594
+ '· 道具 preset:box, sphere, cylinder, cone, plane, capsule, torus, pyramid, disc, arc\n' +
3595
+ '· 循环动作:poseAnim 首尾 pose 相同即可循环(如 walk_a→walk_b→walk_a)\n' +
3596
+ '· 路径:path 为二维地面点数组,物体沿路径运动并按切线自动转向(pathFace:false 关闭)\n' +
3597
+ '· 角色一律使用资产库的真实关节人偶(assetId=xbot):characters[].preset 可填 actors 列表里的体型,未填默认 m_standard',
3598
+ poses: (eng && eng.BONE_POSES ? eng.BONE_POSES.map((p) => ({ id: p.id, zh: p.zh, en: p.en })) : []),
3599
+ presets: ['box', 'sphere', 'cylinder', 'cone', 'plane', 'capsule', 'torus', 'pyramid', 'disc', 'arc'],
3600
+ actors: (eng && eng.ACTOR_PRESETS ? eng.ACTOR_PRESETS.map((p) => ({ preset: p.id, zh: p.zh, en: p.en, h: p.h })) : []),
3601
+ sequences: (eng && eng.ACTION_SEQUENCES ? eng.ACTION_SEQUENCES.map((s2) => ({ id: s2.id, zh: s2.zh, loop: !!s2.loop })) : []),
3602
+ };
3603
+ }
3604
+ if (action === 'brief') {
3605
+ const brief = String((args && args.brief) || '').trim();
3606
+ if (!brief) throw new Error('action=brief 需要 brief 一句话描述');
3607
+ const eng2 = await loadDirectorEngine();
3608
+ if (!eng2 || typeof eng2.specFromBrief !== 'function') throw new Error('引擎未就绪');
3609
+ const spec2 = eng2.specFromBrief(brief, { template: args.template, duration: args.duration });
3610
+ const r4 = await methods.directorStageSave({ projectId: args.projectId, spec: spec2 });
3611
+ if (!r4.ok) throw new Error(r4.error || '保存失败');
3612
+ return {
3613
+ ok: true, projectId: String(args.projectId || ''), spec: spec2,
3614
+ text: '✅ 已按「' + (spec2.title || brief) + '」生成预演规格并写入项目(' + (spec2.characters || []).length + ' 角色 / ' + (spec2.cameras || []).length + ' 机位 / ' + (spec2.shots || []).length + ' 分镜 / ' + spec2.duration + 's)。打开或刷新 3D 导演台即可看到;点「导出视频」出片。',
3615
+ };
3616
+ }
3617
+ if (action === 'get') {
3618
+ const r2 = await methods.directorStageLoad({ projectId: args.projectId });
3619
+ return { ok: !!r2.ok, projectId: String(args.projectId || ''), spec: r2.spec || undefined, text: r2.found ? '✅ 已读取导演台规格(savedAt=' + new Date(r2.savedAt || Date.now()).toISOString() + ')' : '(该项目还没有导演台规格)' };
3620
+ }
3621
+ if (!args || !args.spec || typeof args.spec !== 'object') throw new Error('action=apply 需要 spec 对象');
3622
+ const r3 = await methods.directorStageSave({ projectId: args.projectId, spec: args.spec });
3623
+ if (!r3.ok) throw new Error(r3.error || '保存失败');
3624
+ return {
3625
+ ok: true, projectId: String(args.projectId || ''), file: r3.file,
3626
+ text: '✅ 导演台规格已写入(' + r3.bytes + ' 字节)。打开/刷新画布的 3D 导演台会自动套用该场景;点「导出视频」即可产出白模预演片。',
3627
+ };
3628
+ },
3629
+ });
3630
+
3503
3631
  reg({
3504
3632
  name: 'canvas_delete_project',
3505
3633
  description: '删除无限画布上的一个项目(含其全部节点)。用户明确要求删除项目、或清理误创建的空项目时使用;删除前请先用 canvas_list_projects 确认 id。',
@@ -3806,7 +3934,7 @@ export function apply(ctx) {
3806
3934
  path: `/${NAME}/api/file`,
3807
3935
  async handler(req, res) {
3808
3936
  try {
3809
- if (req.method !== 'GET') { res.statusCode = 405; res.end('仅接受 GET'); return; }
3937
+ if (req.method !== 'GET' && req.method !== 'HEAD') { res.statusCode = 405; res.end('仅接受 GET/HEAD'); return; }
3810
3938
  const root = dataRoot();
3811
3939
  if (!root) { res.statusCode = 404; res.end('未设置数据根目录'); return; }
3812
3940
  const u = new URL(req.url, 'http://localhost');
@@ -3818,7 +3946,15 @@ export function apply(ctx) {
3818
3946
  if (!st.isFile()) { res.statusCode = 404; res.end('not found'); return; }
3819
3947
  res.setHeader('content-type', mimeOf(abs));
3820
3948
  res.setHeader('accept-ranges', 'bytes');
3821
- res.setHeader('cache-control', 'no-cache');
3949
+ // 派生预览文件内容不变 → 长缓存;原文件保持 no-cache
3950
+ res.setHeader('cache-control', isDerivedMedia(rel) ? 'public, max-age=31536000, immutable' : 'no-cache');
3951
+ // HEAD:只回应元信息(node fetch HEAD 探针用),不读文件、不占解码/内存
3952
+ if (req.method === 'HEAD') {
3953
+ res.statusCode = 200;
3954
+ res.setHeader('content-length', String(st.size));
3955
+ res.end();
3956
+ return;
3957
+ }
3822
3958
  const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || ''));
3823
3959
  if (m && (m[1] !== '' || m[2] !== '')) {
3824
3960
  let start;
@@ -3845,7 +3981,89 @@ export function apply(ctx) {
3845
3981
  }
3846
3982
  },
3847
3983
  }),
3848
- // CAS 对象库读路径(0.18):0.18.0–0.18.4 上传的媒体仍在 objects/ 里,必须继续可读。
3984
+ // 视频静态封面(poster)落盘:body 为 jpeg/png 字节,写入 `<rel>.poster.jpg`。
3985
+ // 有 poster 后画布视频节点默认只画一张 img(浏览器磁盘缓存、零视频解码),播放时才挂 <video>。
3986
+ ctx.webServer.register({
3987
+ kind: 'exact',
3988
+ path: `/${NAME}/api/poster`,
3989
+ async handler(req, res) {
3990
+ res.setHeader('content-type', 'application/json; charset=utf-8');
3991
+ try {
3992
+ if (req.method !== 'POST' && req.method !== 'PUT') {
3993
+ res.statusCode = 405;
3994
+ res.end(JSON.stringify({ error: '仅接受 POST/PUT' }));
3995
+ return;
3996
+ }
3997
+ const root = dataRoot();
3998
+ if (!root) throw new Error('未设置数据根目录');
3999
+ const u = new URL(req.url, 'http://localhost');
4000
+ const rawRel = String(u.searchParams.get('rel') || '').trim();
4001
+ if (!rawRel) throw new Error('缺少 rel');
4002
+ if (/\.poster\.jpg$/i.test(rawRel)) throw new Error('rel 不应包含 poster 后缀');
4003
+ const rel = physicalRel(rawRel) || rawRel; // 逻辑路径(pid)→ 物理路径(项目名文件夹)
4004
+ const { abs, clean } = safeJoin(root, rel + '.poster.jpg'); // safeJoin 会拒绝 ../ 与绝对路径
4005
+ mkdirSync(path.dirname(abs), { recursive: true });
4006
+ const chunks = [];
4007
+ let size = 0;
4008
+ const MAX_POSTER = 8 * 1024 * 1024; // 封面只应是缩略 JPEG,8MB 足够且防无界写盘
4009
+ for await (const c of req) {
4010
+ size += c.length;
4011
+ if (size > MAX_POSTER) { res.statusCode = 413; res.end(JSON.stringify({ error: 'poster 过大(上限 8MB)' })); return; }
4012
+ chunks.push(c);
4013
+ }
4014
+ if (!size) throw new Error('poster 内容为空');
4015
+ await fs.writeFile(abs, Buffer.concat(chunks));
4016
+ res.end(JSON.stringify({ ok: true, rel: clean, size }));
4017
+ } catch (e) {
4018
+ res.statusCode = 400;
4019
+ res.end(JSON.stringify({ error: String((e && e.message) || e) }));
4020
+ }
4021
+ },
4022
+ }),
4023
+ // 节点展示用缩略图落盘:body 为 jpeg/png,写入 `<rel>.thumb.jpg`。
4024
+ // 缩略图是系统派生文件:不进 assets.json、不出现在资产库;节点只加载它(几十 KB),
4025
+ // 点开看原图 / 点播放才加载原文件。
4026
+ ctx.webServer.register({
4027
+ kind: 'exact',
4028
+ path: `/${NAME}/api/thumb`,
4029
+ async handler(req, res) {
4030
+ res.setHeader('content-type', 'application/json; charset=utf-8');
4031
+ try {
4032
+ if (req.method !== 'POST' && req.method !== 'PUT') {
4033
+ res.statusCode = 405;
4034
+ res.end(JSON.stringify({ error: '仅接受 POST/PUT' }));
4035
+ return;
4036
+ }
4037
+ const root = dataRoot();
4038
+ if (!root) throw new Error('未设置数据根目录');
4039
+ const u = new URL(req.url, 'http://localhost');
4040
+ const rawRel = String(u.searchParams.get('rel') || '').trim();
4041
+ if (!rawRel) throw new Error('缺少 rel');
4042
+ if (/\.thumb\.jpg$/i.test(rawRel)) throw new Error('rel 不应包含 thumb 后缀');
4043
+ const ct = String(req.headers['content-type'] || '').toLowerCase();
4044
+ if (ct && !/^image\/(jpeg|png)/.test(ct)) throw new Error('缩略图只接受 image/jpeg 或 image/png');
4045
+ const rel = physicalRel(rawRel) || rawRel;
4046
+ const { abs, clean } = safeJoin(root, rel + '.thumb.jpg');
4047
+ mkdirSync(path.dirname(abs), { recursive: true });
4048
+ const chunks = [];
4049
+ let size = 0;
4050
+ const MAX_THUMB = 2 * 1024 * 1024;
4051
+ for await (const c of req) {
4052
+ size += c.length;
4053
+ if (size > MAX_THUMB) { res.statusCode = 413; res.end(JSON.stringify({ error: 'thumb 过大(上限 2MB)' })); return; }
4054
+ chunks.push(c);
4055
+ }
4056
+ if (!size) throw new Error('thumb 内容为空');
4057
+ await fs.writeFile(abs, Buffer.concat(chunks));
4058
+ res.end(JSON.stringify({ ok: true, rel: clean, size, maxSide: THUMB_MAX_SIDE }));
4059
+ } catch (e) {
4060
+ res.statusCode = 400;
4061
+ res.end(JSON.stringify({ error: String((e && e.message) || e) }));
4062
+ }
4063
+ },
4064
+ }),
4065
+
4066
+ // CAS 对象库读路径(0.18):0.18.0–0.18.4 上传的媒体仍在 objects/ 里,必须继续可读。
3849
4067
  // IC-08 起写路径已退役(POST/PUT 一律 405),只有 GET。
3850
4068
  ctx.webServer.register({
3851
4069
  kind: 'exact',
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.1",
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