dsh-remotion 0.4.0 → 0.5.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
@@ -2,6 +2,15 @@
2
2
 
3
3
  [返回简介](README.md) · [使用说明](docs/USAGE.md) · [验证记录](docs/VALIDATION.md)
4
4
 
5
+ ## 0.5.0 (2026-10-05)
6
+
7
+ - 增加可恢复工程归档,释放当前工程名额并完整保留素材与成品;禁止在另一个实例渲染期间移动工程,保留 Remotion 官方许可证提示。
8
+
9
+ ## 0.4.1 (2026-10-03)
10
+
11
+ - 视频工作台后台错误与任务阶段支持英文;切换语言重新展示失败引导,项目文字和原始引擎日志保留原文。
12
+ - 验收范围见 [中英文界面检查](docs/validation-language-2026-10-03.md)。
13
+
5
14
  ## 0.4.0 (2026-10-03)
6
15
 
7
16
  - 新增视频工作台、三种模板、素材副本上传、官方 Studio 预览、本机 MP4 与可编辑 ZIP 备份。
package/README.en.md CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  [中文](README.md)
4
4
 
5
+ ![dsh-remotion whale girl plugin cover](https://raw.githubusercontent.com/STARDUSTLC666/dsh-remotion/master/assets/cover-whale-girl.png)
6
+
5
7
  Bring Remotion React video creation skills into DSH.
6
8
 
7
- [![npm](https://img.shields.io/npm/v/dsh-remotion)](https://www.npmjs.com/package/dsh-remotion) [![downloads](https://img.shields.io/npm/dm/dsh-remotion)](https://www.npmjs.com/package/dsh-remotion)
9
+ [![npm](https://img.shields.io/npm/v/dsh-remotion)](https://www.npmjs.com/package/dsh-remotion) [![downloads](https://raw.githubusercontent.com/STARDUSTLC666/dsh-suite/npm-downloads/assets/dsh-remotion-downloads.svg)](https://www.npmjs.com/package/dsh-remotion)
8
10
 
9
11
  ## What it does
10
12
 
package/README.md CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  [English](README.en.md)
4
4
 
5
+ ![dsh-remotion 鲸鱼娘插件封面](https://raw.githubusercontent.com/STARDUSTLC666/dsh-remotion/master/assets/cover-whale-girl.png)
6
+
5
7
  把 Remotion 的 React 视频创作技能接入 DSH。
6
8
 
7
- [![npm](https://img.shields.io/npm/v/dsh-remotion)](https://www.npmjs.com/package/dsh-remotion) [![downloads](https://img.shields.io/npm/dm/dsh-remotion)](https://www.npmjs.com/package/dsh-remotion)
9
+ [![npm](https://img.shields.io/npm/v/dsh-remotion)](https://www.npmjs.com/package/dsh-remotion) [![downloads](https://raw.githubusercontent.com/STARDUSTLC666/dsh-suite/npm-downloads/assets/dsh-remotion-downloads.svg)](https://www.npmjs.com/package/dsh-remotion)
8
10
 
9
11
  ## 功能
10
12
 
package/docs/USAGE.en.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  [Overview](../README.en.md) · [Changelog](../CHANGELOG.md) · [Validation](VALIDATION.md)
4
4
 
5
+ ## Current improvements
6
+
7
+ Save before confirming an archive; restore it from Archived projects. Archives preserve data and disk usage. Up to 30 projects can be active; stop active jobs first.
8
+
5
9
  ## Installation
6
10
 
7
11
  ```bash
package/docs/USAGE.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  [返回简介](../README.md) · [更新记录](../CHANGELOG.md) · [验证记录](VALIDATION.md)
4
4
 
5
+ ## 本次改进
6
+
7
+ 保存工程后确认归档,可在“已归档工程”恢复。归档不删除项目资料、不减少磁盘占用;最多 30 个当前工程。运行中的任务需先关闭。
8
+
5
9
  ## 安装
6
10
 
7
11
  ```bash
@@ -1,5 +1,7 @@
1
1
  # dsh-remotion 验证记录
2
2
 
3
+ [0.4.1 中英文界面验收](validation-language-2026-10-03.md)。
4
+
3
5
  本页整理原 README 的历史验证说明,保留当时的版本、日期与范围。自动测试、启动检查、浏览器操作和真实服务验收分别记录,不能相互替代。更详细的版本验收文件仍保留在仓库中。
4
6
 
5
7
  ## 0.4.0 视频工作台(2026-10-03)
@@ -0,0 +1,16 @@
1
+ # 0.4.1:中英文界面验收
2
+
3
+ 2026-10-03,Windows / Node 24.16.0。采用 E 盘独立 DSH_HOME、合成资料与无密钥 profile;用户资料、账号、密钥、桌面窗口与 VPN 保持原状。
4
+
5
+ - 自动测试:39 项全部通过。
6
+ - 可见 Web 操作:英文界面超长标题保存被拒绝,提示说明 72 字符上限并保留输入;改为合法中文标题后成功保存。中文界面恢复同一修订的项目和正文。
7
+ - 宿主:官方 DSH 0.2.1-alpha.1 Web;官方 RC2 与 alpha SDK 同载 18 组件,110 工具、35 插件技能、19 输出样例注册与 schema 检查通过。无账号的健康结果不代表真实连接。
8
+ - 边界:本轮没有重新渲染 MP4,渲染和 Studio 的实际验收见 0.4.0 记录。原始引擎日志保持原文。
9
+
10
+ 中英文共用同一份资料;界面翻译不会改写标题、正文、路径、引用或项目文件。系统文件选择控件和文件对话框跟随 Windows / 浏览器语言。未知技术诊断与原始引擎日志保留原文;英文界面提供操作恢复引导。
11
+
12
+ 此记录不代表原生桌面窗口内的新功能已逐项操作通过。发布包完整性、GitHub CI 与公开 npm 全新安装在发布时另行核对。
13
+
14
+ ## English scope
15
+
16
+ The Chinese and English UI share the same data. UI translation preserves original content, paths and project files. Visible interactions were performed in an isolated official alpha Web profile; both RC2 and alpha SDK registration/schema checks passed. Native file controls use the OS/browser language, and original diagnostic logs are retained. Browser results do not certify native desktop interaction or real-service delivery.
package/lib/client.js CHANGED
@@ -2,6 +2,23 @@
2
2
  window.__ModuleLoader__.load({ id: 'dsh-remotion', factory: require => {
3
3
  const React = require('react'), { useState, useEffect, useRef } = React, h = React.createElement;
4
4
  const engine = 'remotion', title = engine === 'hyperframes' ? 'HyperFrames' : 'Remotion', route = 'api/dsh-' + engine + '/workbench';
5
+
6
+ function mediaError(error, en) {
7
+ const message = typeof error === 'string' ? error : error?.message || String(error || '');
8
+ if (!en || !/[\u3400-\u9fff]/.test(message)) return message;
9
+ if (error?.status === 401) return 'Your session expired. Reopen DSH and try again.';
10
+ if (/先归档|已达 30/.test(message)) return 'The 30 active project slots are full. Archive another project before creating or restoring one.';
11
+ if (/已有预览或渲染/.test(message)) return 'Stop the active preview or cancel the render before archiving or restoring a project.';
12
+ if (error?.status === 409 || /已.*更新|另一窗口|修订/.test(message)) return 'The project changed. Reopen the latest version and compare it with your preserved input.';
13
+ if (/标题/.test(message)) return 'Enter a title up to 72 characters.';
14
+ if (/时长/.test(message)) return 'Set a duration between 3 and 30 seconds.';
15
+ if (/正文/.test(message)) return 'Use body text up to 360 characters.';
16
+ if (/素材|PNG|JPEG|MP3|WAV|MP4/.test(message)) return 'Check the media format and file contents. Each asset must be ≤20 MB; a project supports 12 assets and 40 MB total, with one audio and one video background.';
17
+ if (/任务|官方|FFmpeg|Chrome|环境/.test(message)) return 'The official task did not finish. Open the job log and check the render environment, then retry. Your project is preserved.';
18
+ if (/工程记录|项目目录|文件/.test(message)) return 'The project files could not be read safely. Back them up and check their location and permissions before retrying.';
19
+ return 'The operation failed. Check the DSH logs and reload the project before retrying; your input is preserved.';
20
+ }
21
+
5
22
  let cache = { project: null, fields: null, dirty: false, job: null, creating: false };
6
23
  const empty = () => ({ template: 'title', title: '', body: '', format: 'landscape', duration: 8 });
7
24
  const fieldsOf = p => ({ template: p.template, title: p.title, body: p.body, format: p.format, duration: p.duration });
@@ -13,22 +30,22 @@ window.__ModuleLoader__.load({ id: 'dsh-remotion', factory: require => {
13
30
  }
14
31
  function urlFor(project, kind, extra = {}) { const url = new URL(route, document.baseURI); url.search = new URLSearchParams({ id: project.id, kind, ...extra }); return url.href; }
15
32
  function Workbench({ locale }) {
16
- const [lang, setLang] = useState('zh'), [projects, setProjects] = useState([]), [environment, setEnvironment] = useState(null), [templates, setTemplates] = useState([]);
33
+ const [lang, setLang] = useState('zh'), [projects, setProjects] = useState([]), [archived, setArchived] = useState([]), [archiveReview, setArchiveReview] = useState(false), [environment, setEnvironment] = useState(null), [templates, setTemplates] = useState([]);
17
34
  const [project, setProject] = useState(cache.project), [fields, setFields] = useState(cache.fields || empty()), [dirty, setDirty] = useState(cache.dirty), [creating, setCreating] = useState(cache.creating);
18
35
  const [job, setJob] = useState(cache.job), [busy, setBusy] = useState(false), [error, setError] = useState(''), [notice, setNotice] = useState(''), [pending, setPending] = useState(null), [conflict, setConflict] = useState(false);
19
36
  const busyRef = useRef(false), fileRef = useRef(null), latest = useRef({ project, fields, dirty }); latest.current = { project, fields, dirty };
20
37
  const t = (zh, en) => lang === 'en' ? en : zh, activeJob = job?.state === 'running', running = busy || activeJob && job.kind !== 'prepare';
21
- const stage = lang === 'en' ? ({ '下载环境': 'Preparing environment', '检查 FFmpeg': 'Checking FFmpeg', '下载官方依赖': 'Downloading official dependencies', '检查官方环境': 'Checking official CLI', '检查或下载 Chrome': 'Checking or downloading Chrome', '下载专用渲染浏览器': 'Downloading the official rendering browser', '准备修订快照': 'Preparing saved revision', '启动官方预览': 'Starting official preview', '官方预览已就绪': 'Official preview is ready', '检查合成': 'Checking composition', '渲染 MP4': 'Rendering MP4', '已完成': 'Completed', '已取消': 'Cancelled', '已取消;工程已保留': 'Cancelled; project preserved', '官方任务未完成,请展开运行记录后重试。工程已保留。': 'The official task failed. Open the job log and retry; your project is preserved.', '未找到 FFmpeg,请安装并加入 PATH,重启 DSH 后重试。工程已保留。': 'FFmpeg was not found. Install it, add it to PATH, then restart DSH and retry; your project is preserved.' }[job?.stage] || job?.stage) : job?.stage;
38
+ const stage = lang === 'en' ? ({ '下载环境': 'Preparing environment', '检查 FFmpeg': 'Checking FFmpeg', '下载官方依赖': 'Downloading official dependencies', '检查官方环境': 'Checking official CLI', '检查或下载 Chrome': 'Checking or downloading Chrome', '下载专用渲染浏览器': 'Downloading the official rendering browser', '准备修订快照': 'Preparing saved revision', '启动官方预览': 'Starting official preview', '官方预览已就绪': 'Official preview is ready', '检查合成': 'Checking composition', '渲染 MP4': 'Rendering MP4', '已完成': 'Completed', '已取消': 'Cancelled', '已取消;工程已保留': 'Cancelled; project preserved', '官方任务未完成,请展开运行记录后重试。工程已保留。': 'The official task failed. Open the job log and retry; your project is preserved.', '未找到 FFmpeg,请安装并加入 PATH,重启 DSH 后重试。工程已保留。': 'FFmpeg was not found. Install it, add it to PATH, then restart DSH and retry; your project is preserved.' }[job?.stage] || mediaError(job?.stage, true)) : job?.stage;
22
39
  useEffect(() => { const update = () => setLang(String(locale?.getSnapshot?.().active || '').startsWith('en') ? 'en' : 'zh'); update(); return locale?.subscribe?.(update); }, [locale]);
23
40
  useEffect(() => { cache = { project, fields, dirty, job, creating }; }, [project, fields, dirty, job, creating]);
24
41
  useEffect(() => { const exit = e => { if (latest.current.dirty) { e.preventDefault(); e.returnValue = ''; } }; window.addEventListener('beforeunload', exit); return () => window.removeEventListener('beforeunload', exit); }, []);
25
42
  async function refresh() {
26
- const data = await api({ action: 'list' }); setProjects(data.projects); setEnvironment(data.environment); setTemplates(data.templates);
43
+ const data = await api({ action: 'list' }); setProjects(data.projects); setArchived(data.archivedProjects || []); setEnvironment(data.environment); setTemplates(data.templates);
27
44
  const current = latest.current, saved = !current.dirty && data.projects.find(row => row.id === current.project?.id);
28
45
  if (saved) { setProject(saved); setFields(fieldsOf(saved)); }
29
46
  setJob(previous => data.jobs.find(row => row.id === previous?.id) || data.jobs.filter(row => row.state === 'running').at(-1) || data.jobs.at(-1) || null);
30
47
  }
31
- useEffect(() => { refresh().catch(e => setError(e.message)); }, []);
48
+ useEffect(() => { refresh().catch(e => setError(e)); }, []);
32
49
  useEffect(() => {
33
50
  if (!job || job.state !== 'running') return;
34
51
  let live = true, polling = false;
@@ -39,12 +56,12 @@ window.__ModuleLoader__.load({ id: 'dsh-remotion', factory: require => {
39
56
  // Refresh output before the terminal state tears down this polling effect.
40
57
  if (next.state !== 'running') await refresh();
41
58
  else setJob(next);
42
- } catch (e) { if (live) { setError(e.message); setJob(null); } } finally { polling = false; }
59
+ } catch (e) { if (live) { setError(e); setJob(null); } } finally { polling = false; }
43
60
  }, 1000);
44
61
  return () => { live = false; clearInterval(timer); };
45
62
  }, [job?.id, job?.state]);
46
- async function perform(fn) { if (busyRef.current) return; busyRef.current = true; setBusy(true); setError(''); setNotice(''); try { return await fn(); } catch (e) { setError(e.message); setConflict(e.status === 409); } finally { busyRef.current = false; setBusy(false); } }
47
- async function open(id) { const next = await api({ action: 'get', id }); setProject(next); setFields(fieldsOf(next)); setCreating(false); setDirty(false); setConflict(false); setPending(null); }
63
+ async function perform(fn) { if (busyRef.current) return; busyRef.current = true; setBusy(true); setError(''); setNotice(''); try { return await fn(); } catch (e) { setError(e); setConflict(e.status === 409); } finally { busyRef.current = false; setBusy(false); } }
64
+ async function open(id) { const next = await api({ action: 'get', id }); setProject(next); setFields(fieldsOf(next)); setCreating(false); setDirty(false); setConflict(false); setPending(null); setArchiveReview(false); }
48
65
  function navigate(target) { if (dirty) { setPending(target); return; } perform(() => move(target)); }
49
66
  async function move(target) { if (target === 'new') { setProject(null); setFields(empty()); setCreating(true); } else if (target === 'list') { setProject(null); setCreating(false); await refresh(); } else await open(target); setDirty(false); setPending(null); }
50
67
  async function save() { const next = await api(project ? { action: 'update', id: project.id, revision: project.revision, fields } : { action: 'create', fields }); setProject(next); setFields(fieldsOf(next)); setCreating(false); setDirty(false); setConflict(false); setNotice(t('工程已保存。', 'Project saved.')); await refresh(); return next; }
@@ -54,12 +71,16 @@ window.__ModuleLoader__.load({ id: 'dsh-remotion', factory: require => {
54
71
  const response = await fetch(new URL(route + '/upload', document.baseURI), { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/octet-stream', 'x-dsh-media': '1', 'x-project-id': project.id, 'x-project-revision': String(project.revision), 'x-file-name': encodeURIComponent(file.name) }, body: file }); const result = await response.json(); if (!response.ok || !result.ok) { const e = new Error(result.message); e.status = response.status; throw e; } setProject(result.value); setNotice(t('素材副本已保存,原文件未改变。', 'A media copy was saved; your original is unchanged.'));
55
72
  }); event.target.value = ''; }
56
73
  async function start(action) { await perform(async () => { if (action !== 'prepare' && dirty) throw new Error(t('请先保存修改。', 'Save changes first.')); setJob(await api({ action, id: project?.id, revision: project?.revision })); }); }
74
+ async function archiveProject() { await api({action:'archive',id:project.id,revision:project.revision,confirmed:true}); setArchiveReview(false); setProject(null); setCreating(false); setDirty(false); await refresh(); setNotice(t('工程已归档,素材和成品均保留,可在下方恢复。','Project archived. Media and output are preserved; restore it below.')); }
57
75
  const render = project?.renders?.[0], stale = render && render.revision !== project.revision;
58
76
  if (!environment) return h('div', { className: 'dmw' }, h('style', null, css), h('h2', null, title + t(' 视频工作台', ' video workbench')),
59
- error ? h(React.Fragment, null, h('p', { className: 'notice error', role: 'alert' }, error), h('button', { disabled: busy, onClick: () => perform(refresh) }, t('重新读取项目', 'Reload projects'))) : h('p', { role: 'status' }, t('正在读取工程与渲染环境…', 'Loading projects and render environment…')));
77
+ error ? h(React.Fragment, null, h('p', { className: 'notice error', role: 'alert' }, mediaError(error, lang === 'en')), h('button', { disabled: busy, onClick: () => perform(refresh) }, t('重新读取项目', 'Reload projects'))) : h('p', { role: 'status' }, t('正在读取工程与渲染环境…', 'Loading projects and render environment…')));
60
78
  return h('div', { className: 'dmw' }, h('style', null, css), h('h2', null, title + t(' 视频工作台', ' video workbench')),
61
79
  h('p', { className: 'muted' }, t('模板 → 素材 → 官方预览 → 本机 MP4。工程与素材保存在 DSH 本地数据目录。', 'Template → media → official preview → local MP4. Projects stay in the local DSH data folder.')),
62
- error && h('div', { className: 'notice error', role: 'alert' }, error), notice && h('div', { className: 'notice', role: 'status' }, notice),
80
+ error && h('div', { className: 'notice error', role: 'alert' }, mediaError(error, lang === 'en')), notice && h('div', { className: 'notice', role: 'status' }, notice),
81
+ archived.length > 0 && h('details', { className: 'card' }, h('summary', null, t('已归档工程', 'Archived projects') + ' (' + archived.length + ')'), h('p', {className:'muted'}, t('归档保留素材与成品,不占当前 30 个工程名额。恢复后可以继续编辑。','Archives retain media and output, freeing an active project slot. Restore to edit again.')), archived.map(p => h('div',{className:'bar',key:p.id}, h('strong',null,p.title), h('button',{disabled:busy||dirty||activeJob,onClick:()=>perform(async()=>{await api({action:'restore',id:p.id,revision:p.revision,confirmed:true});await refresh();await open(p.id);setNotice(t('工程已恢复。','Project restored.'))})},t('恢复工程','Restore project'))))),
82
+ project && !creating && h('div',{className:'bar'},h('button',{disabled:busy||dirty||activeJob,onClick:()=>setArchiveReview(true)},t('归档此工程','Archive this project'))),
83
+ project && archiveReview && h('div',{className:'card',role:'alert'},h('p',null,t('归档后将从当前项目列表移走;工程、上传素材和已导出 MP4 全部保留,可随时恢复。未保存修改需先处理。','Archiving removes this project from the active list. The project, uploaded media and exported MP4s are preserved and can be restored. Save or discard pending edits first.')),h('div',{className:'bar'},h('button',{disabled:busy||dirty||activeJob,onClick:()=>perform(archiveProject)},t('确认归档','Confirm archive')),h('button',{disabled:busy,onClick:()=>setArchiveReview(false)},t('取消归档','Cancel archive')))),
63
84
  pending && h('div', { className: 'card' }, h('p', null, t('有未保存内容,如何继续?', 'You have unsaved changes.')),
64
85
  h('div', { className: 'bar' }, h('button', { disabled: running, onClick: () => perform(async () => { await save(); await move(pending); }) }, t('保存后继续', 'Save and continue')), h('button', { disabled: running, onClick: () => perform(() => move(pending)) }, t('放弃修改并继续', 'Discard and continue')), h('button', { onClick: () => setPending(null) }, t('留在当前', 'Stay here')))),
65
86
  conflict && project && h('button', { disabled: running, onClick: () => { setPending(project.id); } }, t('重新打开最新版本(先处理当前输入)', 'Reopen latest version (review your current input first)')),
@@ -7,10 +7,12 @@ export declare class MediaStore {
7
7
  readonly root: string;
8
8
  constructor(engine: MediaEngine, root?: string);
9
9
  init(): Promise<void>;
10
- path(id: string): Promise<string>;
10
+ path(id: string, archived?: boolean): Promise<string>;
11
11
  checkPath(path: string): Promise<void>;
12
- get(id: string): Promise<MediaProject>;
13
- list(): Promise<MediaProject[]>;
12
+ get(id: string, archived?: boolean): Promise<MediaProject>;
13
+ list(archived?: boolean): Promise<MediaProject[]>;
14
+ /** Move a complete managed project without deleting sources, assets or renders. */
15
+ archive(id: string, revision: unknown, restore?: boolean): Promise<MediaProject>;
14
16
  private write;
15
17
  create(value: unknown): Promise<MediaProject>;
16
18
  edit(id: string, revision: unknown, fn: (p: MediaProject) => Promise<void> | void, bump?: boolean): Promise<MediaProject>;
@@ -16,9 +16,9 @@ export class MediaStore {
16
16
  root;
17
17
  constructor(engine, root) { this.root = resolve(root ?? join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'data', 'dsh-' + engine)); }
18
18
  async init() { await mkdir(join(this.root, 'projects'), { recursive: true, mode: 0o700 }); }
19
- async path(id) {
19
+ async path(id, archived = false) {
20
20
  await this.init();
21
- const dir = join(this.root, 'projects', mediaId(id));
21
+ const dir = join(this.root, archived ? 'archives' : 'projects', mediaId(id));
22
22
  if ((await lstat(dir)).isSymbolicLink())
23
23
  throw new Error('项目目录不能是符号链接。');
24
24
  await this.checkPath(dir);
@@ -29,8 +29,8 @@ export class MediaStore {
29
29
  if (rel === '..' || rel.startsWith('..' + (process.platform === 'win32' ? '\\' : '/')) || isAbsolute(rel))
30
30
  throw new Error('文件不在本插件项目目录内。');
31
31
  }
32
- async get(id) {
33
- const file = join(await this.path(id), 'project.json');
32
+ async get(id, archived = false) {
33
+ const file = join(await this.path(id, archived), 'project.json');
34
34
  await this.checkPath(file);
35
35
  if ((await stat(file)).size > 65536)
36
36
  throw new Error('工程记录过大,请保留文件后检查。');
@@ -47,15 +47,47 @@ export class MediaStore {
47
47
  mediaId(r.id);
48
48
  return p;
49
49
  }
50
- async list() {
50
+ async list(archived = false) {
51
51
  await this.init();
52
52
  const result = [];
53
- for (const id of await readdir(join(this.root, 'projects'))) {
53
+ const folder = join(this.root, archived ? 'archives' : 'projects');
54
+ const ids = await readdir(folder).catch(error => { if (archived && error.code === 'ENOENT')
55
+ return []; throw error; });
56
+ for (const id of ids) {
54
57
  if (/^[0-9a-f-]{36}$/.test(id))
55
- result.push(await this.get(id));
58
+ result.push(await this.get(id, archived));
56
59
  }
57
60
  return result.sort((a, b) => b.updated.localeCompare(a.updated));
58
61
  }
62
+ /** Move a complete managed project without deleting sources, assets or renders. */
63
+ async archive(id, revision, restore = false) {
64
+ await this.init();
65
+ const unlock = await lockfile.lock(join(this.root, 'projects'), { retries: { retries: 8, minTimeout: 30, maxTimeout: 60 } });
66
+ let release;
67
+ try {
68
+ const source = await this.path(id, restore);
69
+ release = await lockfile.lock(source, { retries: { retries: 8, minTimeout: 30, maxTimeout: 60 } });
70
+ const p = await this.get(id, restore);
71
+ if (revision !== p.revision)
72
+ throw new MediaConflict('工程已更新,请重新读取后再归档或恢复。');
73
+ if (restore && (await this.list()).length >= 30)
74
+ throw new MediaConflict('当前项目已达 30 个,请先归档其他项目再恢复。');
75
+ const folder = join(this.root, restore ? 'projects' : 'archives');
76
+ await mkdir(folder, { recursive: true });
77
+ await this.checkPath(folder);
78
+ const target = join(folder, mediaId(id));
79
+ if (await lstat(target).then(() => true, error => { if (error.code === 'ENOENT')
80
+ return false; throw error; }))
81
+ throw new Error('目标已有同编号工程,已保留两份文件,请先备份检查。');
82
+ await rename(source, target);
83
+ return p;
84
+ }
85
+ finally {
86
+ if (release)
87
+ await release();
88
+ await unlock();
89
+ }
90
+ }
59
91
  async write(p) {
60
92
  const dir = await this.path(p.id), temp = join(dir, randomUUID() + '.tmp');
61
93
  try {
@@ -71,7 +103,7 @@ export class MediaStore {
71
103
  const unlock = await lockfile.lock(join(this.root, 'projects'), { retries: { retries: 8, minTimeout: 30, maxTimeout: 60 } });
72
104
  try {
73
105
  if ((await this.list()).length >= 30)
74
- throw new Error('工作台最多保存 30 个项目,请先备份现有工程。');
106
+ throw new Error('工作台最多保存 30 个当前项目,请先归档现有工程。');
75
107
  const p = { ...validateFields(value), id: randomUUID(), revision: 1, updated: new Date().toISOString(), assets: [], renders: [] };
76
108
  await mkdir(join(this.root, 'projects', p.id), { mode: 0o700 });
77
109
  await this.write(p);
@@ -387,7 +387,7 @@ export class MediaWorkbench {
387
387
  const v = JSON.parse(text);
388
388
  switch (v.action) {
389
389
  case 'list':
390
- value = { projects: await this.store.list(), templates: MEDIA_TEMPLATES, environment: await this.environment(), jobs: [...this.jobs.values()].map(j => structuredClone(j.view)) };
390
+ value = { projects: await this.store.list(), archivedProjects: await this.store.list(true), templates: MEDIA_TEMPLATES, environment: await this.environment(), jobs: [...this.jobs.values()].map(j => structuredClone(j.view)) };
391
391
  break;
392
392
  case 'create':
393
393
  value = await this.store.create(v.fields);
@@ -401,6 +401,28 @@ 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 'archive':
405
+ case 'restore': {
406
+ if (v.confirmed !== true)
407
+ throw new Error('请明确确认归档或恢复。');
408
+ // The same slot covers jobs from another DSH process, so a project
409
+ // cannot move while its render or preview still writes into it.
410
+ const slot = join(this.store.root, 'workbench-slot');
411
+ await this.store.init();
412
+ await mkdir(slot, { recursive: true });
413
+ const unlock = await lockfile.lock(slot, { retries: 0, stale: 30000, update: 10000 }).catch(error => {
414
+ if (error.code === 'ELOCKED')
415
+ throw new MediaConflict('已有预览或渲染正在运行,请先关闭预览或取消渲染,再归档或恢复工程。');
416
+ throw error;
417
+ });
418
+ try {
419
+ value = await this.store.archive(mediaId(v.id), v.revision, v.action === 'restore');
420
+ }
421
+ finally {
422
+ await unlock();
423
+ }
424
+ break;
425
+ }
404
426
  case 'prepare':
405
427
  value = await this.start('prepare');
406
428
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remotion",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "DSH 视频插件:Remotion 技能、模板与素材工作台、官方 Studio 预览和本地 MP4 导出。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",