@ninkch/dsh-infinite-canvas 0.17.3 → 0.17.4

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/README.md CHANGED
@@ -76,7 +76,7 @@ Publishing runs through **npm Trusted Publishing** (OIDC) — no npm token exist
76
76
 
77
77
  ## Data & privacy
78
78
 
79
- - Canvas documents, projects, settings and the series bible live in browser `localStorage` (`dsh-ic:*`); assets live in IndexedDB (`dsh-infinite-canvas`).
79
+ - Canvas documents, projects, settings and the series bible live in browser `localStorage` (`dsh-ic:*`); assets live in IndexedDB (`dsh-infinite-canvas`) — **or on disk** once a data root is set in Settings (per-project folders under the configured root, served by the plugin backend with HTTP Range support for video seeking).
80
80
  - AI text generation runs through the Harness host process (your configured models, no extra keys).
81
81
  - Image/video/TTS calls go directly from the browser to the endpoints you configure — mind CORS and key handling.
82
82
  - Nothing is uploaded anywhere by the plugin itself.
package/README.zh-CN.md CHANGED
@@ -76,7 +76,7 @@ flowchart LR
76
76
 
77
77
  ## 数据与隐私
78
78
 
79
- - 画布文档、项目、设置与设定集存于浏览器 `localStorage`(`dsh-ic:*`);资产存于 IndexedDB(`dsh-infinite-canvas`)。
79
+ - 画布文档、项目、设置与设定集存于浏览器 `localStorage`(`dsh-ic:*`);资产存于 IndexedDB(`dsh-infinite-canvas`)——**或在设置中配置数据根目录后落盘**(按项目分目录存放,由插件后台以 HTTP Range 方式服务,支持视频拖动进度条)。
80
80
  - AI 文本经 Harness 宿主进程调用(使用你已配置的模型,无需额外 Key)。
81
81
  - 图片/视频/TTS 请求由浏览器直连你配置的地址——请注意 CORS 与密钥保管。
82
82
  - 插件自身不向任何第三方上传数据。
package/client.js CHANGED
@@ -338,6 +338,20 @@ window.__ModuleLoader__.load({
338
338
  'updateInstalledHint': '重启 Harness 后生效(网页部分刷新页面即可)',
339
339
  'updateFailGuide': '自动安装未成功,请在 DSH 插件管理 中升级',
340
340
  'updatePolicySkipped': '(已按你的点击跳过 24 小时新版本冷却期)',
341
+ 'dataRootLabel': '数据根目录',
342
+ 'dataRootPlaceholder': '如 D:\\canvas-data',
343
+ 'dataRootHint': '资产与视频按项目落盘到该目录(不再占用 C 盘 IndexedDB)。留空 = 浏览器 IndexedDB。',
344
+ 'dataRootSave': '设置此目录',
345
+ 'dataRootOpen': '打开目录',
346
+ 'dataRootMigrate': '迁移历史资产',
347
+ 'dataRootMigrated': '已迁移 {n} 个资产到数据根目录',
348
+ 'dataRootMigratedDone': '已迁移',
349
+ 'dataRootMigrating': '迁移中',
350
+ 'dataRootUnsupported': '当前插件后台版本较旧(不支持数据根目录):重启 Harness 后可用',
351
+ 'dataRootSaved': '数据根目录已设置',
352
+ 'dataRootBrowse': '浏览选择目录',
353
+ 'dataRootPicking': '请在弹出的窗口中选择目录(可直接取消)…',
354
+ 'dataRootPickUnsupported': '当前平台不支持目录选择器,请手动填写路径',
341
355
  'updateCurrent': '当前',
342
356
  'updateGo': '去升级',
343
357
  'npmUpgradeHint': '请在 DSH 插件管理 中升级到最新版(本插件绝不自动安装或重启);安装完成后重启 Harness 生效',
@@ -600,6 +614,20 @@ window.__ModuleLoader__.load({
600
614
  'updateInstalledHint': 'restart Harness to take effect (refresh the page for the web part)',
601
615
  'updateFailGuide': 'Auto-install failed — please upgrade via the DSH Plugin Manager',
602
616
  'updatePolicySkipped': '(skipped the 24-hour release cool-down at your click)',
617
+ 'dataRootLabel': 'Data root',
618
+ 'dataRootPlaceholder': 'e.g. D:\\canvas-data',
619
+ 'dataRootHint': 'Assets and videos are stored per project under this folder (no more C: IndexedDB). Empty = browser IndexedDB.',
620
+ 'dataRootSave': 'Set directory',
621
+ 'dataRootOpen': 'Open folder',
622
+ 'dataRootMigrate': 'Migrate existing assets',
623
+ 'dataRootMigrated': 'Migrated {n} assets to the data root',
624
+ 'dataRootMigratedDone': 'Migrated',
625
+ 'dataRootMigrating': 'Migrating',
626
+ 'dataRootUnsupported': 'The plugin backend is older and lacks data-root support — restart Harness to enable',
627
+ 'dataRootSaved': 'Data root set',
628
+ 'dataRootBrowse': 'Browse for folder',
629
+ 'dataRootPicking': 'Pick a folder in the dialog (cancel anytime)…',
630
+ 'dataRootPickUnsupported': 'No folder picker on this platform — type the path instead',
603
631
  'updateCurrent': 'current',
604
632
  'updateGo': 'Upgrade',
605
633
  'npmUpgradeHint': 'Please upgrade via the DSH Plugin Manager (this plugin never auto-installs or restarts); restart Harness after installing',
@@ -1047,30 +1075,81 @@ window.__ModuleLoader__.load({
1047
1075
  }
1048
1076
  const assetUrlCache = new Map();
1049
1077
  const assetNameCache = new Map(); // assetId -> name(连线绑定的同步名称查询)
1078
+ function fileUrlFor(rel) { return '/' + NS + '/api/file?rel=' + encodeURIComponent(rel); }
1079
+ function extFromMime(mime) {
1080
+ const sub = String(mime || '').split(';')[0].split('/')[1] || 'bin';
1081
+ return /^[a-z0-9]{1,8}$/i.test(sub) ? sub : 'bin';
1082
+ }
1050
1083
  async function assetUrl(id) {
1051
1084
  if (!id) return null;
1052
1085
  if (assetUrlCache.has(id)) return assetUrlCache.get(id);
1053
1086
  const rec = await idbGet(id);
1054
- if (!rec || !rec.blob) return null;
1055
- const url = URL.createObjectURL(rec.blob);
1056
- assetUrlCache.set(id, url);
1087
+ if (!rec) return null;
1088
+ let url = null;
1089
+ if (rec.rel) url = fileUrlFor(rec.rel); // 磁盘资产:Host 文件服务(支持 Range)
1090
+ else if (rec.blob) url = URL.createObjectURL(rec.blob); // 旧资产:IndexedDB blob
1091
+ if (url) assetUrlCache.set(id, url);
1057
1092
  return url;
1058
1093
  }
1094
+ /** 取资产 Blob:磁盘资产经 Host 文件服务拉取,旧资产直接读 IndexedDB。 */
1095
+ async function assetBlob(id) {
1096
+ const rec = await idbGet(id);
1097
+ if (!rec) return null;
1098
+ if (rec.blob) return rec.blob;
1099
+ if (rec.rel) {
1100
+ const r = await fetch(fileUrlFor(rec.rel));
1101
+ if (!r.ok) throw new Error('资产读取失败 HTTP ' + r.status);
1102
+ return await r.blob();
1103
+ }
1104
+ return null;
1105
+ }
1059
1106
  async function saveAssetRecord(projectId, kind, blob, meta) {
1107
+ const id = uid('a');
1108
+ const mime = blob.type || (kind === 'image' ? 'image/png' : 'video/webm');
1060
1109
  const rec = {
1061
- id: uid('a'), projectId, kind,
1062
- mime: blob.type || (kind === 'image' ? 'image/png' : 'video/webm'),
1110
+ id, projectId, kind,
1111
+ mime,
1063
1112
  name: (meta && meta.name) || (kind === 'image' ? 'image' : 'video') + '-' + Date.now().toString(36),
1064
- blob,
1065
1113
  createdAt: Date.now(),
1066
1114
  prompt: (meta && meta.prompt) || '',
1067
1115
  source: (meta && meta.source) || 'demo',
1068
1116
  category: (meta && meta.category) || 'general',
1069
1117
  };
1118
+ if (S.dataRoot) {
1119
+ // 数据根目录已配置:落盘到 <root>/projects/<项目>/<类型>/<id>.<ext>,IndexedDB 只存元数据
1120
+ const rel = `projects/${projectId}/${kind}/${id}.${extFromMime(mime)}`;
1121
+ const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: blob });
1122
+ if (!r.ok) throw new Error('资产落盘失败: ' + (await r.text()));
1123
+ rec.rel = rel;
1124
+ rec.size = blob.size;
1125
+ rec.stored = 'disk';
1126
+ } else {
1127
+ rec.blob = blob;
1128
+ rec.stored = 'idb';
1129
+ }
1070
1130
  await idbPut(rec);
1071
1131
  assetNameCache.set(rec.id, rec.name);
1072
1132
  return rec;
1073
1133
  }
1134
+ /** 一次性把 IndexedDB 里的历史资产迁移到数据根目录(保留元数据,移除 blob)。 */
1135
+ async function migrateAssetsToDisk(onProgress) {
1136
+ if (!S.dataRoot) throw new Error('请先设置数据根目录');
1137
+ const all = await idbAll();
1138
+ const targets = all.filter((r) => r.blob && !r.rel);
1139
+ let done = 0;
1140
+ for (const rec of targets) {
1141
+ const rel = `projects/${rec.projectId}/${rec.kind}/${rec.id}.${extFromMime(rec.mime)}`;
1142
+ const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: rec.blob });
1143
+ if (!r.ok) throw new Error('迁移失败: ' + rel + ' — ' + (await r.text()));
1144
+ const next = { ...rec, rel, size: rec.blob.size, stored: 'disk' };
1145
+ delete next.blob;
1146
+ await idbPut(next);
1147
+ assetUrlCache.delete(rec.id); // 旧 objectURL 失效,改走文件 URL
1148
+ done++;
1149
+ if (onProgress) onProgress(done, targets.length);
1150
+ }
1151
+ return { migrated: done, total: targets.length };
1152
+ }
1074
1153
  function useAssetUrl(id) {
1075
1154
  const [url, setUrl] = useState(null);
1076
1155
  useEffect(() => {
@@ -1696,7 +1775,9 @@ window.__ModuleLoader__.load({
1696
1775
  const map = craftMapLoad(pid);
1697
1776
  if (!opts.force && map[rec.id]) return map[rec.id];
1698
1777
  const rel = craftProjectSlug() + '/media/' + rec.id + extForMime(rec.mime, rec.name);
1699
- await craft.write(rel, rec.blob);
1778
+ const blob = rec.blob || await assetBlob(rec.id);
1779
+ if (!blob) throw new Error('资产内容不可用: ' + rec.id);
1780
+ await craft.write(rel, blob);
1700
1781
  craftMapSet(pid, rec.id, rel);
1701
1782
  return rel;
1702
1783
  }
@@ -1880,6 +1961,15 @@ window.__ModuleLoader__.load({
1880
1961
  update((s) => { s.updateBusy = false; s.updateMsg = T('npmUpgradeHint') + ((S.updateInfo && S.updateInfo.remoteVersion) ? ' → v' + S.updateInfo.remoteVersion : ''); });
1881
1962
  }
1882
1963
  }
1964
+ /** 从 Host 拉取数据根目录配置(Host 旧版无此 RPC → 标记不支持)。 */
1965
+ async function refreshDataRoot() {
1966
+ try {
1967
+ const r = await hostRpc('dataRootGet');
1968
+ update((s) => { s.dataRoot = r.dataRoot || null; s.dataRootExists = !!r.exists; s.dataRootSupported = true; });
1969
+ } catch (e) {
1970
+ update((s) => { s.dataRoot = null; s.dataRootSupported = !/未知操作/.test(String((e && e.message) || e)); });
1971
+ }
1972
+ }
1883
1973
 
1884
1974
  // ============================================================
1885
1975
  // 尺寸 / 比例(Toonflow 式:清晰度 × 比例 → 像素)
@@ -2138,6 +2228,9 @@ window.__ModuleLoader__.load({
2138
2228
  assetsTick: 0,
2139
2229
  tick: 0,
2140
2230
  craftStatus: null, // {at, data} | {at, error}
2231
+ dataRoot: null, // 数据根目录(Host 配置);null = 未设置(资产走 IndexedDB)
2232
+ dataRootExists: false,
2233
+ dataRootSupported: true,
2141
2234
  updateInfo: null, // Host updateCheck 结果
2142
2235
  updateChecking: false,
2143
2236
  updateBusy: false,
@@ -2483,8 +2576,7 @@ window.__ModuleLoader__.load({
2483
2576
  if (!src) return null;
2484
2577
  const aid = servedAssetId(src, e.fromPort);
2485
2578
  if (!aid) return null;
2486
- const rec = await idbGet(aid);
2487
- return rec ? rec.blob : null;
2579
+ return await assetBlob(aid);
2488
2580
  }
2489
2581
  // 多参考图:按节点上的显式顺序(refOrder,未排序时按连线先后)解析,带名称/分类供 [图N] 绑定
2490
2582
  async function resolveRefMetas(nodeId, portId) {
@@ -2501,7 +2593,8 @@ window.__ModuleLoader__.load({
2501
2593
  if (!aid) continue;
2502
2594
  try {
2503
2595
  const rec = await idbGet(aid);
2504
- if (rec && rec.blob) metas.push({ blob: rec.blob, name: rec.name || '', category: rec.category || 'general' });
2596
+ const blob = await assetBlob(aid);
2597
+ if (blob) metas.push({ blob, name: (rec && rec.name) || '', category: (rec && rec.category) || 'general' });
2505
2598
  } catch { /* 单个参考图读取失败不影响其余 */ }
2506
2599
  }
2507
2600
  return metas;
@@ -3894,10 +3987,11 @@ window.__ModuleLoader__.load({
3894
3987
  if (!x.nd.data.assetId) continue;
3895
3988
  try {
3896
3989
  const rec = await idbGet(x.nd.data.assetId);
3897
- if (rec && rec.blob) {
3990
+ const blob = await assetBlob(x.nd.data.assetId);
3991
+ if (blob) {
3898
3992
  const ttsId = n.data.tts && n.data.tts[x.e.id] && n.data.tts[x.e.id].assetId;
3899
3993
  const ttsUrl = ttsId ? await assetUrl(ttsId) : null;
3900
- metas.push({ url: URL.createObjectURL(rec.blob), name: rec.name || 'seg', sub: getSub(x), ttsUrl });
3994
+ metas.push({ url: URL.createObjectURL(blob), name: (rec && rec.name) || 'seg', sub: getSub(x), ttsUrl });
3901
3995
  }
3902
3996
  } catch { /* 跳过加载失败的片段 */ }
3903
3997
  }
@@ -3933,9 +4027,10 @@ window.__ModuleLoader__.load({
3933
4027
  if (!x.nd.data.assetId) continue;
3934
4028
  try {
3935
4029
  const rec = await idbGet(x.nd.data.assetId);
3936
- if (!(rec && rec.blob)) continue;
3937
- const rel = await ensureAssetOnDisk(rec);
3938
- items.push({ rel, name: rec.name || 'seg', sub: getSub(x), dur: await videoDurationOf(rec.blob) });
4030
+ const blob = await assetBlob(x.nd.data.assetId);
4031
+ if (!blob) continue;
4032
+ const rel = await ensureAssetOnDisk({ ...(rec || {}), id: x.nd.data.assetId, blob });
4033
+ items.push({ rel, name: (rec && rec.name) || 'seg', sub: getSub(x), dur: await videoDurationOf(blob) });
3939
4034
  } catch (e) { toast('Craft: ' + (e.message || e)); return; }
3940
4035
  }
3941
4036
  if (!items.length) { toast(T('noSegments')); return; }
@@ -4297,7 +4392,12 @@ window.__ModuleLoader__.load({
4297
4392
  lsSet(LS_ACTIVE, S.activeId);
4298
4393
  try {
4299
4394
  const recs = await idbByProject(pid);
4300
- for (const r of recs) { try { await idbDelete(r.id); } catch { /* 逐个容错 */ } }
4395
+ for (const r of recs) {
4396
+ try {
4397
+ if (r.rel) { try { await hostRpc('assetDelete', { rel: r.rel }); } catch { /* 磁盘清理失败不阻塞 */ } }
4398
+ await idbDelete(r.id);
4399
+ } catch { /* 逐个容错 */ }
4400
+ }
4301
4401
  } catch { /* 资产清理失败不影响项目删除 */ }
4302
4402
  setDelTarget(null);
4303
4403
  };
@@ -4455,6 +4555,7 @@ window.__ModuleLoader__.load({
4455
4555
  if (files.length) setTick((x) => x + 1);
4456
4556
  };
4457
4557
  const del = async (a) => {
4558
+ if (a.rel) { try { await hostRpc('assetDelete', { rel: a.rel }); } catch { /* 磁盘清理失败不阻塞 */ } }
4458
4559
  await idbDelete(a.id);
4459
4560
  update((s) => {
4460
4561
  for (const nd of Object.values(s.graph.nodes)) {
@@ -4468,7 +4569,7 @@ window.__ModuleLoader__.load({
4468
4569
  const syncAll = async () => {
4469
4570
  const st = await craftStatusSafe();
4470
4571
  if (!st) return;
4471
- const recs = assets.filter((a) => a.blob);
4572
+ const recs = assets.filter((a) => a.blob || a.rel);
4472
4573
  let okN = 0, failN = 0;
4473
4574
  for (const a of recs) {
4474
4575
  try { await syncAssetToDisk(a, { force: true }); okN++; } catch { failN++; }
@@ -4838,7 +4939,7 @@ window.__ModuleLoader__.load({
4838
4939
  if (!(st.bins && st.bins.photocraft && st.bins.photocraft.found)) { toast(T('craftCliMissing').replace('{name}', 'photocraft')); return; }
4839
4940
  let paramsObj;
4840
4941
  try { paramsObj = JSON.parse(paramsText || '{}'); } catch { toast(T('craftInvalidJson')); return; }
4841
- if (!rec || !rec.blob) { toast(T('noResult')); return; }
4942
+ if (!rec || (!rec.blob && !rec.rel)) { toast(T('noResult')); return; }
4842
4943
  setRunning(true); setErr(null); setProgress('…');
4843
4944
  try {
4844
4945
  const mediaRel = await ensureAssetOnDisk(rec);
@@ -4945,6 +5046,77 @@ window.__ModuleLoader__.load({
4945
5046
  ));
4946
5047
  }
4947
5048
 
5049
+ /** 数据根目录卡片:设置/打开/迁移历史资产。 */
5050
+ function DataRootCard() {
5051
+ const [val, setVal] = useState('');
5052
+ const [busy, setBusy] = useState(false);
5053
+ const [picking, setPicking] = useState(false);
5054
+ const [mig, setMig] = useState(null); // null | {done,total} | 'done'
5055
+ useEffect(() => { if (S.dataRoot) setVal(S.dataRoot); }, [S.dataRoot]);
5056
+ const save = async () => {
5057
+ if (busy) return;
5058
+ setBusy(true);
5059
+ try {
5060
+ await hostRpc('dataRootSet', { root: val.trim() });
5061
+ await refreshDataRoot();
5062
+ toast(T('dataRootSaved') + (S.dataRoot ? ':' + S.dataRoot : ''));
5063
+ } catch (e) { toast('设置失败:' + ((e && e.message) || e)); }
5064
+ setBusy(false);
5065
+ };
5066
+ const applyRoot = async (root) => {
5067
+ await hostRpc('dataRootSet', { root });
5068
+ await refreshDataRoot();
5069
+ setVal(S.dataRoot || root);
5070
+ toast(T('dataRootSaved') + ':' + S.dataRoot);
5071
+ };
5072
+ const browse = async () => {
5073
+ if (busy || picking) return;
5074
+ setBusy(true); setPicking(true);
5075
+ try {
5076
+ const r = await hostRpc('pickFolder');
5077
+ if (r && r.ok && r.path) await applyRoot(r.path);
5078
+ else if (r && r.unsupported) toast(T('dataRootPickUnsupported'));
5079
+ // cancelled → 静默
5080
+ } catch (e) {
5081
+ const msg = String((e && e.message) || e);
5082
+ toast(/未知操作/.test(msg) ? T('dataRootUnsupported') : '目录选择失败:' + msg);
5083
+ }
5084
+ setPicking(false); setBusy(false);
5085
+ };
5086
+ const openDir = async () => { try { await hostRpc('dataRootReveal'); } catch (e) { toast((e && e.message) || e); } };
5087
+ const migrate = async () => {
5088
+ if (busy || picking || mig) return;
5089
+ setBusy(true); setMig({ done: 0, total: 0 });
5090
+ try {
5091
+ const r = await migrateAssetsToDisk((done, total) => setMig({ done, total }));
5092
+ setMig('done');
5093
+ toast(T('dataRootMigrated').replace('{n}', String(r.migrated)));
5094
+ } catch (e) { setMig(null); toast('迁移失败:' + ((e && e.message) || e)); }
5095
+ setBusy(false);
5096
+ };
5097
+ return h('div', { key: 'dataroot', className: 'dshic-card' },
5098
+ h('div', { className: 'dshic-row' }, Ic('image', 12), h('strong', { style: { fontSize: 12 } }, T('dataRootLabel'))),
5099
+ S.dataRootSupported === false
5100
+ ? h('div', { className: 'dshic-hint' }, T('dataRootUnsupported'))
5101
+ : [
5102
+ h('div', { className: 'dshic-row', key: 'row1' },
5103
+ h('input', {
5104
+ className: 'dshic-input', value: val, placeholder: T('dataRootPlaceholder'),
5105
+ onChange: (e) => setVal(e.target.value), onPointerDown: (e) => e.stopPropagation(),
5106
+ }),
5107
+ h('button', { type: 'button', className: 'dshic-mini', title: T('dataRootBrowse'), disabled: busy || picking, onPointerDown: (e) => e.stopPropagation(), onClick: browse }, Ic('folder', 12)),
5108
+ h('button', { type: 'button', className: 'dshic-mini', disabled: busy || picking, onPointerDown: (e) => e.stopPropagation(), onClick: save }, T('dataRootSave')),
5109
+ ),
5110
+ picking ? h('div', { className: 'dshic-hint', key: 'picking' }, T('dataRootPicking')) : null,
5111
+ h('div', { className: 'dshic-hint', key: 'hint' }, T('dataRootHint')),
5112
+ h('div', { className: 'dshic-row', key: 'row2' },
5113
+ S.dataRoot ? h('button', { type: 'button', className: 'dshic-mini', onPointerDown: (e) => e.stopPropagation(), onClick: openDir }, T('dataRootOpen')) : null,
5114
+ S.dataRoot ? h('button', { type: 'button', className: 'dshic-mini', disabled: busy || picking || !!mig, onPointerDown: (e) => e.stopPropagation(), onClick: migrate }, mig === 'done' ? T('dataRootMigratedDone') : T('dataRootMigrate')) : null,
5115
+ (mig && mig !== 'done') ? h('span', { style: { fontSize: 11, opacity: 0.85 } }, T('dataRootMigrating') + ' ' + mig.done + '/' + mig.total) : null,
5116
+ ),
5117
+ ],
5118
+ );
5119
+ }
4948
5120
  function SettingsTab() {
4949
5121
  const [s, setS] = useState(loadSettings);
4950
5122
  const apply = (fn) => {
@@ -4958,6 +5130,7 @@ window.__ModuleLoader__.load({
4958
5130
  const field = (label, value, onChange, placeholder, password) => h(Field, { label, value, onChange, placeholder, password });
4959
5131
  return [
4960
5132
  h(CraftCard, { key: 'craft' }),
5133
+ h(DataRootCard, { key: 'dataroot' }),
4961
5134
  h('div', { key: 'lang', className: 'dshic-card' },
4962
5135
  h('div', { className: 'dshic-row' }, Ic('text', 12), h('strong', { style: { fontSize: 12 } }, T('langLabel'))),
4963
5136
  h('select', {
@@ -4983,7 +5156,7 @@ window.__ModuleLoader__.load({
4983
5156
  h(ApiListEditor, { key: 'imglist', kind: 'image' }),
4984
5157
  h(ApiListEditor, { key: 'vidlist', kind: 'video' }),
4985
5158
  h(ApiListEditor, { key: 'ttslist', kind: 'tts' }),
4986
- h('div', { key: 'note', className: 'dshic-hint' }, 'DeepSeek Harness · dsh-infinite-canvas v0.17.1'),
5159
+ h('div', { key: 'note', className: 'dshic-hint' }, 'DeepSeek Harness · dsh-infinite-canvas' + ((S.updateInfo && S.updateInfo.runningVersion) ? ' v' + S.updateInfo.runningVersion : '')),
4987
5160
  ];
4988
5161
  }
4989
5162
 
@@ -5317,6 +5490,8 @@ window.__ModuleLoader__.load({
5317
5490
  probeCraft();
5318
5491
  // 检查更新(Host RPC;Host 旧版未带此方法时静默)
5319
5492
  checkUpdate(true);
5493
+ // 数据根目录(Host 旧版静默降级为 IndexedDB 模式)
5494
+ refreshDataRoot();
5320
5495
  // 后台路由探活:失败时在画布顶部给出可操作的提示
5321
5496
  hostRpc('ping').then(() => { S.hostOk = true; S.hostError = null; notify(); }).catch((e) => {
5322
5497
  S.hostOk = false;
package/docs/release.md CHANGED
@@ -9,7 +9,8 @@
9
9
  2. **绝不自动重启 Harness、绝不自动刷新页面。** 更新/安装后只提示(横幅/文案),何时生效由用户决定。
10
10
  3. **beta 版本号绝不复用**(npm 本身也禁止覆盖已发布版本);**正式版本号只在 main 上诞生**。
11
11
  4. **版本单一来源 = `package.json`**。改版本一律用 `npm version …`,不手改;Host 运行时读取它,禁止在代码里硬编码版本号。
12
- 5. npm token 只通过命令行注入,**绝不写入任何文件**。
12
+ 5. **版本号一律按 0.0.x(patch)递增**(如 0.17.3 → 0.17.4),不做 minor/major 升级——新功能同样走 patch 号。
13
+ 6. npm token 只通过命令行注入,**绝不写入任何文件**。
13
14
 
14
15
  ## 1. 测试三层漏斗
15
16
 
package/index.js CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { spawn } from 'node:child_process';
10
10
  import fs from 'node:fs/promises';
11
- import { readFileSync, existsSync, readdirSync } from 'node:fs';
11
+ import { createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
12
12
  import os from 'node:os';
13
13
  import path from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
@@ -106,6 +106,87 @@ async function ensureBundleEntry(profileDir, pkg) {
106
106
  return true;
107
107
  }
108
108
 
109
+ // ---------- 数据根目录(资产落盘)----------
110
+ // 配置存在 ~/.dsh/infinite-canvas/config.json:跨重装/重启稳定,绝不放进包目录。
111
+ const CONFIG_PATH = path.join(os.homedir(), '.dsh', 'infinite-canvas', 'config.json');
112
+
113
+ function readIcConfig() {
114
+ try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) || {}; } catch { return {}; }
115
+ }
116
+ function writeIcConfig(cfg) {
117
+ mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
118
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
119
+ }
120
+ function dataRoot() {
121
+ const r = readIcConfig().dataRoot;
122
+ return (typeof r === 'string' && r.trim()) ? path.resolve(r.trim()) : null;
123
+ }
124
+ /** rel 限定在 dataRoot 之内:拒绝绝对路径 / 盘符 / 上级跳转,返回 { abs, clean }。 */
125
+ function safeJoin(root, rel) {
126
+ const clean = String(rel || '').replace(/\\/g, '/').replace(/^\/+/, '').trim();
127
+ if (!clean) throw new Error('缺少相对路径');
128
+ if (/(^|\/)\.\.(\/|$)/.test(clean) || /^[a-z]:/i.test(clean)) throw new Error('非法路径: ' + rel);
129
+ const rootAbs = path.resolve(root);
130
+ const abs = path.resolve(rootAbs, ...clean.split('/'));
131
+ if (abs !== rootAbs && !abs.startsWith(rootAbs + path.sep)) throw new Error('路径越界');
132
+ return { abs, clean };
133
+ }
134
+ const MIME_BY_EXT = {
135
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', svg: 'image/svg+xml',
136
+ webm: 'video/webm', mp4: 'video/mp4', mov: 'video/quicktime', mkv: 'video/x-matroska',
137
+ wav: 'audio/wav', mp3: 'audio/mpeg', m4a: 'audio/mp4', ogg: 'audio/ogg',
138
+ json: 'application/json', srt: 'text/plain; charset=utf-8', txt: 'text/plain; charset=utf-8',
139
+ };
140
+ function mimeOf(p) {
141
+ const m = /\.([a-z0-9]+)$/i.exec(p);
142
+ return m ? (MIME_BY_EXT[m[1].toLowerCase()] || 'application/octet-stream') : 'application/octet-stream';
143
+ }
144
+
145
+ /**
146
+ * 弹出系统"选择目录"对话框(阻塞至用户选择/取消)。
147
+ * Windows = PowerShell + WinForms FolderBrowserDialog(TopMost,可新建文件夹);
148
+ * macOS = osascript choose folder;Linux = zenity(若安装)。均不可用 → { ok:false, unsupported:true }。
149
+ */
150
+ function pickFolderDialog() {
151
+ return new Promise((resolve) => {
152
+ const collect = (child) => {
153
+ const stdout = [];
154
+ child.stdout?.setEncoding('utf8');
155
+ child.stdout?.on('data', (d) => stdout.push(String(d)));
156
+ const timer = setTimeout(() => { try { child.kill(); } catch { /* 忽略 */ } }, 600000);
157
+ child.on('error', (e) => { clearTimeout(timer); resolve({ ok: false, unsupported: e.code === 'ENOENT', error: String(e?.message ?? e) }); });
158
+ child.on('close', (code) => {
159
+ clearTimeout(timer);
160
+ const picked = stdout.join('').trim().split(/\r?\n/).filter(Boolean).pop() || '';
161
+ if (code === 0 && picked) resolve({ ok: true, path: picked });
162
+ else resolve({ ok: false, cancelled: true });
163
+ });
164
+ };
165
+ try {
166
+ if (process.platform === 'win32') {
167
+ const script = [
168
+ '$ErrorActionPreference="Stop"',
169
+ '[Console]::OutputEncoding=[System.Text.Encoding]::UTF8',
170
+ 'Add-Type -AssemblyName System.Windows.Forms | Out-Null',
171
+ '$o = New-Object System.Windows.Forms.Form',
172
+ '$o.TopMost = $true',
173
+ '$d = New-Object System.Windows.Forms.FolderBrowserDialog',
174
+ "$d.Description = '选择数据根目录(资产与视频将存到该目录)'",
175
+ '$d.ShowNewFolderButton = $true',
176
+ 'if ($d.ShowDialog($o) -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $d.SelectedPath }',
177
+ ].join('; ');
178
+ collect(spawn('powershell.exe', ['-NoProfile', '-STA', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-Command', script], { windowsHide: true }));
179
+ } else if (process.platform === 'darwin') {
180
+ collect(spawn('osascript', ['-e', 'POSIX path of (choose folder with prompt "选择数据根目录")'], { windowsHide: true }));
181
+ } else {
182
+ collect(spawn('zenity', ['--file-selection', '--directory', '--title=选择数据根目录'], { windowsHide: true }));
183
+ }
184
+ } catch (e) {
185
+ resolve({ ok: false, unsupported: true, error: String((e && e.message) || e) });
186
+ }
187
+ });
188
+ }
189
+
109
190
  /** 读取 npm registry 上 latest dist-tag 的版本号。 */
110
191
  async function npmLatestVersion() {
111
192
  const res = await fetch(NPM_LATEST_URL, {
@@ -195,7 +276,7 @@ async function updateInstall() {
195
276
  export const inject = ['webServer'];
196
277
 
197
278
  // 仅供诊断 / 自动化测试(不属于 RPC 面,运行时行为不受影响)
198
- export const _internals = { updateCheck, updateInstall, npmLatestVersion, cmpVer, findPnpm, getProfileDir: () => PROFILE_DIR, getVersion: () => VERSION };
279
+ export const _internals = { updateCheck, updateInstall, npmLatestVersion, cmpVer, findPnpm, getProfileDir: () => PROFILE_DIR, getVersion: () => VERSION, dataRoot, safeJoin, mimeOf, getIcConfigPath: () => CONFIG_PATH };
199
280
 
200
281
  function resolveModel(providers, want) {
201
282
  const byProvider = want && want.provider
@@ -278,6 +359,42 @@ export function apply(ctx) {
278
359
  async llmComplete(args) { return complete(args); },
279
360
  async updateCheck() { return updateCheck(); },
280
361
  async updateInstall() { return updateInstall(); },
362
+ async dataRootGet() {
363
+ const root = dataRoot();
364
+ let exists = false;
365
+ if (root) { try { exists = statSync(root).isDirectory(); } catch { /* 不存在 */ } }
366
+ return { dataRoot: root, exists, configPath: CONFIG_PATH };
367
+ },
368
+ async dataRootSet(args) {
369
+ const raw = String((args && args.root) ?? '').trim();
370
+ if (!raw) throw new Error('需要数据根目录路径');
371
+ const root = path.resolve(raw);
372
+ if (!path.isAbsolute(root)) throw new Error('需要绝对路径(如 D:\\canvas-data)');
373
+ mkdirSync(root, { recursive: true });
374
+ const cfg = readIcConfig();
375
+ cfg.dataRoot = root;
376
+ writeIcConfig(cfg);
377
+ return { ok: true, dataRoot: root };
378
+ },
379
+ async dataRootReveal() {
380
+ const root = dataRoot();
381
+ if (!root) throw new Error('未设置数据根目录');
382
+ mkdirSync(root, { recursive: true });
383
+ const open = process.platform === 'win32' ? 'explorer.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open';
384
+ try { spawn(open, [root], { detached: true, stdio: 'ignore' }).unref(); } catch { /* 打开失败不阻塞 */ }
385
+ return { ok: true, dataRoot: root };
386
+ },
387
+ async pickFolder() {
388
+ const r = await pickFolderDialog();
389
+ return r; // { ok, path } | { ok:false, cancelled } | { ok:false, unsupported }
390
+ },
391
+ async assetDelete(args) {
392
+ const root = dataRoot();
393
+ if (!root) throw new Error('未设置数据根目录');
394
+ const { abs } = safeJoin(root, (args && args.rel) || '');
395
+ try { unlinkSync(abs); } catch { /* 已不存在视为成功 */ }
396
+ return { ok: true };
397
+ },
281
398
  async listSkills() {
282
399
  const skills = ctx.get('skills');
283
400
  if (!skills) return { skills: [], error: 'skills 服务不可用' };
@@ -307,31 +424,108 @@ export function apply(ctx) {
307
424
  },
308
425
  };
309
426
 
310
- return ctx.webServer.register({
311
- kind: 'exact',
312
- path: `/${NAME}/api/rpc`,
313
- async handler(req, res) {
314
- res.setHeader('content-type', 'application/json; charset=utf-8');
315
- try {
316
- if (req.method !== 'POST') {
317
- res.statusCode = 405;
318
- res.end(JSON.stringify({ error: '仅接受 POST' }));
319
- return;
427
+ const registrations = [
428
+ ctx.webServer.register({
429
+ kind: 'exact',
430
+ path: `/${NAME}/api/rpc`,
431
+ async handler(req, res) {
432
+ res.setHeader('content-type', 'application/json; charset=utf-8');
433
+ try {
434
+ if (req.method !== 'POST') {
435
+ res.statusCode = 405;
436
+ res.end(JSON.stringify({ error: '仅接受 POST' }));
437
+ return;
438
+ }
439
+ let raw = '';
440
+ for await (const chunk of req) {
441
+ raw += chunk;
442
+ if (raw.length > 2e6) throw new Error('请求过大');
443
+ }
444
+ const { method, args } = JSON.parse(raw);
445
+ const fn = methods[method];
446
+ if (!fn) throw new Error('未知操作: ' + method);
447
+ res.end(JSON.stringify(await fn(args ?? {})));
448
+ } catch (e) {
449
+ res.statusCode = 400;
450
+ res.end(JSON.stringify({ error: String((e && e.message) || e) }));
320
451
  }
321
- let raw = '';
322
- for await (const chunk of req) {
323
- raw += chunk;
324
- if (raw.length > 2e6) throw new Error('请求过大');
452
+ },
453
+ }),
454
+ ctx.webServer.register({
455
+ kind: 'exact',
456
+ path: `/${NAME}/api/asset`,
457
+ async handler(req, res) {
458
+ res.setHeader('content-type', 'application/json; charset=utf-8');
459
+ try {
460
+ if (req.method !== 'POST' && req.method !== 'PUT') {
461
+ res.statusCode = 405;
462
+ res.end(JSON.stringify({ error: '仅接受 POST/PUT' }));
463
+ return;
464
+ }
465
+ const root = dataRoot();
466
+ if (!root) throw new Error('未设置数据根目录');
467
+ const u = new URL(req.url, 'http://localhost');
468
+ const { abs, clean } = safeJoin(root, u.searchParams.get('rel') || '');
469
+ mkdirSync(path.dirname(abs), { recursive: true });
470
+ let size = 0;
471
+ await new Promise((resolve, reject) => {
472
+ const ws = createWriteStream(abs);
473
+ req.on('data', (c) => { size += c.length; });
474
+ req.on('error', reject);
475
+ ws.on('error', reject);
476
+ ws.on('finish', resolve);
477
+ req.pipe(ws);
478
+ });
479
+ res.end(JSON.stringify({ ok: true, rel: clean, size }));
480
+ } catch (e) {
481
+ res.statusCode = 400;
482
+ res.end(JSON.stringify({ error: String((e && e.message) || e) }));
325
483
  }
326
- const { method, args } = JSON.parse(raw);
327
- const fn = methods[method];
328
- if (!fn) throw new Error('未知操作: ' + method);
329
- res.end(JSON.stringify(await fn(args ?? {})));
330
- } catch (e) {
331
- res.statusCode = 400;
332
- res.end(JSON.stringify({ error: String((e && e.message) || e) }));
333
- }
334
- },
335
- });
336
- }, `${NAME}: api route`);
484
+ },
485
+ }),
486
+ ctx.webServer.register({
487
+ kind: 'exact',
488
+ path: `/${NAME}/api/file`,
489
+ async handler(req, res) {
490
+ try {
491
+ if (req.method !== 'GET') { res.statusCode = 405; res.end('仅接受 GET'); return; }
492
+ const root = dataRoot();
493
+ if (!root) { res.statusCode = 404; res.end('未设置数据根目录'); return; }
494
+ const u = new URL(req.url, 'http://localhost');
495
+ const { abs } = safeJoin(root, u.searchParams.get('rel') || '');
496
+ let st;
497
+ try { st = statSync(abs); } catch { res.statusCode = 404; res.end('not found'); return; }
498
+ if (!st.isFile()) { res.statusCode = 404; res.end('not found'); return; }
499
+ res.setHeader('content-type', mimeOf(abs));
500
+ res.setHeader('accept-ranges', 'bytes');
501
+ res.setHeader('cache-control', 'no-cache');
502
+ const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || ''));
503
+ if (m && (m[1] !== '' || m[2] !== '')) {
504
+ let start;
505
+ let end;
506
+ if (m[1] === '') { start = Math.max(0, st.size - Number(m[2])); end = st.size - 1; }
507
+ else { start = parseInt(m[1], 10); end = m[2] === '' ? st.size - 1 : Math.min(parseInt(m[2], 10), st.size - 1); }
508
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= st.size) {
509
+ res.statusCode = 416;
510
+ res.setHeader('content-range', 'bytes */' + st.size);
511
+ res.end();
512
+ return;
513
+ }
514
+ res.statusCode = 206;
515
+ res.setHeader('content-range', `bytes ${start}-${end}/${st.size}`);
516
+ res.setHeader('content-length', String(end - start + 1));
517
+ createReadStream(abs, { start, end }).pipe(res);
518
+ } else {
519
+ res.setHeader('content-length', String(st.size));
520
+ createReadStream(abs).pipe(res);
521
+ }
522
+ } catch (e) {
523
+ res.statusCode = 400;
524
+ res.end(String((e && e.message) || e));
525
+ }
526
+ },
527
+ }),
528
+ ];
529
+ return () => { for (const off of registrations) { try { if (off) off(); } catch { /* 忽略 */ } } };
530
+ }, `${NAME}: api routes`);
337
531
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninkch/dsh-infinite-canvas",
3
- "version": "0.17.3",
3
+ "version": "0.17.4",
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",