dsh-remotion 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,8 +1,16 @@
1
- #- 同步官方 Remotion 4.0.539 的 12 个技能及引用资源,补充 DSH 包边界,避免独立技能更新命令改到其他安装。
2
- 更新记录
1
+ # 更新记录
3
2
 
4
3
  [返回简介](README.md) · [使用说明](docs/USAGE.md) · [验证记录](docs/VALIDATION.md)
5
4
 
5
+ ## 0.9.0 (2026-10-28)
6
+
7
+ - 素材页支持上移/下移图片,显示播放序号,立即保存并校验工程修订;不改音频/视频位置和旧成品。
8
+ - Add keyboard-accessible image ordering without re-uploading. Existing renders remain available; export again to apply the new order.
9
+
10
+ ## 0.8.3 (2026-10-27)
11
+
12
+ - 修复分批更新时的新旧更新控件混用:保留原页面、草稿与安装保护,空闲后接管旧控制器,兼容旧入口。
13
+
6
14
  ## 0.8.2 (2026-10-27)
7
15
 
8
16
  - 更新控件移至原有工作台或原生插件详情页底部;更新记录与选项原地展开,移除独立更新菜单与页面。
package/README.en.md CHANGED
@@ -14,6 +14,8 @@ Open the new native sidebar workbench after updating and restarting DSH. Setting
14
14
 
15
15
  Bundled skills: official Remotion 4.0.539, twelve skills and their reference resources.
16
16
 
17
+ Move images up or down in the Media tab, including with the keyboard. The order saves immediately; export again to apply it to a new MP4.
18
+
17
19
  ## What it does
18
20
 
19
21
  - A settings workbench for templates, editable content, media uploads, official Studio preview and local MP4 export.
package/README.md CHANGED
@@ -14,6 +14,8 @@
14
14
 
15
15
  技能内容:官方 Remotion 4.0.539,12 个技能及完整引用资源。
16
16
 
17
+ 在「素材」页上移或下移图片即可调整轮播顺序,支持键盘操作。顺序立即保存;重新导出后应用于新 MP4。
18
+
17
19
  ## 功能
18
20
 
19
21
  - 设置页视频工作台:选模板、编辑内容、上传素材,在官方 Studio 预览并导出本机 MP4。
@@ -0,0 +1,12 @@
1
+ # 0.9.0 图片排序验收
2
+
3
+ 2026-10-28,Windows / Node 24.16 / 官方 DSH 0.2.1-rc.3 Web,后台 Chrome / Playwright。Browser plugin 不可用,因此采用本地 Playwright,不切换用户前台窗口。
4
+
5
+ - 实际创建轮播工程,上传三张有效 PNG,用鼠标和键盘调整顺序,关闭详情再打开验证持久化。
6
+ - 真正准备官方渲染环境并导出 6 秒 MP4,下载后在 1/3/5 秒抽帧,逐张查看确认绿色、蓝色、红色顺序;再次移动图片后旧 MP4 的 SHA-256 保持一致,界面提示重新导出。
7
+ - 中文浅色、英文深色及 390px 窄屏实际操作;窄屏按钮也实际点击,图片重新排列成功;未捕获页面异常。
8
+ - 后端覆盖图片完整排列、重复/缺失/外来 ID、旧修订、空操作、混合音频位置、文件字节和旧渲染记录保留。
9
+
10
+ Remotion 保持官方 4.0.539 的当前技能与固定渲染器。
11
+
12
+ 未测:Electron 专属菜单、全部模板/格式组合、所有外部媒体与真实业务项目。受控测试素材不代表任意用户项目。自动安装继续默认关闭。
package/lib/client.js CHANGED
@@ -176,7 +176,7 @@ function __stardustUpdatesGuard(React, name, { dirty = false, busy = false, reta
176
176
  function __StardustPluginUpdatesFooter({ React, name }) {
177
177
  const [, render] = React.useState(0), pool = __stardustUpdatesGuards();
178
178
  React.useEffect(() => { const fn = () => render(value => value + 1); pool.viewListeners.add(fn); return () => pool.viewListeners.delete(fn); }, []);
179
- const client = [...pool.clients].find(client => client.has(name));
179
+ const client = [...pool.clients].find(client => typeof client.Footer === 'function' && client.has(name));
180
180
  if (!client) return null;
181
181
  return React.createElement(client.Footer, { name });
182
182
  }
@@ -238,14 +238,22 @@ function __stardustUpdatesJoin(host, React, require, participant) {
238
238
  if (typeof root.plugin !== 'function') return;
239
239
  let record = pools.get(root);
240
240
  if (!record) {
241
- record = { refs: new Map(), timer: null, version: participant.version, alive: true, hold: false };
241
+ record = { refs: new Map(), timer: null, version: participant.version, alive: true, hold: false, uiRevision: 2 };
242
242
  pools.set(root, record);
243
+ __stardustUpdatesActivate(record, pools, root, React, require);
244
+ } else if (record.uiRevision !== 2 && !record.migration) {
245
+ // The first loaded participant may still own a pre-footer client. Keep
246
+ // its references and draft guards, wait for any admitted install, then
247
+ // replace that one client fiber instead of running two window heartbeats.
243
248
  const shared = record;
244
- record.fiber = root.plugin({ name: 'stardustlc-plugin-updates-client', inject: ['slots', 'locale', 'remote', 'remote.pluginManager'], apply(ctx) {
245
- const client = __stardustUpdatesClient(ctx, React, require, shared);
246
- ctx.effect(() => () => { shared.alive = false; client.dispose(); if (pools.get(root) === shared) pools.delete(root); });
247
- } });
248
- Promise.resolve(record.fiber).catch(() => { shared.alive = false; if (pools.get(root) === shared) pools.delete(root); });
249
+ record.migration = Promise.resolve().then(async () => {
250
+ while (shared.alive && shared.hold) await new Promise(resolve => setTimeout(resolve, 500));
251
+ if (!shared.alive || !shared.refs.size) return;
252
+ await shared.fiber?.dispose?.();
253
+ if (!shared.refs.size) return;
254
+ shared.alive = true; shared.uiRevision = 2; pools.set(root, shared);
255
+ __stardustUpdatesActivate(shared, pools, root, React, require);
256
+ }).catch(() => { /* The original page remains usable if host disposal fails. */ });
249
257
  }
250
258
  clearTimeout(record.timer);
251
259
  const ref = {}; record.refs.set(ref, participant);
@@ -259,10 +267,17 @@ function __stardustUpdatesJoin(host, React, require, participant) {
259
267
  if (!record.refs.size) record.timer = setTimeout(stop, 1000);
260
268
  });
261
269
  }
270
+ function __stardustUpdatesActivate(shared, pools, root, React, require) {
271
+ shared.fiber = root.plugin({ name: 'stardustlc-plugin-updates-client', inject: ['slots', 'locale', 'remote', 'remote.pluginManager'], apply(ctx) {
272
+ const client = __stardustUpdatesClient(ctx, React, require, shared);
273
+ ctx.effect(() => () => { shared.alive = false; client.dispose(); if (pools.get(root) === shared) pools.delete(root); });
274
+ } });
275
+ Promise.resolve(shared.fiber).catch(() => { shared.alive = false; if (pools.get(root) === shared) pools.delete(root); });
276
+ }
262
277
  function __stardustUpdatesClient(ctx, React, require, shared) {
263
278
  const h = React.createElement, W = createWorkbenchUI(React), route = new URL('api/stardustlc/plugin-updates', document.baseURI);
264
279
  const { useState, useEffect } = React, id = crypto.randomUUID(), listeners = new Set(), pool = __stardustUpdatesGuards(), requests = new Set(), notified = new Set(), knownRows = new Map();
265
- let alive = true, registered = false, state = null, lastInteraction = Date.now(), inPoll = false, manualOperation = false;
280
+ let alive = true, registered = false, state = null, lastInteraction = Date.now(), inPoll = false, manualOperation = false, legacyHint = null;
266
281
  const language = () => String(ctx.locale?.getSnapshot?.().active || '').startsWith('en') ? 'en' : 'zh';
267
282
  const t = (zh, en) => language() === 'en' ? en : zh;
268
283
  const preferences = name => state?.preferences.packages[name] || { checkEnabled: true, autoInstall: false, deferredUntil: 0, ignoredVersion: null };
@@ -387,11 +402,11 @@ function __stardustUpdatesClient(ctx, React, require, shared) {
387
402
  function Reminder() {
388
403
  useSnapshot();
389
404
  const row = state?.rows.find(row => { const prefs = preferences(row.name); return row.available && !row.updating && !row.bundledBy && prefs.checkEnabled && prefs.deferredUntil <= Date.now() && prefs.ignoredVersion !== row.release.version && !notified.has(row.name + '@' + row.release.version); });
390
- if (!row) return null;
405
+ if (!row && !legacyHint) return null;
391
406
  let Toast; try { Toast = require('@deepseek-ai/dsh-client-ui-primitives').Toast; } catch { return null; }
392
407
  if (!Toast) return null;
393
- const key = row.name + '@' + row.release.version;
394
- return h(Toast, { key, text: (language() === 'en' ? row.labelEn : row.label) + t('有新版本,可在页面底部查看。', ' has an update. Review it at the bottom of its page.'), icon: h(W.Icon, { name: 'refresh', size: 18 }), holdMs: 7000, onDone: () => { notified.add(key); notify(); } });
408
+ const hint = legacyHint, key = hint ? 'legacy-' + hint : row.name + '@' + row.release.version;
409
+ return h(Toast, { key, text: hint ? hint + t(':请打开「插件」中的此插件详情页,在底部管理更新。', ': open its details in Plugins to manage updates at the bottom.') : (language() === 'en' ? row.labelEn : row.label) + t('有新版本,可在页面底部查看。', ' has an update. Review it at the bottom of its page.'), icon: h(W.Icon, { name: 'refresh', size: 18 }), holdMs: 7000, onDone: () => { if (hint) legacyHint = null; else notified.add(key); notify(); } });
395
410
  }
396
411
  function register() {
397
412
  if (!alive) return;
@@ -410,7 +425,7 @@ function __stardustUpdatesClient(ctx, React, require, shared) {
410
425
  finally { inPoll = false; }
411
426
  }
412
427
  const timer = setInterval(() => { void poll(); }, 15000), first = setTimeout(() => { void poll(); }, 100);
413
- const client = { t, Footer: Panel, has: name => registered && !!originalRow(name), row: rowFor, dispose() {
428
+ const client = { t, Footer: Panel, Button: W.Button, open(name) { const footer = [...document.querySelectorAll('[data-plugin-update-footer]')].find(node => node.getAttribute('data-plugin-update-footer') === name); if (footer) footer.scrollIntoView({ block: 'end', behavior: 'smooth' }); else { legacyHint = name; notify(); } }, has: name => registered && !!originalRow(name), row: rowFor, dispose() {
414
429
  close(); alive = false; clearTimeout(first); clearInterval(timer); pool.clients.delete(client);
415
430
  pool.listeners.delete(guardChanged); listeners.clear();
416
431
  for (const event of ['pointerdown', 'keydown', 'input']) document.removeEventListener(event, interaction, true);
@@ -596,13 +611,24 @@ function MediaCanvas({ project, fields, render, dirty, stale, job, running, read
596
611
  function MediaAssets({ project, dirty, running, fileRef, upload, perform, setProject, t }) {
597
612
  const [remove, setRemove] = useState(null);
598
613
  if (!project) return h(W.Empty, { title: t('先保存工程,再添加素材', 'Save the project before adding media'), icon: 'video' }, t('填写标题并点击上方“保存工程”。', 'Enter a title and choose Save project above.'));
614
+ const imageIds = project.assets.filter(asset => asset.kind === 'image').map(asset => asset.id);
615
+ const move = (asset, offset) => perform(async () => {
616
+ const order = [...imageIds], index = order.indexOf(asset.id), next = index + offset;
617
+ if (index < 0 || next < 0 || next >= order.length) return;
618
+ [order[index], order[next]] = [order[next], order[index]];
619
+ setProject(await api({ action: 'reorderImages', id: project.id, revision: project.revision, imageIds: order }));
620
+ });
599
621
  return h('section', { className: 'media-assets-view' },
600
622
  h('div', { className: 'w-toolbar' }, h('h3', null, t('工程素材', 'Project media') + ' · ' + project.assets.length), h(W.Button, { icon: 'upload', disabled: running || dirty, onClick: () => fileRef.current?.click() }, t('上传素材', 'Upload media'))),
601
- h('p', { className: 'w-muted' }, t('PNG/JPEG 图片、一个背景音频和一个静音背景视频;每个 ≤20 MB,总共 ≤40 MB。轮播按上传顺序,每张至少两秒。', 'PNG/JPEG images, one background audio and one muted background video; each ≤20 MB, total ≤40 MB. Slideshow follows upload order, at least two seconds per image.')),
623
+ h('p', { className: 'w-muted' }, t('PNG/JPEG 图片、一个背景音频和一个静音背景视频;每个 ≤20 MB,总共 ≤40 MB。轮播按下方图片顺序,每张至少两秒。', 'PNG/JPEG images, one background audio and one muted background video; each ≤20 MB, total ≤40 MB. Slideshow follows the image order below, at least two seconds per image.')),
624
+ imageIds.length > 1 ? h('p', { className: 'w-muted' }, t('上移或下移图片后立即保存。已导出的 MP4 保持原样,请重新导出以应用新顺序。', 'Moving an image saves its order immediately. Existing MP4s are preserved; export again to apply the new order.')) : null,
602
625
  dirty ? h(W.Status, null, t('请先保存修改,再上传或移除素材副本。', 'Save changes before uploading or removing media copies.')) : null,
603
626
  h('input', { ref: fileRef, type: 'file', accept: '.png,.jpg,.jpeg,.mp3,.wav,.mp4', disabled: running || dirty, onChange: upload, 'aria-label': t('上传素材副本', 'Upload a media copy'), style: { display: 'none' } }),
604
627
  project.assets.length ? h('ul', { className: 'media-asset-list' }, project.assets.map(asset => h('li', { key: asset.id }, asset.kind === 'image' ? h('img', { src: urlFor(project, 'asset', { asset: asset.id }), alt: asset.name }) : h(W.Icon, { name: asset.kind === 'video' ? 'video' : 'file', size: 28 }),
605
- h('div', null, h('strong', null, asset.name), h('p', { className: 'w-muted' }, asset.kind + ' · ' + Math.ceil(asset.bytes / 1024) + ' KB')),
628
+ h('div', null, h('strong', null, asset.name), h('p', { className: 'w-muted' }, (asset.kind === 'image' ? t('图片 ', 'Image ') + (imageIds.indexOf(asset.id) + 1) + ' / ' + imageIds.length : asset.kind === 'audio' ? t('背景音频', 'Background audio') : t('背景视频', 'Background video')) + ' · ' + Math.ceil(asset.bytes / 1024) + ' KB')),
629
+ asset.kind === 'image' && imageIds.length > 1 ? h('div', { className: 'w-toolbar media-asset-order' },
630
+ h(W.Button, { variant: 'ghost', disabled: running || dirty || imageIds.indexOf(asset.id) === 0, 'aria-label': t('上移图片:', 'Move image up: ') + asset.name, onClick: () => move(asset, -1) }, t('上移', 'Move up')),
631
+ h(W.Button, { variant: 'ghost', disabled: running || dirty || imageIds.indexOf(asset.id) === imageIds.length - 1, 'aria-label': t('下移图片:', 'Move image down: ') + asset.name, onClick: () => move(asset, 1) }, t('下移', 'Move down'))) : null,
606
632
  h(W.Button, { variant: 'ghost', icon: 'trash', disabled: running || dirty, onClick: () => setRemove(asset) }, t('移除上传副本', 'Remove uploaded copy'))))) : h(W.Empty, { title: t('还没有上传素材', 'No media uploaded yet'), icon: 'video' }, t('标题卡可以直接导出。需要图片或配乐时,再上传自己的素材。', 'Title cards can be exported as they are. Upload your own media when you need images or music.')),
607
633
  remove ? h(W.Dialog, { title: t('移除素材副本', 'Remove media copy'), closeLabel: t('取消', 'Cancel'), busy: running, onClose: () => setRemove(null) }, h('p', null, remove.name), h('p', { className: 'w-muted' }, t('仅移除本工程中的上传副本,原文件保留。', 'Only the uploaded project copy is removed. Your original file is preserved.')), h('div', { className: 'w-toolbar' }, h(W.Button, { variant: 'danger', disabled: running || dirty, onClick: () => perform(async () => { setProject(await api({ action: 'removeAsset', id: project.id, revision: project.revision, asset: remove.id })); setRemove(null); }) }, t('确认移除副本', 'Confirm removal')), h(W.Button, { disabled: running, onClick: () => setRemove(null) }, t('取消', 'Cancel')))) : null);
608
634
  }
@@ -633,6 +659,10 @@ const MEDIA_WORKBENCH_CSS = `
633
659
  return message;
634
660
  if (error?.status === 401)
635
661
  return 'Your session expired. Reopen DSH and try again.';
662
+ if (/调整图片顺序需要/.test(message))
663
+ return 'Reload the project before changing its image order.';
664
+ if (/图片顺序/.test(message))
665
+ return 'Use every current image exactly once. Reload the project before trying again.';
636
666
  if (/先归档|已达 30/.test(message))
637
667
  return 'The 30 active project slots are full. Archive another project before creating or restoring one.';
638
668
  if (/已有预览或渲染/.test(message))
@@ -829,7 +859,7 @@ h('div', { className: 'bar' }, h('button', { disabled: busy || dirty || activeJo
829
859
  catch { } W.register(ctx,{id:'dsh-'+engine+'.workbench',label:()=>title,icon:engine==='hyperframes'?'layers':'play',component:Workbench,inject:()=>({locale}),order:engine==='hyperframes'?65:66});
830
860
  ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'dsh-' + engine, order: engine === 'hyperframes' ? 62 : 63, label: () => title, inject: () => ({ locale }) }, Workbench)); }
831
861
  return { inject: ['slots', 'locale'], apply };
832
- } },{"name":"dsh-remotion","version":"0.1.0","hasWorkbench":true,"packageVersion":"0.8.2","label":"Remotion","labelEn":"Remotion","suiteComponent":true}));
862
+ } },{"name":"dsh-remotion","version":"0.1.0","hasWorkbench":true,"packageVersion":"0.9.0","label":"Remotion","labelEn":"Remotion","suiteComponent":true}));
833
863
 
834
864
  })();
835
865
 
@@ -17,6 +17,8 @@ export declare class MediaStore {
17
17
  create(value: unknown): Promise<MediaProject>;
18
18
  edit(id: string, revision: unknown, fn: (p: MediaProject) => Promise<void> | void, bump?: boolean): Promise<MediaProject>;
19
19
  update(id: string, revision: unknown, value: unknown): Promise<MediaProject>;
20
+ /** Reorder existing images atomically; audio/video slots and uploaded bytes stay intact. */
21
+ reorderImages(id: string, revision: unknown, order: unknown): Promise<MediaProject>;
20
22
  assetBytes(id: string, asset: MediaAsset): Promise<Buffer>;
21
23
  upload(id: string, revision: unknown, name: string, bytes: Buffer): Promise<MediaProject>;
22
24
  removeAsset(id: string, revision: unknown, assetId: string): Promise<MediaProject>;
@@ -132,6 +132,21 @@ export class MediaStore {
132
132
  }
133
133
  }
134
134
  async update(id, revision, value) { return this.edit(id, revision, p => { Object.assign(p, validateFields(value)); }); }
135
+ /** Reorder existing images atomically; audio/video slots and uploaded bytes stay intact. */
136
+ async reorderImages(id, revision, order) {
137
+ if (!Number.isInteger(revision) || Number(revision) < 1)
138
+ throw new Error('调整图片顺序需要当前工程修订号。');
139
+ return this.edit(id, revision, p => {
140
+ const images = p.assets.filter(a => a.kind === 'image'), byId = new Map(images.map(a => [a.id, a]));
141
+ if (!Array.isArray(order) || order.length !== images.length || new Set(order).size !== images.length || order.some(id => typeof id !== 'string' || !byId.has(id)))
142
+ throw new Error('图片顺序必须包含当前项目的全部图片,且每张只出现一次。');
143
+ if (images.every((asset, index) => asset.id === order[index]))
144
+ return;
145
+ let index = 0;
146
+ p.assets = p.assets.map(asset => asset.kind === 'image' ? byId.get(order[index++]) : asset);
147
+ p.revision++;
148
+ }, false);
149
+ }
135
150
  async assetBytes(id, asset) {
136
151
  const file = join(await this.path(id), 'assets', asset.file);
137
152
  await this.checkPath(file);
@@ -27,7 +27,7 @@ export interface MediaProject extends MediaFields {
27
27
  }>;
28
28
  }
29
29
  export declare const MEDIA_VERSIONS: {
30
- readonly hyperframes: "0.8.207";
30
+ readonly hyperframes: "0.8.209";
31
31
  readonly remotion: "4.0.539";
32
32
  readonly gsap: "3.15.0";
33
33
  };
@@ -1,4 +1,4 @@
1
- export const MEDIA_VERSIONS = { hyperframes: '0.8.207', remotion: '4.0.539', gsap: '3.15.0' };
1
+ export const MEDIA_VERSIONS = { hyperframes: '0.8.209', remotion: '4.0.539', gsap: '3.15.0' };
2
2
  export const MEDIA_TEMPLATES = [
3
3
  { id: 'title', zh: '标题卡', en: 'Title card', description: '一句标题、一段介绍,适合开场与通知。' },
4
4
  { id: 'product', zh: '产品介绍', en: 'Product card', description: '深色画布与一张主图,适合产品亮点。' },
@@ -401,6 +401,9 @@ export class MediaWorkbench {
401
401
  case 'removeAsset':
402
402
  value = await this.store.removeAsset(mediaId(v.id), v.revision, mediaId(v.asset));
403
403
  break;
404
+ case 'reorderImages':
405
+ value = await this.store.reorderImages(mediaId(v.id), v.revision, v.imageIds);
406
+ break;
404
407
  case 'archive':
405
408
  case 'restore': {
406
409
  if (v.confirmed !== true)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remotion",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "DSH 视频插件:Remotion 技能、模板与素材工作台、官方 Studio 预览和本地 MP4 导出。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -61,7 +61,7 @@
61
61
  "fflate": "0.8.3",
62
62
  "proper-lockfile": "4.1.2",
63
63
  "yaml": "^2.9.0",
64
- "@stardustlc/dsh-updates": "0.1.2"
64
+ "@stardustlc/dsh-updates": "0.1.3"
65
65
  },
66
66
  "devDependencies": {
67
67
  "@types/node": "^24.0.0",