@ninkch/dsh-infinite-canvas 0.19.0 → 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 (3) hide show
  1. package/client.js +183 -14
  2. package/index.js +68 -4
  3. package/package.json +1 -1
package/client.js CHANGED
@@ -654,6 +654,8 @@ window.__ModuleLoader__.load({
654
654
  'dirActionNone': '当前没有选中自建动作',
655
655
  'dirLens': '焦段(mm)',
656
656
  'dirSensor': '传感器',
657
+ 'mediaMissing': '素材缺失',
658
+ 'retry': '重试',
657
659
  'dirKeys': '快捷键',
658
660
  'dirRenderLive': '实时渲染中 · 所见即成片',
659
661
  'dirSun': '太阳光(物理日光)',
@@ -1548,6 +1550,8 @@ window.__ModuleLoader__.load({
1548
1550
  'dirExportCancelled': 'Export cancelled',
1549
1551
  'dirLookAhead': 'Look ahead',
1550
1552
  'dirSensor': 'Sensor',
1553
+ 'mediaMissing': 'Asset file missing',
1554
+ 'retry': 'Retry',
1551
1555
  'dirKeys': 'Shortcuts',
1552
1556
  'dirRenderLive': 'Live render · what you see is the final',
1553
1557
  'dirSun': 'Sun (physical daylight)',
@@ -2562,7 +2566,9 @@ body[data-ds-dark-theme] .dshic-inline-input{border-color:var(--dsw-alias-border
2562
2566
  .dshic-canvas{flex:1;position:relative;overflow:hidden;background-color:var(--dsw-alias-bg-base);
2563
2567
  background-image:radial-gradient(color-mix(in srgb,var(--dsw-alias-border-l2) 55%,transparent) 1px,transparent 1px);
2564
2568
  outline:none;}
2565
- .dshic-world{position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform;}
2569
+ .dshic-world{position:absolute;left:0;top:0;transform-origin:0 0;}
2570
+ /* 仅在交互中提升图层:常驻 will-change 会把整张画布变成一张巨大纹理,页面缩放/重排时先白一片 */
2571
+ .dshic-world.is-interacting{will-change:transform;}
2566
2572
  .dshic-edges{position:absolute;left:0;top:0;width:1px;height:1px;overflow:visible;pointer-events:none;}
2567
2573
  .dshic-edge{fill:none;stroke-width:2;pointer-events:none;}
2568
2574
  .dshic-edge.kind-text{stroke:var(--dsw-alias-label-secondary);}
@@ -2990,7 +2996,7 @@ body[data-ds-dark-theme] .dshic-storage-details{border-color:var(--dsw-alias-bor
2990
2996
  .dshic-canvas.is-node-dragging .dshic-media-wrap.is-running .dshic-media-fill{animation:none !important;}
2991
2997
  /* 视频 poster 生成失败:中性占位(不闪、不破图) */
2992
2998
  .dshic-media-placeholder{position:absolute;inset:0;background:var(--dshic-preview-bg);display:block;}
2993
- @keyframes dshic-sheen{0%{filter:brightness(.92)}50%{filter:brightness(1.06)}100%{filter:brightness(.92)}}
2999
+ @keyframes dshic-sheen{0%{opacity:.82}50%{opacity:1}100%{opacity:.82}}
2994
3000
  /* 操作栏(第三段,原型 .foot):固定高度 NODE_FOOT_H = 7 + 24 + 7 + 1px border-top;
2995
3001
  border-top + 上下内距保证按钮与画面之间有明显间距(不贴边),且 flex:none 不参与画面比例 */
2996
3002
  .dshic-media-foot{flex:none;height:var(--dshic-foot-h);box-sizing:border-box;display:flex;align-items:center;gap:6px;
@@ -4856,6 +4862,7 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
4856
4862
  rec.rel = rel;
4857
4863
  rec.size = blob.size;
4858
4864
  rec.stored = 'disk';
4865
+ if (kind === 'image') { try { queueThumb(rec.id); } catch { /* 忽略 */ } }
4859
4866
  const proj = S.projects.find((p) => p.id === projectId);
4860
4867
  if (S.store === 'sqlite') {
4861
4868
  // 主路径:文件落项目文件夹 + 元数据进 canvas.db(任意浏览器/远程立即可见)
@@ -5016,7 +5023,12 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
5016
5023
  cv.width = w; cv.height = h;
5017
5024
  const ctx = cv.getContext && cv.getContext('2d');
5018
5025
  if (!ctx) return finish(null);
5019
- ctx.drawImage(v, 0, 0, w, h);
5026
+ const k2 = Math.min(1, 512 / Math.max(w, h));
5027
+ const w2 = Math.max(1, Math.round(w * k2));
5028
+ const h2 = Math.max(1, Math.round(h * k2));
5029
+ cv.width = w2; cv.height = h2;
5030
+ ctx.fillStyle = '#14181f'; ctx.fillRect(0, 0, w2, h2);
5031
+ ctx.drawImage(v, 0, 0, w2, h2);
5020
5032
  finish(cv.toDataURL('image/jpeg', 0.72));
5021
5033
  } catch { finish(null); }
5022
5034
  };
@@ -5093,6 +5105,108 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
5093
5105
  return { poster, state };
5094
5106
  }
5095
5107
 
5108
+ // ============================================================
5109
+ // 节点缩略图(系统派生文件,**不是资产**)
5110
+ // <rel>.thumb.jpg:最长边 512、JPEG 0.72;不进 assets.json / 资产库 / 导入扫描。
5111
+ // 节点只挂它(几十 KB);点开看原图 / 点播放才按需加载原文件(看哪个取哪个)。
5112
+ // ============================================================
5113
+ const THUMB_MAX = 512, THUMB_QUALITY = 0.72;
5114
+ const thumbCache = new Map();
5115
+ const thumbInflight = new Map();
5116
+ const thumbFailed = new Set(); // 负缓存:每个资产每会话只尝试一次
5117
+ const thumbUrlForRel = (rel) => (rel ? fileUrlFor(rel + '.thumb.jpg') : null);
5118
+ function thumbIsFailed(assetId) { return !!assetId && thumbFailed.has(assetId); }
5119
+ async function probeHeadUrl(url) {
5120
+ if (!url) return false;
5121
+ try { const r = await fetch(url, { method: 'HEAD', cache: 'no-store' }); return !!(r && r.ok); } catch { return false; }
5122
+ }
5123
+ async function makeThumbBlob(blob) {
5124
+ if (!blob) return null;
5125
+ const draw = (src, w0, h0) => {
5126
+ const k = Math.min(1, THUMB_MAX / Math.max(1, Math.max(w0, h0)));
5127
+ const w = Math.max(1, Math.round(w0 * k));
5128
+ const h = Math.max(1, Math.round(h0 * k));
5129
+ const cv = document.createElement('canvas');
5130
+ cv.width = w; cv.height = h;
5131
+ const g = cv.getContext && cv.getContext('2d');
5132
+ if (!g) return Promise.resolve(null);
5133
+ g.fillStyle = '#14181f'; g.fillRect(0, 0, w, h);
5134
+ g.drawImage(src, 0, 0, w, h);
5135
+ return new Promise((res) => { try { cv.toBlob((b) => res(b || null), 'image/jpeg', THUMB_QUALITY); } catch { res(null); } });
5136
+ };
5137
+ // createImageBitmap 在部分环境(软件渲染 / 异常 GPU)会**挂起而不抛错**:
5138
+ // 必须加超时竞速,否则 thumbInflight 永不清除、节点永远停在占位("一直加载")。
5139
+ try {
5140
+ if (typeof createImageBitmap === 'function') {
5141
+ const bmp = await Promise.race([
5142
+ createImageBitmap(blob),
5143
+ new Promise((_, rej) => setTimeout(() => rej(new Error('bitmap timeout')), 1500)),
5144
+ ]);
5145
+ try { return await draw(bmp, bmp.width || 1, bmp.height || 1); }
5146
+ finally { try { if (bmp.close) bmp.close(); } catch { /* 忽略 */ } }
5147
+ }
5148
+ } catch { /* 超时/不支持 → 走 <img> 回退 */ }
5149
+ const url = URL.createObjectURL(blob);
5150
+ try {
5151
+ const img = await new Promise((res, rej) => {
5152
+ const im = new Image();
5153
+ im.onload = () => res(im); im.onerror = () => rej(new Error('decode failed')); im.src = url;
5154
+ });
5155
+ return await draw(img, img.naturalWidth || 1, img.naturalHeight || 1);
5156
+ } catch { return null; } finally { try { URL.revokeObjectURL(url); } catch { /* 忽略 */ } }
5157
+ }
5158
+ async function ensureThumbForAsset(assetId) {
5159
+ if (!assetId) return null;
5160
+ if (thumbCache.has(assetId)) return thumbCache.get(assetId);
5161
+ if (thumbInflight.has(assetId)) return thumbInflight.get(assetId);
5162
+ const task = (async () => {
5163
+ const rec = await idbGet(assetId).catch(() => null);
5164
+ if (!rec || !rec.rel || rec.kind === 'video') return null; // 视频走首帧 poster
5165
+ const url = thumbUrlForRel(rec.rel);
5166
+ if (await probeHeadUrl(url)) { thumbCache.set(assetId, url); return url; }
5167
+ if (thumbFailed.has(assetId)) return null;
5168
+ const blob = await assetBlob(assetId).catch(() => null);
5169
+ const thumb = await makeThumbBlob(blob);
5170
+ if (!thumb) { thumbFailed.add(assetId); return null; }
5171
+ try {
5172
+ const r = await fetch('/' + NS + '/api/thumb?rel=' + encodeURIComponent(rec.rel), {
5173
+ method: 'POST', headers: { 'content-type': 'image/jpeg' }, body: thumb,
5174
+ });
5175
+ if (r && r.ok) { thumbCache.set(assetId, url); return url; }
5176
+ } catch { /* 落盘失败 → 内存兜底 */ }
5177
+ const objUrl = URL.createObjectURL(thumb);
5178
+ thumbCache.set(assetId, objUrl);
5179
+ return objUrl;
5180
+ })();
5181
+ thumbInflight.set(assetId, task);
5182
+ task.then(() => thumbInflight.delete(assetId), () => thumbInflight.delete(assetId));
5183
+ return task;
5184
+ }
5185
+ function useNodeThumb(assetId) {
5186
+ const [url, setUrl] = useState(null);
5187
+ useEffect(() => {
5188
+ if (!assetId) { setUrl(null); return undefined; }
5189
+ let ok = true;
5190
+ ensureThumbForAsset(assetId).then((u) => { if (ok) setUrl(u); }).catch(() => { if (ok) setUrl(null); });
5191
+ return () => { ok = false; };
5192
+ }, [assetId]);
5193
+ return url;
5194
+ }
5195
+ let thumbQueueActive = 0;
5196
+ const thumbQueue = [];
5197
+ function queueThumb(assetId) {
5198
+ if (!assetId) return;
5199
+ thumbQueue.push(assetId);
5200
+ const pump = () => {
5201
+ while (thumbQueueActive < 2 && thumbQueue.length) {
5202
+ const id = thumbQueue.shift();
5203
+ thumbQueueActive++;
5204
+ ensureThumbForAsset(id).catch(() => null).finally(() => { thumbQueueActive--; pump(); });
5205
+ }
5206
+ };
5207
+ pump();
5208
+ }
5209
+
5096
5210
  // ============================================================
5097
5211
  // 生成能力:演示模式(本地合成)+ 自定义 API
5098
5212
  // ============================================================
@@ -8404,6 +8518,7 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
8404
8518
  rec.rel = rel;
8405
8519
  rec.size = blob.size;
8406
8520
  rec.stored = 'disk';
8521
+ if (kind === 'image') { try { queueThumb(rec.id); } catch { /* 忽略 */ } }
8407
8522
  const proj = S.projects.find((p) => p.id === projectId);
8408
8523
  if (S.store === 'sqlite') {
8409
8524
  const saved = await hostRpc('assetCreate', { asset: assetIndexRecord(rec), projectName: (proj && proj.name) || '' });
@@ -9837,6 +9952,7 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
9837
9952
  }
9838
9953
 
9839
9954
  function onCanvasPointerDown(e) {
9955
+ try { if (worldEl) worldEl.classList.add('is-interacting'); } catch { /* 忽略 */ }
9840
9956
  const target = e.target;
9841
9957
  // 生成面板是浮层(不在 world 内):面板内的点击不能触发画布平移/清空选择
9842
9958
  if (target.closest && target.closest('.dshic-composer')) return;
@@ -10061,6 +10177,7 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
10061
10177
  }
10062
10178
  }
10063
10179
  function onCanvasPointerUp(e) {
10180
+ try { if (worldEl) worldEl.classList.remove('is-interacting'); } catch { /* 忽略 */ }
10064
10181
  if (!drag.mode) return;
10065
10182
  const mode = drag.mode;
10066
10183
  // 收尾:先补上最后一帧拖拽(rAF 尚未执行的 pointermove),清掉视觉 class 与 Alt 标记
@@ -11641,7 +11758,9 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
11641
11758
  })();
11642
11759
  return () => { ok = false; };
11643
11760
  }, [n.id, S.assetsTick, S.store]);
11644
- const assetUrlV = useAssetUrl(n.data.assetId);
11761
+ // 节点只挂派生缩略图(几十 KB);原图/原视频仅在「点开看 / 点播放」时按需取,且一次只取一个
11762
+ const thumbUrlV = useNodeThumb(kind === 'image' ? n.data.assetId : null);
11763
+ const [playUrl, setPlayUrl] = useState(null); // 视频原文件 URL:只在播放期间存在
11645
11764
  // 视频默认只画静态 poster;<video> 只在播放时挂载(见下方播放状态机)
11646
11765
  const videoPoster = useVideoPoster(kind === 'video' ? n.data.assetId : null);
11647
11766
  const posterUrl = videoPoster.poster;
@@ -11667,7 +11786,7 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
11667
11786
  const refEdges = S.graph.edges.filter((x) => x.toNode === n.id && x.toPort === 'ref');
11668
11787
  const firstRefSrc = refEdges.length ? S.graph.nodes[refEdges[0].fromNode] : null;
11669
11788
  const firstRefAid = firstRefSrc ? servedAssetId(firstRefSrc, refEdges[0].fromPort) : null;
11670
- const refThumbUrl = useAssetUrl(firstRefAid);
11789
+ const refThumbUrl = useNodeThumb(firstRefAid);
11671
11790
  const running = n.data.status === 'running';
11672
11791
  const done = !!n.data.assetId;
11673
11792
  const statusLabel = running ? T('genRunning') : done ? T('generated') : (n.data.status === 'error' ? T('genFailed') : '');
@@ -11691,7 +11810,8 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
11691
11810
  normalizeMediaNodeSize(nd);
11692
11811
  });
11693
11812
  };
11694
- const imgShown = done && !!assetUrlV && kind === 'image' && mediaState !== 'error';
11813
+ const imgShown = done && !!thumbUrlV && kind === 'image' && mediaState !== 'error';
11814
+ const imgMissing = done && kind === 'image' && !thumbUrlV && thumbIsFailed(n.data.assetId);
11695
11815
  const videoPlaying = done && kind === 'video' && playing;
11696
11816
  // 视频:有 poster 即视为就绪(mediaState ready 由 poster 的 onLoad 设置,不再依赖 <video> 元数据)
11697
11817
  const posterLoading = done && kind === 'video' && !posterUrl && (posterState === 'idle' || posterState === 'loading');
@@ -11721,7 +11841,14 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
11721
11841
  const v = videoRef.current;
11722
11842
  if (!v) return;
11723
11843
  try { const p = v.play(); if (p && typeof p.catch === 'function') p.catch(() => { /* 保留播放键 */ }); } catch { /* 忽略 */ }
11724
- }, [playing, n.data.assetId]);
11844
+ }, [playing, n.data.assetId, playUrl]);
11845
+ // 播放时才去取原视频 URL(懒加载:看哪个取哪个;暂停/结束立即释放)
11846
+ useEffect(() => {
11847
+ if (!videoPlaying) { setPlayUrl(null); return undefined; }
11848
+ let ok = true;
11849
+ assetUrl(n.data.assetId).then((u) => { if (ok) setPlayUrl(u); }).catch(() => { if (ok) setPlayUrl(null); });
11850
+ return () => { ok = false; };
11851
+ }, [videoPlaying, n.data.assetId]);
11725
11852
  /** 进度条拖动 seek(控件条只在播放中出现,拖动期间不打断播放)。 */
11726
11853
  const seekVideo = (t) => {
11727
11854
  const v = videoRef.current;
@@ -11781,19 +11908,39 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
11781
11908
  posterFailed
11782
11909
  ? h('div', { key: 'poster-fail', className: 'dshic-media-placeholder', 'aria-hidden': 'true' })
11783
11910
  : null,
11911
+ // 原文件缺失(404):稳定占位 + 重试,不再每次渲染都请求
11912
+ imgMissing
11913
+ ? h('div', { key: 'missing', className: 'dshic-media-placeholder is-missing' },
11914
+ h('span', null, T('mediaMissing')),
11915
+ h('button', {
11916
+ type: 'button', className: 'dshic-mini is-ghost',
11917
+ onPointerDown: (e) => e.stopPropagation(),
11918
+ onClick: (e) => {
11919
+ e.stopPropagation();
11920
+ thumbFailed.delete(n.data.assetId);
11921
+ setMediaState('idle');
11922
+ update((s2) => { s2.assetsTick++; });
11923
+ },
11924
+ }, T('retry')))
11925
+ : null,
11784
11926
  imgShown
11785
11927
  ? h('img', {
11786
- key: 'img', className: 'dshic-media-fill', src: assetUrlV, draggable: false,
11928
+ key: 'img', className: 'dshic-media-fill', src: thumbUrlV, draggable: false, decoding: 'async',
11787
11929
  onLoad: (e) => { setMediaState('ready'); const el = e && e.currentTarget; recordMediaDims(el && el.naturalWidth, el && el.naturalHeight); },
11788
11930
  onError: () => setMediaState('error'),
11789
- onDoubleClick: (e) => { e.stopPropagation(); openLightbox('image', assetUrlV, n.data.label); },
11931
+ onDoubleClick: async (e) => {
11932
+ e.stopPropagation();
11933
+ // 双击放大:**此时才**取原图(单张、按需;关闭灯箱即卸载)
11934
+ const u = await assetUrl(n.data.assetId).catch(() => null);
11935
+ if (u) openLightbox('image', u, n.data.label);
11936
+ },
11790
11937
  })
11791
11938
  : null,
11792
11939
  // 播放时才挂真实 <video>(poster 作为 poster 属性兜底,挂载瞬间也不闪)
11793
11940
  videoPlaying
11794
11941
  ? h('video', {
11795
- key: 'vid', ref: videoRef, className: 'dshic-media-fill', src: assetUrlV || undefined,
11796
- poster: posterUrl || undefined, preload: 'metadata', autoPlay: true,
11942
+ key: 'vid', ref: videoRef, className: 'dshic-media-fill', src: playUrl || undefined,
11943
+ poster: posterUrl || undefined, preload: 'auto', autoPlay: true,
11797
11944
  // 视频控件保留 stopPropagation(拖动/选择不被视频吃掉),单击画面打开生成面板;
11798
11945
  // 双击画面 = 播放/暂停(原型行为);播放键自身另行 stopPropagation。
11799
11946
  onPointerDown: (e) => { e.stopPropagation(); },
@@ -11866,9 +12013,12 @@ body[data-ds-dark-theme] .dshic-pick-item.is-on{background:color-mix(in srgb,var
11866
12013
  done
11867
12014
  ? h('div', { key: 'acts', className: 'dshic-media-foot', 'data-node-foot': '1' },
11868
12015
  h('button', {
11869
- type: 'button', className: 'dshic-mini is-ghost', disabled: !assetUrlV,
12016
+ type: 'button', className: 'dshic-mini is-ghost', disabled: !done,
11870
12017
  onPointerDown: (e) => e.stopPropagation(),
11871
- onClick: async () => downloadBlobFromUrl(assetUrlV, await downloadNameForAsset(n.data.assetId, (kind === 'image' ? 'image' : 'video') + '-' + n.id.slice(-5), kind)),
12018
+ onClick: async () => {
12019
+ const u = await assetUrl(n.data.assetId).catch(() => null);
12020
+ if (u) downloadBlobFromUrl(u, await downloadNameForAsset(n.data.assetId, (kind === 'image' ? 'image' : 'video') + '-' + n.id.slice(-5), kind));
12021
+ },
11872
12022
  }, Ic('download', 11), T('download')),
11873
12023
  h('button', {
11874
12024
  type: 'button', className: 'dshic-mini is-ghost', disabled: running,
@@ -13393,6 +13543,9 @@ const DIRECTOR_KEY_HELP = [
13393
13543
  // 任何「不影响 scene 但影响画面」的变化(切模式/选中/悬停/显示开关)都必须 bumpView(),
13394
13544
  // 渲染 effect 只认这个 revision → 不再出现「切了模式要等点击画面才刷新」。
13395
13545
  const canvasRef = useRef(null);
13546
+ const dirWheelHandlerRef = useRef(null); // 最新的滚轮处理器(原生监听转发用)
13547
+ const dirViewportElRef = useRef(null);
13548
+ const dirWheelListenerRef = useRef(null);
13396
13549
  const pipRef = useRef(null);
13397
13550
  const pipGlRef = useRef(null);
13398
13551
  const glRef = useRef(null);
@@ -14910,7 +15063,23 @@ const DIRECTOR_KEY_HELP = [
14910
15063
  h('main', { className: 'dshic-dir-stage' },
14911
15064
  h('div', {
14912
15065
  className: cls('dshic-dir-viewport', S.directorPendingPlace && 'is-placing', S.directorDragging && 'is-dragging'), 'data-role': 'dir-viewport',
14913
- onPointerDown: onDown, onPointerMove: onMove, onPointerUp: onUp, onPointerLeave: onUp, onWheel: onWheel,
15066
+ onPointerDown: onDown, onPointerMove: onMove, onPointerUp: onUp, onPointerLeave: onUp,
15067
+ // 原生 non-passive 滚轮:React 的 onWheel 无法 preventDefault(页面会一起缩放)
15068
+ ref: (el) => {
15069
+ dirWheelHandlerRef.current = onWheel;
15070
+ const prev = dirViewportElRef.current;
15071
+ if (prev && prev !== el && dirWheelListenerRef.current) {
15072
+ try { prev.removeEventListener('wheel', dirWheelListenerRef.current); } catch { /* 忽略 */ }
15073
+ }
15074
+ dirViewportElRef.current = el;
15075
+ if (!dirWheelListenerRef.current) {
15076
+ dirWheelListenerRef.current = (ev) => { const fn = dirWheelHandlerRef.current; if (fn) fn(ev); };
15077
+ }
15078
+ if (el) {
15079
+ try { el.removeEventListener('wheel', dirWheelListenerRef.current); } catch { /* 忽略 */ }
15080
+ try { el.addEventListener('wheel', dirWheelListenerRef.current, { passive: false }); } catch { /* 忽略 */ }
15081
+ }
15082
+ },
14914
15083
  onContextMenu: (e2) => { if (e2 && e2.preventDefault) e2.preventDefault(); },
14915
15084
  },
14916
15085
  h('canvas', { ref: canvasRef, className: 'dshic-dir-canvas' }),
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 {
@@ -975,6 +980,21 @@ function mimeOf(p) {
975
980
  return m ? (MIME_BY_EXT[m[1].toLowerCase()] || 'application/octet-stream') : 'application/octet-stream';
976
981
  }
977
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
+
978
998
  /**
979
999
  * 弹出系统"选择目录"对话框(阻塞至用户选择/取消)。
980
1000
  * Windows = PowerShell + WinForms FolderBrowserDialog(TopMost,可新建文件夹);
@@ -2775,7 +2795,7 @@ async function updateInstall() {
2775
2795
  export const inject = ['webServer', 'tools'];
2776
2796
 
2777
2797
  // 仅供诊断 / 自动化测试(不属于 RPC 面,运行时行为不受影响)
2778
- 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 };
2779
2799
 
2780
2800
  function resolveModel(providers, want) {
2781
2801
  const byProvider = want && want.provider
@@ -3926,7 +3946,8 @@ export function apply(ctx) {
3926
3946
  if (!st.isFile()) { res.statusCode = 404; res.end('not found'); return; }
3927
3947
  res.setHeader('content-type', mimeOf(abs));
3928
3948
  res.setHeader('accept-ranges', 'bytes');
3929
- res.setHeader('cache-control', 'no-cache');
3949
+ // 派生预览文件内容不变 → 长缓存;原文件保持 no-cache
3950
+ res.setHeader('cache-control', isDerivedMedia(rel) ? 'public, max-age=31536000, immutable' : 'no-cache');
3930
3951
  // HEAD:只回应元信息(node fetch HEAD 探针用),不读文件、不占解码/内存
3931
3952
  if (req.method === 'HEAD') {
3932
3953
  res.statusCode = 200;
@@ -3999,7 +4020,50 @@ export function apply(ctx) {
3999
4020
  }
4000
4021
  },
4001
4022
  }),
4002
- // CAS 对象库读路径(0.18):0.18.0–0.18.4 上传的媒体仍在 objects/ 里,必须继续可读。
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/ 里,必须继续可读。
4003
4067
  // IC-08 起写路径已退役(POST/PUT 一律 405),只有 GET。
4004
4068
  ctx.webServer.register({
4005
4069
  kind: 'exact',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninkch/dsh-infinite-canvas",
3
- "version": "0.19.0",
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",