dsh-remotion 0.4.1 → 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 +4 -0
- package/README.en.md +3 -1
- package/README.md +3 -1
- package/docs/USAGE.en.md +4 -0
- package/docs/USAGE.md +4 -0
- package/lib/client.js +9 -3
- package/lib/media-store.d.ts +5 -3
- package/lib/media-store.js +40 -8
- package/lib/media-workbench.js +23 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
package/README.en.md
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
[中文](README.md)
|
|
4
4
|
|
|
5
|
+

|
|
6
|
+
|
|
5
7
|
Bring Remotion React video creation skills into DSH.
|
|
6
8
|
|
|
7
|
-
[](https://www.npmjs.com/package/dsh-remotion) [](https://www.npmjs.com/package/dsh-remotion) [](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
|
+

|
|
6
|
+
|
|
5
7
|
把 Remotion 的 React 视频创作技能接入 DSH。
|
|
6
8
|
|
|
7
|
-
[](https://www.npmjs.com/package/dsh-remotion) [](https://www.npmjs.com/package/dsh-remotion) [](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
package/lib/client.js
CHANGED
|
@@ -7,6 +7,8 @@ window.__ModuleLoader__.load({ id: 'dsh-remotion', factory: require => {
|
|
|
7
7
|
const message = typeof error === 'string' ? error : error?.message || String(error || '');
|
|
8
8
|
if (!en || !/[\u3400-\u9fff]/.test(message)) return message;
|
|
9
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.';
|
|
10
12
|
if (error?.status === 409 || /已.*更新|另一窗口|修订/.test(message)) return 'The project changed. Reopen the latest version and compare it with your preserved input.';
|
|
11
13
|
if (/标题/.test(message)) return 'Enter a title up to 72 characters.';
|
|
12
14
|
if (/时长/.test(message)) return 'Set a duration between 3 and 30 seconds.';
|
|
@@ -28,7 +30,7 @@ window.__ModuleLoader__.load({ id: 'dsh-remotion', factory: require => {
|
|
|
28
30
|
}
|
|
29
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; }
|
|
30
32
|
function Workbench({ locale }) {
|
|
31
|
-
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([]);
|
|
32
34
|
const [project, setProject] = useState(cache.project), [fields, setFields] = useState(cache.fields || empty()), [dirty, setDirty] = useState(cache.dirty), [creating, setCreating] = useState(cache.creating);
|
|
33
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);
|
|
34
36
|
const busyRef = useRef(false), fileRef = useRef(null), latest = useRef({ project, fields, dirty }); latest.current = { project, fields, dirty };
|
|
@@ -38,7 +40,7 @@ window.__ModuleLoader__.load({ id: 'dsh-remotion', factory: require => {
|
|
|
38
40
|
useEffect(() => { cache = { project, fields, dirty, job, creating }; }, [project, fields, dirty, job, creating]);
|
|
39
41
|
useEffect(() => { const exit = e => { if (latest.current.dirty) { e.preventDefault(); e.returnValue = ''; } }; window.addEventListener('beforeunload', exit); return () => window.removeEventListener('beforeunload', exit); }, []);
|
|
40
42
|
async function refresh() {
|
|
41
|
-
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);
|
|
42
44
|
const current = latest.current, saved = !current.dirty && data.projects.find(row => row.id === current.project?.id);
|
|
43
45
|
if (saved) { setProject(saved); setFields(fieldsOf(saved)); }
|
|
44
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);
|
|
@@ -59,7 +61,7 @@ window.__ModuleLoader__.load({ id: 'dsh-remotion', factory: require => {
|
|
|
59
61
|
return () => { live = false; clearInterval(timer); };
|
|
60
62
|
}, [job?.id, job?.state]);
|
|
61
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); } }
|
|
62
|
-
async function open(id) { const next = await api({ action: 'get', id }); setProject(next); setFields(fieldsOf(next)); setCreating(false); setDirty(false); setConflict(false); setPending(null); }
|
|
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); }
|
|
63
65
|
function navigate(target) { if (dirty) { setPending(target); return; } perform(() => move(target)); }
|
|
64
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); }
|
|
65
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; }
|
|
@@ -69,12 +71,16 @@ window.__ModuleLoader__.load({ id: 'dsh-remotion', factory: require => {
|
|
|
69
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.'));
|
|
70
72
|
}); event.target.value = ''; }
|
|
71
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.')); }
|
|
72
75
|
const render = project?.renders?.[0], stale = render && render.revision !== project.revision;
|
|
73
76
|
if (!environment) return h('div', { className: 'dmw' }, h('style', null, css), h('h2', null, title + t(' 视频工作台', ' video workbench')),
|
|
74
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…')));
|
|
75
78
|
return h('div', { className: 'dmw' }, h('style', null, css), h('h2', null, title + t(' 视频工作台', ' video workbench')),
|
|
76
79
|
h('p', { className: 'muted' }, t('模板 → 素材 → 官方预览 → 本机 MP4。工程与素材保存在 DSH 本地数据目录。', 'Template → media → official preview → local MP4. Projects stay in the local DSH data folder.')),
|
|
77
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')))),
|
|
78
84
|
pending && h('div', { className: 'card' }, h('p', null, t('有未保存内容,如何继续?', 'You have unsaved changes.')),
|
|
79
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')))),
|
|
80
86
|
conflict && project && h('button', { disabled: running, onClick: () => { setPending(project.id); } }, t('重新打开最新版本(先处理当前输入)', 'Reopen latest version (review your current input first)')),
|
package/lib/media-store.d.ts
CHANGED
|
@@ -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>;
|
package/lib/media-store.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/lib/media-workbench.js
CHANGED
|
@@ -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;
|