dsh-hyperframes 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 +21 -0
- package/README.en.md +26 -87
- package/README.md +25 -89
- package/docs/USAGE.en.md +115 -0
- package/docs/USAGE.md +119 -0
- package/docs/VALIDATION.md +22 -0
- package/lib/client.js +90 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +5 -1
- package/lib/media-store.d.ts +25 -0
- package/lib/media-store.js +174 -0
- package/lib/media-templates.d.ts +52 -0
- package/lib/media-templates.js +91 -0
- package/lib/media-workbench.d.ts +42 -0
- package/lib/media-workbench.js +476 -0
- package/package.json +17 -4
- package/templates/DESIGN.md +26 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { spawn, execFile } from 'node:child_process';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { mkdir, readFile, writeFile, stat, symlink, copyFile, open, unlink, realpath } from 'node:fs/promises';
|
|
5
|
+
import { basename, dirname, join, delimiter, toNamespacedPath } from 'node:path';
|
|
6
|
+
import { createServer } from 'node:net';
|
|
7
|
+
import lockfile from 'proper-lockfile';
|
|
8
|
+
import { zipSync, strToU8 } from 'fflate';
|
|
9
|
+
import { MediaStore, mediaId, MediaConflict } from './media-store.js';
|
|
10
|
+
import { MEDIA_VERSIONS, MEDIA_TEMPLATES, projectSources } from './media-templates.js';
|
|
11
|
+
const mime = { png: 'image/png', jpg: 'image/jpeg', mp3: 'audio/mpeg', wav: 'audio/wav', mp4: 'video/mp4' };
|
|
12
|
+
const clean = (text) => text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[redacted]@').replace(/(token|password|authorization)(\s*[=:]\s*)[^\s,;]+/gi, '$1$2[redacted]').slice(0, 1600);
|
|
13
|
+
const errorMessage = (e) => clean(e instanceof Error ? e.message : String(e));
|
|
14
|
+
function installedChromePaths() {
|
|
15
|
+
if (process.platform === 'darwin')
|
|
16
|
+
return ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'];
|
|
17
|
+
if (process.platform !== 'win32')
|
|
18
|
+
return ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser'];
|
|
19
|
+
return [join(process.env.ProgramFiles || 'C:/Program Files', 'Google/Chrome/Application/chrome.exe'), join(process.env['ProgramFiles(x86)'] || 'C:/Program Files (x86)', 'Google/Chrome/Application/chrome.exe'), ...(process.env.LOCALAPPDATA ? [join(process.env.LOCALAPPDATA, 'Google/Chrome/Application/chrome.exe')] : [])];
|
|
20
|
+
}
|
|
21
|
+
function npmCli() {
|
|
22
|
+
const paths = [join(dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js'), join(dirname(process.execPath), '../lib/node_modules/npm/bin/npm-cli.js')];
|
|
23
|
+
for (const path of (process.env.PATH || '').split(delimiter))
|
|
24
|
+
paths.push(join(path, 'node_modules/npm/bin/npm-cli.js'), join(path, '../node_modules/npm/bin/npm-cli.js'));
|
|
25
|
+
if (process.env.APPDATA)
|
|
26
|
+
paths.push(join(process.env.APPDATA, 'npm/node_modules/npm/bin/npm-cli.js'));
|
|
27
|
+
const file = paths.find(existsSync);
|
|
28
|
+
if (!file)
|
|
29
|
+
throw new Error('没有找到 npm。请安装 Node.js 22.19+ 或 24+,重启 DSH 后再准备环境。');
|
|
30
|
+
return file;
|
|
31
|
+
}
|
|
32
|
+
function childEnv(root) {
|
|
33
|
+
const result = {};
|
|
34
|
+
for (const [key, value] of Object.entries(process.env))
|
|
35
|
+
if (/^(path|pathext|systemroot|windir|userprofile|home|appdata|localappdata|https?_proxy|all_proxy|no_proxy|comspec|programfiles|programfiles\(x86\))$/i.test(key))
|
|
36
|
+
result[key] = value;
|
|
37
|
+
return { ...result, ELECTRON_RUN_AS_NODE: '1', DSH_DESKTOP_NODE_EXECUTABLE: process.execPath, HOME: join(root, 'user-home'), USERPROFILE: join(root, 'user-home'), APPDATA: join(root, 'user-home', 'AppData/Roaming'), LOCALAPPDATA: join(root, 'user-home', 'AppData/Local'), CI: '1', NO_COLOR: '1', BROWSER: 'none', TEMP: join(root, 'tmp'), TMP: join(root, 'tmp'), TMPDIR: join(root, 'tmp'), npm_config_userconfig: join(root, 'local.npmrc'), npm_config_cache: join(root, 'npm-cache'), PUPPETEER_CACHE_DIR: join(root, 'browser'), HYPERFRAMES_NO_UPDATE_CHECK: '1', HYPERFRAMES_SKIP_SKILLS: '1', HYPERFRAMES_TELEMETRY_DISABLED: '1', DO_NOT_TRACK: '1', HYPERFRAMES_EXTRACT_CACHE_DIR: join(root, 'frames') };
|
|
38
|
+
}
|
|
39
|
+
async function freePort() {
|
|
40
|
+
return new Promise((resolve, reject) => { const server = createServer(); server.once('error', reject); server.listen(0, '127.0.0.1', () => { const address = server.address(); const port = typeof address === 'object' && address ? address.port : 0; server.close(() => resolve(port)); }); });
|
|
41
|
+
}
|
|
42
|
+
/** Restrict all servers in our renderer child to loopback using Node's public API. */
|
|
43
|
+
const LOOPBACK = `const net=require('node:net');const listen=net.Server.prototype.listen;net.Server.prototype.listen=function(...args){if(args[0]&&typeof args[0]==='object'&&'port'in args[0])args[0]={...args[0],host:'127.0.0.1'};else if(typeof args[0]==='number'){if(typeof args[1]==='string')args[1]='127.0.0.1';else args.splice(1,0,'127.0.0.1')}return listen.apply(this,args)};\n`;
|
|
44
|
+
export class MediaWorkbench {
|
|
45
|
+
engine;
|
|
46
|
+
store;
|
|
47
|
+
route;
|
|
48
|
+
runtime;
|
|
49
|
+
jobs = new Map();
|
|
50
|
+
disposed = false;
|
|
51
|
+
constructor(engine, root) {
|
|
52
|
+
this.engine = engine;
|
|
53
|
+
this.store = new MediaStore(engine, root);
|
|
54
|
+
this.route = '/api/dsh-' + engine + '/workbench';
|
|
55
|
+
this.runtime = join(this.store.root, 'runtime');
|
|
56
|
+
}
|
|
57
|
+
async browserFile(value) {
|
|
58
|
+
if (typeof value !== 'string')
|
|
59
|
+
throw new Error('请重新准备渲染环境,浏览器路径尚未验证。');
|
|
60
|
+
const file = await realpath(value), normalize = (p) => process.platform === 'win32' ? p.toLowerCase() : p;
|
|
61
|
+
const known = await Promise.all(installedChromePaths().map(p => realpath(p).catch(() => '')));
|
|
62
|
+
if (!known.some(p => normalize(p) === normalize(file)))
|
|
63
|
+
await this.store.checkPath(file);
|
|
64
|
+
if (!(await stat(file)).isFile())
|
|
65
|
+
throw new Error('浏览器文件不存在,请重新准备环境。');
|
|
66
|
+
return file;
|
|
67
|
+
}
|
|
68
|
+
async environment() {
|
|
69
|
+
const version = MEDIA_VERSIONS[this.engine];
|
|
70
|
+
let ready = false;
|
|
71
|
+
try {
|
|
72
|
+
const pkg = JSON.parse(await readFile(join(this.runtime, 'node_modules', this.engine === 'hyperframes' ? 'hyperframes' : '@remotion/cli', 'package.json'), 'utf8'));
|
|
73
|
+
const stamp = JSON.parse(await readFile(join(this.runtime, 'ready.json'), 'utf8'));
|
|
74
|
+
const file = await this.browserFile(stamp.browserPath);
|
|
75
|
+
ready = pkg.version === version && stamp.version === version && !(this.engine === 'hyperframes' && process.platform === 'win32' && !/^chrome-headless-shell\.exe$/i.test(basename(file)));
|
|
76
|
+
}
|
|
77
|
+
catch { }
|
|
78
|
+
return { ready, version, node: process.version, hint: this.engine === 'hyperframes' ? '首次下载官方 CLI;Windows 使用官方专用渲染浏览器,其他系统优先复用 Chrome。还需 PATH 中的 FFmpeg。' : '首次下载官方 CLI。优先使用已安装的 Chrome,没有时下载浏览器。Remotion 使用官方许可证:https://www.remotion.dev/license' };
|
|
79
|
+
}
|
|
80
|
+
job(id) { const job = this.jobs.get(mediaId(id)); if (!job)
|
|
81
|
+
throw new Error('任务不存在或 DSH 已重启。工程仍保留,请检查成品后重新操作。'); return structuredClone(job.view); }
|
|
82
|
+
log(job, text) {
|
|
83
|
+
const normalize = (line) => line.replace(/^[◓◑◒◐]\s*/, '').replace(/[. ]+$/, '');
|
|
84
|
+
for (const line of text.split(/[\r\n]+/).filter(Boolean).map(clean)) {
|
|
85
|
+
if (normalize(line) === normalize(job.view.lines.at(-1) || ''))
|
|
86
|
+
continue;
|
|
87
|
+
job.view.lines.push(line);
|
|
88
|
+
if (job.view.lines.length > 40)
|
|
89
|
+
job.view.lines.shift();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
stopChild(job) {
|
|
93
|
+
const child = job.child;
|
|
94
|
+
if (!child?.pid || child.exitCode !== null || child.signalCode !== null)
|
|
95
|
+
return;
|
|
96
|
+
if (process.platform === 'win32')
|
|
97
|
+
execFile(join(process.env.SystemRoot || 'C:/Windows', 'System32/taskkill.exe'), ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true }, () => { });
|
|
98
|
+
else {
|
|
99
|
+
try {
|
|
100
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
child.kill('SIGKILL');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async run(job, file, args, cwd, environment = {}) {
|
|
108
|
+
job.controller.signal.throwIfAborted();
|
|
109
|
+
await mkdir(join(this.store.root, 'tmp'), { recursive: true });
|
|
110
|
+
await mkdir(join(this.store.root, 'user-home/AppData/Roaming'), { recursive: true });
|
|
111
|
+
await mkdir(join(this.store.root, 'user-home/AppData/Local'), { recursive: true });
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const child = spawn(file, args, { cwd, env: { ...childEnv(this.store.root), ...environment }, windowsHide: true, shell: false, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
114
|
+
job.child = child;
|
|
115
|
+
const abort = () => this.stopChild(job);
|
|
116
|
+
job.controller.signal.addEventListener('abort', abort, { once: true });
|
|
117
|
+
child.stdout?.on('data', data => this.log(job, data.toString()));
|
|
118
|
+
child.stderr?.on('data', data => this.log(job, data.toString()));
|
|
119
|
+
const finish = () => { job.controller.signal.removeEventListener('abort', abort); if (job.child === child)
|
|
120
|
+
job.child = undefined; };
|
|
121
|
+
child.once('error', e => { finish(); reject(e); });
|
|
122
|
+
child.once('close', code => { finish(); if (job.controller.signal.aborted)
|
|
123
|
+
reject(new Error('操作已取消。'));
|
|
124
|
+
else if (code !== 0) {
|
|
125
|
+
const useful = job.view.lines.filter(line => /^(?:\w*Error:|npm error)|\b(?:ENOENT|EACCES|ECONNRESET)\b|Unknown flag|Failed to |DevTools remote debugging/.test(line));
|
|
126
|
+
reject(new Error(`官方 ${this.engine} 命令未完成(退出码 ${code})。\n` + (useful.length ? useful.slice(0, 3) : job.view.lines.slice(-5)).join('\n')));
|
|
127
|
+
}
|
|
128
|
+
else
|
|
129
|
+
resolve(); });
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
cli() { return join(this.runtime, 'node_modules', this.engine === 'hyperframes' ? 'hyperframes/bin/hyperframes.mjs' : '@remotion/cli/remotion-cli.js'); }
|
|
133
|
+
async start(kind, id, revision) {
|
|
134
|
+
if (this.disposed)
|
|
135
|
+
throw new Error('插件已卸载,请重新加载。');
|
|
136
|
+
if ([...this.jobs.values()].some(j => j.view.state === 'running' && (j.view.kind === kind || j.view.kind === 'prepare' || kind === 'prepare')))
|
|
137
|
+
throw new Error('已有同类任务运行,请等待完成或明确取消。');
|
|
138
|
+
if (kind !== 'prepare' && !(await this.environment()).ready)
|
|
139
|
+
throw new Error('请先点击“准备渲染环境”,完成首次下载。');
|
|
140
|
+
const snapshot = kind === 'prepare' ? undefined : await this.store.snapshot(mediaId(id), revision);
|
|
141
|
+
if (snapshot)
|
|
142
|
+
projectSources(snapshot.project, this.engine);
|
|
143
|
+
const job = { view: { id: randomUUID(), kind, projectId: snapshot?.project.id, revision: snapshot?.project.revision, state: 'running', stage: kind === 'prepare' ? '下载环境' : '准备修订快照', lines: [] }, controller: new AbortController() };
|
|
144
|
+
if (this.jobs.size >= 30) {
|
|
145
|
+
const old = [...this.jobs.values()].find(j => j.view.state !== 'running');
|
|
146
|
+
if (old)
|
|
147
|
+
this.jobs.delete(old.view.id);
|
|
148
|
+
else
|
|
149
|
+
throw new Error('运行任务过多。');
|
|
150
|
+
}
|
|
151
|
+
this.jobs.set(job.view.id, job);
|
|
152
|
+
void this.execute(job, snapshot);
|
|
153
|
+
return structuredClone(job.view);
|
|
154
|
+
}
|
|
155
|
+
async execute(job, snapshot) {
|
|
156
|
+
let unlock;
|
|
157
|
+
const timeout = setTimeout(() => job.controller.abort(), job.view.kind === 'preview' ? 30 * 60000 : 20 * 60000);
|
|
158
|
+
timeout.unref();
|
|
159
|
+
try {
|
|
160
|
+
await this.store.init();
|
|
161
|
+
await mkdir(this.runtime, { recursive: true });
|
|
162
|
+
const slot = join(this.store.root, 'workbench-slot');
|
|
163
|
+
await mkdir(slot, { recursive: true });
|
|
164
|
+
unlock = await lockfile.lock(slot, { retries: 0, stale: 30000, update: 10000, onCompromised: () => job.controller.abort() });
|
|
165
|
+
if (job.view.kind === 'prepare') {
|
|
166
|
+
await unlink(join(this.runtime, 'ready.json')).catch(() => { });
|
|
167
|
+
if (this.engine === 'hyperframes') {
|
|
168
|
+
job.view.stage = '检查 FFmpeg';
|
|
169
|
+
await this.run(job, 'ffmpeg', ['-version'], this.runtime);
|
|
170
|
+
}
|
|
171
|
+
const dependencies = this.engine === 'hyperframes' ? { hyperframes: MEDIA_VERSIONS.hyperframes, gsap: MEDIA_VERSIONS.gsap } : { '@remotion/cli': MEDIA_VERSIONS.remotion, remotion: MEDIA_VERSIONS.remotion, react: '18.3.1', 'react-dom': '18.3.1' };
|
|
172
|
+
await writeFile(join(this.runtime, 'package.json'), JSON.stringify({ private: true, dependencies }));
|
|
173
|
+
job.view.stage = '下载官方依赖';
|
|
174
|
+
await this.run(job, process.execPath, [npmCli(), 'install', '--ignore-scripts', '--no-audit', '--no-fund', '--registry=https://registry.npmjs.org'], this.runtime);
|
|
175
|
+
job.view.stage = '检查官方环境';
|
|
176
|
+
await this.run(job, process.execPath, [this.cli(), ...(this.engine === 'hyperframes' ? ['--version'] : ['versions'])], this.runtime);
|
|
177
|
+
job.view.stage = '检查或下载 Chrome';
|
|
178
|
+
if (this.engine === 'hyperframes') {
|
|
179
|
+
if (process.platform === 'win32') {
|
|
180
|
+
job.view.stage = '下载专用渲染浏览器';
|
|
181
|
+
// ensure prefers the managed shell; path alone would fall back to desktop Chrome.
|
|
182
|
+
// Avoid --force: the pinned CLI leaves its first spinner running after a forced install.
|
|
183
|
+
await this.run(job, process.execPath, [this.cli(), 'browser', 'ensure'], this.runtime);
|
|
184
|
+
}
|
|
185
|
+
await this.run(job, process.execPath, [this.cli(), 'browser', 'path'], this.runtime);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
const installed = installedChromePaths().find(existsSync);
|
|
189
|
+
if (!installed)
|
|
190
|
+
await this.run(job, process.execPath, [this.cli(), 'browser', 'ensure'], this.runtime);
|
|
191
|
+
await this.run(job, process.execPath, ['-e', "require('@remotion/renderer').ensureBrowser({logLevel:'error',browserExecutable:process.argv[1]||null}).then(v=>console.log(require('node:fs').realpathSync(v.path))).catch(e=>{console.error(e.message);process.exitCode=1})", installed || ''], this.runtime);
|
|
192
|
+
}
|
|
193
|
+
const browserPath = await this.browserFile(job.view.lines.at(-1).trim());
|
|
194
|
+
await writeFile(join(this.runtime, 'ready.json'), JSON.stringify({ version: MEDIA_VERSIONS[this.engine], browserPath }));
|
|
195
|
+
}
|
|
196
|
+
else if (snapshot) {
|
|
197
|
+
const p = snapshot.project, dir = join(await this.store.path(p.id), 'runs', job.view.id);
|
|
198
|
+
await mkdir(dirname(dir), { recursive: true });
|
|
199
|
+
if (this.engine === 'hyperframes')
|
|
200
|
+
await this.run(job, process.execPath, [this.cli(), 'init', dir, '--example', 'blank', '--non-interactive', '--skip-transcribe'], dirname(dir));
|
|
201
|
+
await mkdir(join(dir, 'public/assets'), { recursive: true });
|
|
202
|
+
for (const [file, bytes] of snapshot.assets)
|
|
203
|
+
await writeFile(join(dir, 'public/assets', file), bytes, { flag: 'wx' });
|
|
204
|
+
for (const [file, content] of Object.entries(projectSources(p, this.engine))) {
|
|
205
|
+
await mkdir(dirname(join(dir, file)), { recursive: true });
|
|
206
|
+
await writeFile(join(dir, file), content);
|
|
207
|
+
}
|
|
208
|
+
if (this.engine === 'hyperframes')
|
|
209
|
+
await writeFile(join(dir, 'meta.json'), JSON.stringify({ id: job.view.id, name: p.title, createdAt: p.updated }));
|
|
210
|
+
await writeFile(join(dir, 'loopback.cjs'), LOOPBACK);
|
|
211
|
+
await symlink(join(this.runtime, 'node_modules'), join(dir, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir');
|
|
212
|
+
if (this.engine === 'hyperframes') {
|
|
213
|
+
await mkdir(join(dir, 'vendor'), { recursive: true });
|
|
214
|
+
await copyFile(join(this.runtime, 'node_modules/gsap/dist/gsap.min.js'), join(dir, 'vendor/gsap.min.js'));
|
|
215
|
+
}
|
|
216
|
+
if (job.view.kind === 'preview') {
|
|
217
|
+
const port = await freePort(), url = `http://127.0.0.1:${port}/`;
|
|
218
|
+
job.view.stage = '启动官方预览';
|
|
219
|
+
const args = this.engine === 'hyperframes' ? ['preview', '.', '--port', String(port), '--no-open', '--foreground'] : ['studio', 'src/index.jsx', '--port', String(port), '--no-open', '--ipv4', '--disable-ask-ai'];
|
|
220
|
+
let exited = false;
|
|
221
|
+
const running = this.run(job, process.execPath, ['--require', join(dir, 'loopback.cjs'), this.cli(), ...args], dir).finally(() => { exited = true; });
|
|
222
|
+
// Poll the actual server; a chosen port or spawned process is not readiness.
|
|
223
|
+
const poll = async () => {
|
|
224
|
+
for (let i = 0; i < 180; i++) {
|
|
225
|
+
job.controller.signal.throwIfAborted();
|
|
226
|
+
if (exited)
|
|
227
|
+
return;
|
|
228
|
+
try {
|
|
229
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(1500) });
|
|
230
|
+
if (response.ok) {
|
|
231
|
+
job.view.url = this.engine === 'hyperframes' ? url + '#project/' + job.view.id : url;
|
|
232
|
+
job.view.stage = '官方预览已就绪';
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch { }
|
|
237
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
238
|
+
}
|
|
239
|
+
job.controller.abort();
|
|
240
|
+
};
|
|
241
|
+
await Promise.all([running, poll()]);
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
if (this.engine === 'hyperframes') {
|
|
245
|
+
job.view.stage = '检查合成';
|
|
246
|
+
await this.run(job, process.execPath, [this.cli(), 'lint', '.', '--json'], dir);
|
|
247
|
+
}
|
|
248
|
+
job.view.stage = '渲染 MP4';
|
|
249
|
+
const output = join(dir, 'final.mp4');
|
|
250
|
+
const browserFile = await this.browserFile(JSON.parse(await readFile(join(this.runtime, 'ready.json'), 'utf8')).browserPath);
|
|
251
|
+
const browserPath = browserFile.length >= 240 ? toNamespacedPath(browserFile) : browserFile;
|
|
252
|
+
const chromeMode = /^chrome-headless-shell(?:\.exe)?$/i.test(basename(browserFile)) ? 'headless-shell' : 'chrome-for-testing';
|
|
253
|
+
const args = this.engine === 'hyperframes' ? ['render', '.', '--output', output, '--fps', '24', '--quality', 'draft', '--workers', '1', '--strict', '--no-best-effort', '--no-browser-gpu'] : ['render', 'src/index.jsx', 'ManagedVideo', output, '--codec', 'h264', '--concurrency', '1', '--browser-executable', browserPath, '--chrome-mode', chromeMode];
|
|
254
|
+
await this.run(job, process.execPath, ['--require', join(dir, 'loopback.cjs'), this.cli(), ...args], dir, this.engine === 'hyperframes' ? { HYPERFRAMES_BROWSER_PATH: browserPath } : {});
|
|
255
|
+
const bytes = (await stat(output)).size;
|
|
256
|
+
if (bytes < 100 || bytes > 200 * 1024 * 1024)
|
|
257
|
+
throw new Error('MP4 输出为空或超过 200 MB,请检查运行记录。原工程已保留。');
|
|
258
|
+
const handle = await open(output, 'r'), header = Buffer.alloc(12);
|
|
259
|
+
try {
|
|
260
|
+
await handle.read(header, 0, 12, 0);
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
await handle.close();
|
|
264
|
+
}
|
|
265
|
+
if (header.toString('ascii', 4, 8) !== 'ftyp')
|
|
266
|
+
throw new Error('渲染器输出不是 MP4 文件,原工程已保留。');
|
|
267
|
+
job.controller.signal.throwIfAborted();
|
|
268
|
+
await this.store.edit(p.id, undefined, current => { current.renders = [{ id: job.view.id, revision: p.revision, bytes, created: new Date().toISOString() }, ...current.renders].slice(0, 5); }, false);
|
|
269
|
+
job.view.output = job.view.id;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
job.view.state = job.controller.signal.aborted ? 'cancelled' : 'done';
|
|
273
|
+
job.view.stage = job.view.state === 'done' ? '已完成' : '已取消';
|
|
274
|
+
}
|
|
275
|
+
catch (e) {
|
|
276
|
+
const message = e?.code === 'ELOCKED' ? '另一个 DSH 实例正在运行本插件任务,请等待完成或在那个实例中停止任务。' : errorMessage(e);
|
|
277
|
+
job.view.state = job.controller.signal.aborted ? 'cancelled' : 'failed';
|
|
278
|
+
job.view.stage = job.view.state === 'cancelled' ? '已取消;工程已保留' : /^官方 .*命令未完成/.test(message) ? '官方任务未完成,请展开运行记录后重试。工程已保留。' : /spawn ffmpeg ENOENT/.test(message) ? '未找到 FFmpeg,请安装并加入 PATH,重启 DSH 后重试。工程已保留。' : message;
|
|
279
|
+
this.log(job, message);
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
clearTimeout(timeout);
|
|
283
|
+
if (unlock)
|
|
284
|
+
await unlock().catch(() => { });
|
|
285
|
+
job.view.url = job.view.state === 'running' ? job.view.url : undefined;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
cancel(id) { const job = this.jobs.get(mediaId(id)); if (!job)
|
|
289
|
+
throw new Error('任务不存在。'); if (job.view.state === 'running')
|
|
290
|
+
job.controller.abort(); return structuredClone(job.view); }
|
|
291
|
+
dispose() { this.disposed = true; for (const job of this.jobs.values())
|
|
292
|
+
if (job.view.state === 'running')
|
|
293
|
+
job.controller.abort(); }
|
|
294
|
+
guard(request) {
|
|
295
|
+
const url = new URL(request.url), origin = request.headers.get('origin'), site = request.headers.get('sec-fetch-site');
|
|
296
|
+
// The official carrier keeps Host but rewrites Request.url to its internal URL.
|
|
297
|
+
if (site && !['same-origin', 'none'].includes(site))
|
|
298
|
+
throw new Error('请从 DSH 页面操作。');
|
|
299
|
+
if (origin) {
|
|
300
|
+
const source = new URL(origin);
|
|
301
|
+
if (!['http:', 'https:'].includes(source.protocol) || source.host !== (request.headers.get('host') || url.host))
|
|
302
|
+
throw new Error('请从 DSH 页面操作。');
|
|
303
|
+
}
|
|
304
|
+
if (request.method === 'POST' && request.headers.get('x-dsh-media') !== '1')
|
|
305
|
+
throw new Error('操作来源无效。');
|
|
306
|
+
}
|
|
307
|
+
async fetch(request) {
|
|
308
|
+
try {
|
|
309
|
+
this.guard(request);
|
|
310
|
+
const url = new URL(request.url);
|
|
311
|
+
if (request.method === 'GET') {
|
|
312
|
+
const id = mediaId(url.searchParams.get('id')), p = await this.store.get(id), kind = url.searchParams.get('kind');
|
|
313
|
+
if (kind === 'source') {
|
|
314
|
+
const snapshot = await this.store.snapshot(id, p.revision), files = {};
|
|
315
|
+
for (const [file, content] of Object.entries(projectSources(p, this.engine)))
|
|
316
|
+
files[file] = strToU8(content);
|
|
317
|
+
for (const [file, bytes] of snapshot.assets)
|
|
318
|
+
files['public/assets/' + file] = bytes;
|
|
319
|
+
const bytes = zipSync(files, { level: 1 });
|
|
320
|
+
return new Response(bytes, { headers: { 'content-type': 'application/zip', 'content-disposition': `attachment; filename="${this.engine}-${id}.zip"`, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' } });
|
|
321
|
+
}
|
|
322
|
+
let file, type;
|
|
323
|
+
if (kind === 'asset') {
|
|
324
|
+
const a = p.assets.find(a => a.id === mediaId(url.searchParams.get('asset')));
|
|
325
|
+
if (!a)
|
|
326
|
+
throw new Error('素材不存在。');
|
|
327
|
+
file = join(await this.store.path(id), 'assets', a.file);
|
|
328
|
+
type = mime[a.file.split('.').at(-1)];
|
|
329
|
+
}
|
|
330
|
+
else if (kind === 'mp4') {
|
|
331
|
+
const r = p.renders.find(r => r.id === mediaId(url.searchParams.get('render')));
|
|
332
|
+
if (!r)
|
|
333
|
+
throw new Error('成品不存在。');
|
|
334
|
+
file = join(await this.store.path(id), 'runs', r.id, 'final.mp4');
|
|
335
|
+
type = mime.mp4;
|
|
336
|
+
}
|
|
337
|
+
else
|
|
338
|
+
throw new Error('下载类型无效。');
|
|
339
|
+
await this.store.checkPath(file);
|
|
340
|
+
const size = (await stat(file)).size;
|
|
341
|
+
if (size > 200 * 1024 * 1024)
|
|
342
|
+
throw new Error('成品过大,请从本地工程目录取回。');
|
|
343
|
+
const bytes = await readFile(file), range = request.headers.get('range'), headers = { 'content-type': type, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'accept-ranges': 'bytes' };
|
|
344
|
+
if (range) {
|
|
345
|
+
const match = /^bytes=(\d+)-(\d*)$/.exec(range);
|
|
346
|
+
if (!match)
|
|
347
|
+
return new Response(null, { status: 416, headers: { 'content-range': `bytes */${size}` } });
|
|
348
|
+
const start = Number(match[1]), end = match[2] ? Math.min(Number(match[2]), size - 1) : size - 1;
|
|
349
|
+
if (start > end || start >= size)
|
|
350
|
+
return new Response(null, { status: 416, headers: { 'content-range': `bytes */${size}` } });
|
|
351
|
+
return new Response(bytes.subarray(start, end + 1), { status: 206, headers: { ...headers, 'content-range': `bytes ${start}-${end}/${size}`, 'content-length': String(end - start + 1) } });
|
|
352
|
+
}
|
|
353
|
+
if (url.searchParams.get('download') === '1')
|
|
354
|
+
headers['content-disposition'] = `attachment; filename="${this.engine}-${id}.${kind === 'mp4' ? 'mp4' : file.split('.').at(-1)}"`;
|
|
355
|
+
return new Response(bytes, { headers: { ...headers, 'content-length': String(size) } });
|
|
356
|
+
}
|
|
357
|
+
if (request.method !== 'POST')
|
|
358
|
+
return new Response(null, { status: 405 });
|
|
359
|
+
let value;
|
|
360
|
+
if (url.pathname.endsWith('/upload')) {
|
|
361
|
+
const reader = request.body?.getReader(), chunks = [];
|
|
362
|
+
let size = 0;
|
|
363
|
+
if (!reader)
|
|
364
|
+
throw new Error('请选择素材。');
|
|
365
|
+
try {
|
|
366
|
+
while (true) {
|
|
367
|
+
const chunk = await reader.read();
|
|
368
|
+
if (chunk.done)
|
|
369
|
+
break;
|
|
370
|
+
size += chunk.value.length;
|
|
371
|
+
if (size > 20 * 1024 * 1024)
|
|
372
|
+
throw new Error('单个素材不能超过 20 MB。');
|
|
373
|
+
chunks.push(chunk.value);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
finally {
|
|
377
|
+
await reader.cancel().catch(() => { });
|
|
378
|
+
}
|
|
379
|
+
value = await this.store.upload(mediaId(request.headers.get('x-project-id')), Number(request.headers.get('x-project-revision')), decodeURIComponent(request.headers.get('x-file-name') || ''), Buffer.concat(chunks));
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
if (!request.headers.get('content-type')?.startsWith('application/json'))
|
|
383
|
+
throw new Error('请求格式无效。');
|
|
384
|
+
const text = await request.text();
|
|
385
|
+
if (Buffer.byteLength(text) > 16384)
|
|
386
|
+
throw new Error('请求过大。');
|
|
387
|
+
const v = JSON.parse(text);
|
|
388
|
+
switch (v.action) {
|
|
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)) };
|
|
391
|
+
break;
|
|
392
|
+
case 'create':
|
|
393
|
+
value = await this.store.create(v.fields);
|
|
394
|
+
break;
|
|
395
|
+
case 'get':
|
|
396
|
+
value = await this.store.get(mediaId(v.id));
|
|
397
|
+
break;
|
|
398
|
+
case 'update':
|
|
399
|
+
value = await this.store.update(mediaId(v.id), v.revision, v.fields);
|
|
400
|
+
break;
|
|
401
|
+
case 'removeAsset':
|
|
402
|
+
value = await this.store.removeAsset(mediaId(v.id), v.revision, mediaId(v.asset));
|
|
403
|
+
break;
|
|
404
|
+
case 'prepare':
|
|
405
|
+
value = await this.start('prepare');
|
|
406
|
+
break;
|
|
407
|
+
case 'preview':
|
|
408
|
+
case 'render':
|
|
409
|
+
value = await this.start(v.action, mediaId(v.id), v.revision);
|
|
410
|
+
break;
|
|
411
|
+
case 'job':
|
|
412
|
+
value = this.job(mediaId(v.job));
|
|
413
|
+
break;
|
|
414
|
+
case 'cancel':
|
|
415
|
+
value = this.cancel(mediaId(v.job));
|
|
416
|
+
break;
|
|
417
|
+
default: throw new Error('操作无效。');
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return Response.json({ ok: true, value }, { headers: { 'cache-control': 'no-store' } });
|
|
421
|
+
}
|
|
422
|
+
catch (e) {
|
|
423
|
+
return Response.json({ ok: false, message: errorMessage(e) }, { status: e instanceof MediaConflict ? 409 : 400, headers: { 'cache-control': 'no-store' } });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
export function installMediaWorkbench(ctx, engine) {
|
|
428
|
+
const workbench = new MediaWorkbench(engine);
|
|
429
|
+
ctx.inject?.(['connection'], (host) => {
|
|
430
|
+
host.connection?.fetch?.register({ path: workbench.route, methods: ['GET', 'POST'], requestBody: 'buffered', fetch: (request) => workbench.fetch(request) });
|
|
431
|
+
host.connection?.fetch?.register({ path: workbench.route + '/upload', methods: ['POST'], requestBody: 'streaming', fetch: (request) => workbench.fetch(request) });
|
|
432
|
+
});
|
|
433
|
+
ctx.on?.('dispose', () => workbench.dispose());
|
|
434
|
+
for (const action of ['project', 'render']) {
|
|
435
|
+
const dispose = ctx.tools?.register({
|
|
436
|
+
name: engine + '_' + action,
|
|
437
|
+
description: action === 'project' ? '创建或读取本插件受管理视频工程,列出模板与环境。不会自动安装依赖或渲染。' : '准备官方渲染环境、启动预览/MP4任务、查询进度或取消。工程保存在 DSH 本地数据目录;首次准备环境会下载依赖。',
|
|
438
|
+
parameters: { type: 'object', properties: { action: { type: 'string', enum: action === 'project' ? ['list', 'create', 'get', 'update'] : ['prepare', 'preview', 'render', 'job', 'cancel'] }, id: { type: 'string' }, revision: { type: 'number' }, job: { type: 'string' }, fields: { type: 'object', properties: { template: { type: 'string', enum: ['title', 'product', 'slideshow'] }, title: { type: 'string' }, body: { type: 'string' }, format: { type: 'string', enum: ['landscape', 'portrait', 'square'] }, duration: { type: 'number' } }, required: ['template', 'title', 'body', 'format', 'duration'] } }, required: ['action'] },
|
|
439
|
+
output: {
|
|
440
|
+
schema: { type: 'object', additionalProperties: true },
|
|
441
|
+
render(_args, value) {
|
|
442
|
+
const lines = [engine === 'hyperframes' ? 'HyperFrames 视频工作台' : 'Remotion 视频工作台'];
|
|
443
|
+
if (Array.isArray(value.projects)) {
|
|
444
|
+
lines.push('已保存工程:' + value.projects.length);
|
|
445
|
+
for (const project of value.projects)
|
|
446
|
+
lines.push('- ' + project.title + '(版本 ' + project.revision + ')');
|
|
447
|
+
lines.push(value.environment?.ready ? '渲染环境已就绪。' : '首次预览或导出前,请显式准备渲染环境。');
|
|
448
|
+
}
|
|
449
|
+
else if (value.title)
|
|
450
|
+
lines.push('工程:' + value.title + '(版本 ' + value.revision + ')');
|
|
451
|
+
else if (value.state) {
|
|
452
|
+
lines.push('任务状态:' + value.state, value.stage || '');
|
|
453
|
+
if (value.url)
|
|
454
|
+
lines.push('预览:' + value.url);
|
|
455
|
+
if (value.message)
|
|
456
|
+
lines.push(value.message);
|
|
457
|
+
}
|
|
458
|
+
return [{ type: 'text', text: lines.filter(Boolean).join('\n') }];
|
|
459
|
+
},
|
|
460
|
+
},
|
|
461
|
+
async execute(args) {
|
|
462
|
+
const allowed = action === 'project' ? ['list', 'create', 'get', 'update'] : ['prepare', 'preview', 'render', 'job', 'cancel'];
|
|
463
|
+
if (!allowed.includes(args.action))
|
|
464
|
+
throw new Error('操作无效。');
|
|
465
|
+
const response = await workbench.fetch(new Request('http://dsh.internal' + workbench.route, { method: 'POST', headers: { 'content-type': 'application/json', 'x-dsh-media': '1' }, body: JSON.stringify(args) }));
|
|
466
|
+
const result = await response.json();
|
|
467
|
+
if (!result.ok)
|
|
468
|
+
throw new Error(result.message);
|
|
469
|
+
return result.value;
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
if (typeof dispose === 'function')
|
|
473
|
+
ctx.on?.('dispose', dispose);
|
|
474
|
+
}
|
|
475
|
+
return workbench;
|
|
476
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-hyperframes",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "DSH
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "DSH 视频插件:HyperFrames 技能、模板与素材工作台、官方预览和本地 MP4 导出。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
|
@@ -10,18 +10,22 @@
|
|
|
10
10
|
"types": "./lib/index.d.ts",
|
|
11
11
|
"default": "./lib/index.js"
|
|
12
12
|
},
|
|
13
|
+
"./client": "./lib/client.js",
|
|
13
14
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
14
15
|
"./package.json": "./package.json"
|
|
15
16
|
},
|
|
16
17
|
"files": [
|
|
17
18
|
"lib",
|
|
19
|
+
"templates/DESIGN.md",
|
|
18
20
|
"skills",
|
|
19
21
|
"cordis.patch.yml",
|
|
20
22
|
"README.md",
|
|
21
|
-
"README.en.md"
|
|
23
|
+
"README.en.md",
|
|
24
|
+
"docs",
|
|
25
|
+
"CHANGELOG.md"
|
|
22
26
|
],
|
|
23
27
|
"scripts": {
|
|
24
|
-
"build": "tsc -p tsconfig.json",
|
|
28
|
+
"build": "tsc -p tsconfig.json && node scripts/build-client.mjs",
|
|
25
29
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
26
30
|
"test": "pnpm run build && node --test \"test/*.test.mjs\"",
|
|
27
31
|
"prepublishOnly": "pnpm run build"
|
|
@@ -29,6 +33,12 @@
|
|
|
29
33
|
"dsh": {
|
|
30
34
|
"bundle": {
|
|
31
35
|
"patch": "./cordis.patch.yml"
|
|
36
|
+
},
|
|
37
|
+
"client": {
|
|
38
|
+
"inject": [
|
|
39
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
40
|
+
],
|
|
41
|
+
"platform": "web"
|
|
32
42
|
}
|
|
33
43
|
},
|
|
34
44
|
"keywords": [
|
|
@@ -44,10 +54,13 @@
|
|
|
44
54
|
"node": "^22.19.0 || >=24.0.0"
|
|
45
55
|
},
|
|
46
56
|
"dependencies": {
|
|
57
|
+
"fflate": "0.8.3",
|
|
58
|
+
"proper-lockfile": "4.1.2",
|
|
47
59
|
"yaml": "^2.9.0"
|
|
48
60
|
},
|
|
49
61
|
"devDependencies": {
|
|
50
62
|
"@types/node": "^24.0.0",
|
|
63
|
+
"@types/proper-lockfile": "4.1.4",
|
|
51
64
|
"typescript": "^5.6.0"
|
|
52
65
|
},
|
|
53
66
|
"repository": {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Managed video template identity
|
|
2
|
+
|
|
3
|
+
The three templates are original plugin examples, not copies of upstream templates.
|
|
4
|
+
|
|
5
|
+
## Style Prompt
|
|
6
|
+
|
|
7
|
+
Readable editorial motion with generous margins, a clear headline and one focal image. Title cards and slideshows use a warm paper canvas; product cards use a midnight ink canvas and a coral accent. Motion is a short entrance and a gentle exit driven by the frame or seekable timeline, never by wall-clock time. Portrait and landscape layouts must remain readable without clipping.
|
|
8
|
+
|
|
9
|
+
## Colors
|
|
10
|
+
|
|
11
|
+
- Paper background: #FFF8ED
|
|
12
|
+
- Ink text / dark canvas: #142D39
|
|
13
|
+
- Coral accent: #F26445
|
|
14
|
+
- Mint secondary accent: #79CDB4
|
|
15
|
+
- Light text on ink: #FFF8ED
|
|
16
|
+
|
|
17
|
+
## Typography
|
|
18
|
+
|
|
19
|
+
Use the local system-ui / Microsoft YaHei / sans-serif stack. Do not fetch remote fonts. Reduce heading size for long titles; text wraps within the safe area.
|
|
20
|
+
|
|
21
|
+
## What NOT to Do
|
|
22
|
+
|
|
23
|
+
- Do not place text against the canvas edge or crop it to force a fit.
|
|
24
|
+
- Do not download imagery or send uploaded media to a cloud service.
|
|
25
|
+
- Do not use random, infinite, or wall-clock animations.
|
|
26
|
+
- Do not treat the Studio preview as evidence of the final encoded MP4.
|