@memberjunction/video-studio-render-worker 0.0.1-seed.1 → 6.2.0-edge.7
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/LICENSE +183 -0
- package/README.md +139 -4
- package/assets/fonts/Inter-OFL.txt +93 -0
- package/assets/fonts/Inter-Regular.ttf +0 -0
- package/assets/fonts/JetBrainsMono-OFL.txt +93 -0
- package/assets/fonts/JetBrainsMono-Regular.ttf +0 -0
- package/dist/backend.d.ts +201 -0
- package/dist/backend.d.ts.map +1 -0
- package/dist/backend.js +205 -0
- package/dist/backend.js.map +1 -0
- package/dist/bundle.d.ts +112 -0
- package/dist/bundle.d.ts.map +1 -0
- package/dist/bundle.js +433 -0
- package/dist/bundle.js.map +1 -0
- package/dist/checks/dom-rules.d.ts +57 -0
- package/dist/checks/dom-rules.d.ts.map +1 -0
- package/dist/checks/dom-rules.js +205 -0
- package/dist/checks/dom-rules.js.map +1 -0
- package/dist/checks/hyperframes-check.d.ts +37 -0
- package/dist/checks/hyperframes-check.d.ts.map +1 -0
- package/dist/checks/hyperframes-check.js +161 -0
- package/dist/checks/hyperframes-check.js.map +1 -0
- package/dist/checks/static-checks.d.ts +46 -0
- package/dist/checks/static-checks.d.ts.map +1 -0
- package/dist/checks/static-checks.js +681 -0
- package/dist/checks/static-checks.js.map +1 -0
- package/dist/hyperframes-backend.d.ts +53 -0
- package/dist/hyperframes-backend.d.ts.map +1 -0
- package/dist/hyperframes-backend.js +733 -0
- package/dist/hyperframes-backend.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/jobs.d.ts +88 -0
- package/dist/jobs.d.ts.map +1 -0
- package/dist/jobs.js +650 -0
- package/dist/jobs.js.map +1 -0
- package/dist/lint-gate.d.ts +21 -0
- package/dist/lint-gate.d.ts.map +1 -0
- package/dist/lint-gate.js +528 -0
- package/dist/lint-gate.js.map +1 -0
- package/dist/main.d.ts +47 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +195 -0
- package/dist/main.js.map +1 -0
- package/dist/one-shot.d.ts +37 -0
- package/dist/one-shot.d.ts.map +1 -0
- package/dist/one-shot.js +228 -0
- package/dist/one-shot.js.map +1 -0
- package/dist/preamble.d.ts +69 -0
- package/dist/preamble.d.ts.map +1 -0
- package/dist/preamble.js +331 -0
- package/dist/preamble.js.map +1 -0
- package/dist/server.d.ts +75 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +526 -0
- package/dist/server.js.map +1 -0
- package/package.json +43 -5
package/dist/jobs.js
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { VIDEO_FORMATS, } from '@memberjunction/video-studio-core';
|
|
5
|
+
import { RenderWorkerError, } from './backend.js';
|
|
6
|
+
import { ResolveCompositionDuration, ResolveCompositionFps, StageRenderBundle, ValidateCompositionGeometryMatch, ValidateRenderGeometry, ValidateStrictBase64, } from './bundle.js';
|
|
7
|
+
import { EvaluateDomRules } from './checks/dom-rules.js';
|
|
8
|
+
import { BuildCheckCliFailedIssue } from './checks/hyperframes-check.js';
|
|
9
|
+
import { RunStaticChecks } from './checks/static-checks.js';
|
|
10
|
+
import { RunLintGate, SortCheckIssues } from './lint-gate.js';
|
|
11
|
+
/** Maximum number of still capture points allowed in a single `/stills` request. */
|
|
12
|
+
export const MAX_STILL_POINTS = 48;
|
|
13
|
+
/** Maximum number of grid contact sheets produced by a single `/contact-sheet` request. */
|
|
14
|
+
export const MAX_CONTACT_SHEET_GRIDS = 4;
|
|
15
|
+
/** Default number of tiles per contact sheet grid. */
|
|
16
|
+
export const DEFAULT_TILES_PER_SHEET = 12;
|
|
17
|
+
/** Output width in pixels for every contact sheet image. */
|
|
18
|
+
export const CONTACT_SHEET_WIDTH = 1536;
|
|
19
|
+
/** Issue code emitted when `backend.MeasureText` throws during `/check`. */
|
|
20
|
+
export const MJ_MEASURE_TEXT_FAILED_CODE = 'MJ_MEASURE_TEXT_FAILED';
|
|
21
|
+
/**
|
|
22
|
+
* Executes a `/check` job (VS-RND-5 Step 8, extended in VS-DAT-3):
|
|
23
|
+
* 1. Validate request & geometry (including composition root attribute match and `data-fps` range)
|
|
24
|
+
* 2. Stage bundle (with preamble injected on a single line so line numbers stay unchanged and
|
|
25
|
+
* `@hyperframes/lint` sees `window.__timelines` for `OnFrame`-only compositions)
|
|
26
|
+
* 3. Run RND-1 lint gate (`RunLintGate`); if lint-blocked, return lint issues immediately
|
|
27
|
+
* without invoking Chromium
|
|
28
|
+
* 4. Run static authored-file checks (`RunStaticChecks`)
|
|
29
|
+
* 5. Run `hyperframes check --json` via `backend.RunCheckCli`
|
|
30
|
+
* 6. Measure rendered text across the union of `SampleTimestampsSeconds ?? [0]`,
|
|
31
|
+
* `SettleTimestampsSeconds ?? []`, and (when `RequiredFinalFrameText` is non-empty) the final
|
|
32
|
+
* frame via `backend.MeasureText` and evaluate DOM rules (`EvaluateDomRules`)
|
|
33
|
+
* 7. Merge, dedupe on `Code + Selector + File + Line` keeping earliest `TimestampSeconds`, and sort.
|
|
34
|
+
*/
|
|
35
|
+
export async function RunCheckJob(req, backend, signal, options) {
|
|
36
|
+
if (!req || typeof req !== 'object' || !req.Bundle) {
|
|
37
|
+
throw new RenderWorkerError('InvalidRequest', 'CheckRequest.Bundle is required.');
|
|
38
|
+
}
|
|
39
|
+
validateTimestampArray(req.SampleTimestampsSeconds, 'CheckRequest.SampleTimestampsSeconds');
|
|
40
|
+
validateTimestampArray(req.SettleTimestampsSeconds, 'CheckRequest.SettleTimestampsSeconds');
|
|
41
|
+
if (req.AllowedDataLiterals !== undefined) {
|
|
42
|
+
if (!Array.isArray(req.AllowedDataLiterals) ||
|
|
43
|
+
req.AllowedDataLiterals.some((lit) => typeof lit !== 'string')) {
|
|
44
|
+
throw new RenderWorkerError('InvalidRequest', 'CheckRequest.AllowedDataLiterals must be an array of strings.');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (req.BoundDataLiterals !== undefined) {
|
|
48
|
+
if (typeof req.BoundDataLiterals !== 'object' ||
|
|
49
|
+
req.BoundDataLiterals === null ||
|
|
50
|
+
Array.isArray(req.BoundDataLiterals)) {
|
|
51
|
+
throw new RenderWorkerError('InvalidRequest', 'CheckRequest.BoundDataLiterals must be an object mapping bound keys to string arrays.');
|
|
52
|
+
}
|
|
53
|
+
for (const [key, values] of Object.entries(req.BoundDataLiterals)) {
|
|
54
|
+
if (!Array.isArray(values) || values.some((lit) => typeof lit !== 'string')) {
|
|
55
|
+
throw new RenderWorkerError('InvalidRequest', `CheckRequest.BoundDataLiterals["${key}"] must be an array of strings.`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (req.RequiredFinalFrameText !== undefined) {
|
|
60
|
+
if (!Array.isArray(req.RequiredFinalFrameText) ||
|
|
61
|
+
req.RequiredFinalFrameText.some((item) => typeof item !== 'string')) {
|
|
62
|
+
throw new RenderWorkerError('InvalidRequest', 'CheckRequest.RequiredFinalFrameText must be an array of strings.');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
ValidateRenderGeometry({
|
|
66
|
+
Format: req.Bundle.Format,
|
|
67
|
+
Width: req.Width,
|
|
68
|
+
Height: req.Height,
|
|
69
|
+
});
|
|
70
|
+
ValidateCompositionGeometryMatch(req.Bundle, {
|
|
71
|
+
Width: req.Width,
|
|
72
|
+
Height: req.Height,
|
|
73
|
+
});
|
|
74
|
+
const resolvedFps = ResolveCompositionFps(req.Bundle, 30);
|
|
75
|
+
const hasRequiredFinalFrameText = (req.RequiredFinalFrameText ?? []).some((item) => item.trim().length > 0);
|
|
76
|
+
const resolvedDuration = hasRequiredFinalFrameText
|
|
77
|
+
? ResolveCompositionDuration(req.Bundle, 5)
|
|
78
|
+
: undefined;
|
|
79
|
+
const finalFrameTs = resolvedDuration !== undefined
|
|
80
|
+
? Math.max(0, Number((resolvedDuration - 1 / resolvedFps).toFixed(6)))
|
|
81
|
+
: undefined;
|
|
82
|
+
const staged = await StageRenderBundle(req.Bundle, {
|
|
83
|
+
TempRoot: options?.TempRoot,
|
|
84
|
+
Geometry: { Width: req.Width, Height: req.Height, Fps: resolvedFps },
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
const lintResult = await RunLintGate(req.Bundle, staged.RootDir, backend);
|
|
88
|
+
if (lintResult.BlockRender) {
|
|
89
|
+
return lintResult;
|
|
90
|
+
}
|
|
91
|
+
const staticIssues = RunStaticChecks(req.Bundle, {
|
|
92
|
+
AllowedDataLiterals: req.AllowedDataLiterals,
|
|
93
|
+
});
|
|
94
|
+
const checkJob = {
|
|
95
|
+
RootDir: staged.RootDir,
|
|
96
|
+
EntryFile: req.Bundle.EntryFile,
|
|
97
|
+
Width: req.Width,
|
|
98
|
+
Height: req.Height,
|
|
99
|
+
Fps: resolvedFps,
|
|
100
|
+
};
|
|
101
|
+
const cliIssues = await runCheckCliSafely(backend, checkJob, signal);
|
|
102
|
+
const baseSamples = Array.isArray(req.SampleTimestampsSeconds) && req.SampleTimestampsSeconds.length > 0
|
|
103
|
+
? req.SampleTimestampsSeconds
|
|
104
|
+
: [0];
|
|
105
|
+
const sampleTimestamps = dedupeMeasureTimestamps([
|
|
106
|
+
...baseSamples,
|
|
107
|
+
...(req.SettleTimestampsSeconds ?? []),
|
|
108
|
+
...(finalFrameTs !== undefined ? [finalFrameTs] : []),
|
|
109
|
+
]);
|
|
110
|
+
const domIssues = await runDomChecksSafely(req, backend, checkJob, sampleTimestamps, finalFrameTs, signal);
|
|
111
|
+
const merged = SortCheckIssues(DedupeCheckIssues([...lintResult.Issues, ...staticIssues, ...cliIssues, ...domIssues]));
|
|
112
|
+
const passed = !merged.some((issue) => issue.Severity === 'Error');
|
|
113
|
+
return {
|
|
114
|
+
Passed: passed,
|
|
115
|
+
BlockRender: !passed,
|
|
116
|
+
Issues: merged,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
await staged.Cleanup();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function validateTimestampArray(timestamps, fieldName) {
|
|
124
|
+
if (timestamps === undefined) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (!Array.isArray(timestamps)) {
|
|
128
|
+
throw new RenderWorkerError('InvalidRequest', `${fieldName} must be an array of finite non-negative numbers.`);
|
|
129
|
+
}
|
|
130
|
+
if (timestamps.length > MAX_STILL_POINTS) {
|
|
131
|
+
throw new RenderWorkerError('InvalidRequest', `${fieldName} count (${timestamps.length}) exceeds maximum allowed (${MAX_STILL_POINTS}).`);
|
|
132
|
+
}
|
|
133
|
+
for (let i = 0; i < timestamps.length; i++) {
|
|
134
|
+
const ts = timestamps[i];
|
|
135
|
+
if (typeof ts !== 'number' || !Number.isFinite(ts) || ts < 0) {
|
|
136
|
+
throw new RenderWorkerError('InvalidRequest', `${fieldName}[${i}] must be a finite number >= 0.`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function dedupeMeasureTimestamps(timestamps) {
|
|
141
|
+
const out = [];
|
|
142
|
+
for (const ts of timestamps) {
|
|
143
|
+
if (!out.some((existing) => Math.abs(existing - ts) <= 1e-6)) {
|
|
144
|
+
out.push(ts);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Deduplicates `CheckIssue` items on `Code + Selector + File + Line`, keeping the earliest
|
|
151
|
+
* `TimestampSeconds` when duplicates exist (VS-RND-5 Step 8).
|
|
152
|
+
*/
|
|
153
|
+
export function DedupeCheckIssues(issues) {
|
|
154
|
+
const byKey = new Map();
|
|
155
|
+
for (const issue of issues) {
|
|
156
|
+
const key = `${issue.Code}\u0000${issue.Selector ?? ''}\u0000${issue.File ?? ''}\u0000${issue.Line ?? ''}`;
|
|
157
|
+
const existing = byKey.get(key);
|
|
158
|
+
if (!existing) {
|
|
159
|
+
byKey.set(key, { ...issue });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const existingTime = existing.TimestampSeconds;
|
|
163
|
+
const candidateTime = issue.TimestampSeconds;
|
|
164
|
+
if (candidateTime !== undefined &&
|
|
165
|
+
(existingTime === undefined || candidateTime < existingTime)) {
|
|
166
|
+
byKey.set(key, {
|
|
167
|
+
...issue,
|
|
168
|
+
Severity: existing.Severity === 'Error' || issue.Severity === 'Error'
|
|
169
|
+
? 'Error'
|
|
170
|
+
: issue.Severity,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
else if (existing.Severity !== 'Error' && issue.Severity === 'Error') {
|
|
174
|
+
existing.Severity = 'Error';
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return [...byKey.values()];
|
|
178
|
+
}
|
|
179
|
+
async function runCheckCliSafely(backend, job, signal) {
|
|
180
|
+
try {
|
|
181
|
+
return await backend.RunCheckCli(job, signal);
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (signal?.aborted) {
|
|
185
|
+
throw new RenderWorkerError('Cancelled', 'Check job was cancelled.');
|
|
186
|
+
}
|
|
187
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
188
|
+
return [BuildCheckCliFailedIssue(reason)];
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async function runDomChecksSafely(req, backend, job, sampleTimestamps, finalFrameTimestampSeconds, signal) {
|
|
192
|
+
try {
|
|
193
|
+
const measuredByTimestamp = await backend.MeasureText(job, sampleTimestamps, signal);
|
|
194
|
+
const flatMeasured = measuredByTimestamp.flat();
|
|
195
|
+
return EvaluateDomRules(flatMeasured, {
|
|
196
|
+
Format: req.Bundle.Format,
|
|
197
|
+
Width: req.Width,
|
|
198
|
+
Height: req.Height,
|
|
199
|
+
AllowedDataLiterals: req.AllowedDataLiterals,
|
|
200
|
+
BoundDataLiterals: req.BoundDataLiterals,
|
|
201
|
+
SettleTimestampsSeconds: req.SettleTimestampsSeconds,
|
|
202
|
+
RequiredFinalFrameText: req.RequiredFinalFrameText,
|
|
203
|
+
FinalFrameTimestampSeconds: finalFrameTimestampSeconds,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (signal?.aborted) {
|
|
208
|
+
throw new RenderWorkerError('Cancelled', 'Check job was cancelled.');
|
|
209
|
+
}
|
|
210
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
211
|
+
return [
|
|
212
|
+
{
|
|
213
|
+
Code: MJ_MEASURE_TEXT_FAILED_CODE,
|
|
214
|
+
Category: 'Lint',
|
|
215
|
+
Severity: 'Warning',
|
|
216
|
+
Message: `DOM text measurement failed: ${reason}`,
|
|
217
|
+
},
|
|
218
|
+
];
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Executes a `/render` job: stages the bundle, enforces the lint gate, injects the CSP and
|
|
223
|
+
* `window.MJVideo` preamble, renders the MP4 via `backend.Render`, probes the output, and
|
|
224
|
+
* returns a `RenderResult`.
|
|
225
|
+
*/
|
|
226
|
+
export async function RunRenderJob(req, backend, onProgress, signal, options) {
|
|
227
|
+
if (!req || typeof req !== 'object' || !req.Bundle) {
|
|
228
|
+
throw new RenderWorkerError('InvalidRequest', 'RenderRequest.Bundle is required.');
|
|
229
|
+
}
|
|
230
|
+
ValidateRenderGeometry({
|
|
231
|
+
Format: req.Bundle.Format,
|
|
232
|
+
Width: req.Width,
|
|
233
|
+
Height: req.Height,
|
|
234
|
+
Fps: req.Fps,
|
|
235
|
+
DurationSeconds: req.DurationSeconds,
|
|
236
|
+
});
|
|
237
|
+
ValidateCompositionGeometryMatch(req.Bundle, {
|
|
238
|
+
Width: req.Width,
|
|
239
|
+
Height: req.Height,
|
|
240
|
+
DurationSeconds: req.DurationSeconds,
|
|
241
|
+
});
|
|
242
|
+
const startMs = Date.now();
|
|
243
|
+
const staged = options?.StagedRootDir
|
|
244
|
+
? { RootDir: options.StagedRootDir, Cleanup: async () => undefined }
|
|
245
|
+
: await StageRenderBundle(req.Bundle, {
|
|
246
|
+
TempRoot: options?.TempRoot,
|
|
247
|
+
Geometry: {
|
|
248
|
+
Width: req.Width,
|
|
249
|
+
Height: req.Height,
|
|
250
|
+
Fps: req.Fps,
|
|
251
|
+
DurationSeconds: req.DurationSeconds,
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
try {
|
|
255
|
+
if (!options?.SkipLintGate) {
|
|
256
|
+
const check = await RunLintGate(req.Bundle, staged.RootDir, backend);
|
|
257
|
+
if (check.BlockRender) {
|
|
258
|
+
throw new RenderWorkerError('LintBlocked', 'Composition failed pre-render lint checks.', check.Issues);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const rendered = await backend.Render({
|
|
262
|
+
RootDir: staged.RootDir,
|
|
263
|
+
EntryFile: req.Bundle.EntryFile,
|
|
264
|
+
Width: req.Width,
|
|
265
|
+
Height: req.Height,
|
|
266
|
+
Fps: req.Fps,
|
|
267
|
+
DurationSeconds: req.DurationSeconds,
|
|
268
|
+
Quality: req.Quality,
|
|
269
|
+
}, onProgress, signal);
|
|
270
|
+
const videoBytes = await fs.readFile(rendered.OutputPath);
|
|
271
|
+
const probe = await backend.Probe(rendered.OutputPath);
|
|
272
|
+
const rendererVersion = backend.GetRendererVersion();
|
|
273
|
+
return {
|
|
274
|
+
Format: req.Bundle.Format,
|
|
275
|
+
Video: new Uint8Array(videoBytes),
|
|
276
|
+
MimeType: 'video/mp4',
|
|
277
|
+
Width: probe.Width || req.Width,
|
|
278
|
+
Height: probe.Height || req.Height,
|
|
279
|
+
Fps: req.Fps,
|
|
280
|
+
DurationSeconds: probe.DurationSeconds || req.DurationSeconds,
|
|
281
|
+
FrameCount: rendered.FrameCount,
|
|
282
|
+
HasAudio: probe.HasAudio,
|
|
283
|
+
RendererVersion: rendererVersion,
|
|
284
|
+
RenderDurationMs: Math.max(1, Date.now() - startMs),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
finally {
|
|
288
|
+
await staged.Cleanup();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Executes a `/stills` job: validates `Points` (1..48), stages and lints the bundle, injects
|
|
293
|
+
* the preamble, and captures all requested timestamps in a single browser session.
|
|
294
|
+
*/
|
|
295
|
+
export async function RunStillsJob(req, backend, signal, options) {
|
|
296
|
+
if (!req || typeof req !== 'object' || !req.Bundle) {
|
|
297
|
+
throw new RenderWorkerError('InvalidRequest', 'StillsRequest.Bundle is required.');
|
|
298
|
+
}
|
|
299
|
+
if (!Array.isArray(req.Points) || req.Points.length === 0) {
|
|
300
|
+
throw new RenderWorkerError('InvalidRequest', 'StillsRequest.Points must be a non-empty array.');
|
|
301
|
+
}
|
|
302
|
+
if (req.Points.length > MAX_STILL_POINTS) {
|
|
303
|
+
throw new RenderWorkerError('TooManyStills', `StillsRequest.Points (${req.Points.length}) exceeds maximum (${MAX_STILL_POINTS}).`);
|
|
304
|
+
}
|
|
305
|
+
for (const point of req.Points) {
|
|
306
|
+
if (!point || typeof point.Key !== 'string' || !point.Key.trim()) {
|
|
307
|
+
throw new RenderWorkerError('InvalidRequest', 'Each StillCapturePoint requires a non-empty Key.');
|
|
308
|
+
}
|
|
309
|
+
if (typeof point.TimestampSeconds !== 'number' || !Number.isFinite(point.TimestampSeconds) || point.TimestampSeconds < 0) {
|
|
310
|
+
throw new RenderWorkerError('InvalidRequest', `Invalid TimestampSeconds on point "${point.Key}".`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
ValidateRenderGeometry({
|
|
314
|
+
Format: req.Bundle.Format,
|
|
315
|
+
Width: req.Width,
|
|
316
|
+
Height: req.Height,
|
|
317
|
+
});
|
|
318
|
+
ValidateCompositionGeometryMatch(req.Bundle, {
|
|
319
|
+
Width: req.Width,
|
|
320
|
+
Height: req.Height,
|
|
321
|
+
});
|
|
322
|
+
const imageFormat = req.ImageFormat ?? 'png';
|
|
323
|
+
const mimeType = imageFormat === 'jpeg' ? 'image/jpeg' : 'image/png';
|
|
324
|
+
const staged = await StageRenderBundle(req.Bundle, {
|
|
325
|
+
TempRoot: options?.TempRoot,
|
|
326
|
+
Geometry: { Width: req.Width, Height: req.Height, Fps: 30 },
|
|
327
|
+
});
|
|
328
|
+
try {
|
|
329
|
+
const check = await RunLintGate(req.Bundle, staged.RootDir, backend);
|
|
330
|
+
if (check.BlockRender) {
|
|
331
|
+
throw new RenderWorkerError('LintBlocked', 'Composition failed pre-render lint checks.', check.Issues);
|
|
332
|
+
}
|
|
333
|
+
const buffers = await backend.CaptureStills({
|
|
334
|
+
RootDir: staged.RootDir,
|
|
335
|
+
EntryFile: req.Bundle.EntryFile,
|
|
336
|
+
Width: req.Width,
|
|
337
|
+
Height: req.Height,
|
|
338
|
+
Fps: 30,
|
|
339
|
+
TimestampsSeconds: req.Points.map((p) => p.TimestampSeconds),
|
|
340
|
+
ImageFormat: imageFormat,
|
|
341
|
+
}, signal);
|
|
342
|
+
const stills = req.Points.map((point, idx) => {
|
|
343
|
+
const bytes = buffers[idx] ?? new Uint8Array();
|
|
344
|
+
return {
|
|
345
|
+
Key: point.Key,
|
|
346
|
+
TimestampSeconds: point.TimestampSeconds,
|
|
347
|
+
BeatKey: point.BeatKey,
|
|
348
|
+
Role: point.Role,
|
|
349
|
+
Label: point.Label,
|
|
350
|
+
MimeType: mimeType,
|
|
351
|
+
Width: req.Width,
|
|
352
|
+
Height: req.Height,
|
|
353
|
+
ImageBase64: Buffer.from(bytes).toString('base64'),
|
|
354
|
+
};
|
|
355
|
+
});
|
|
356
|
+
const rendererVersion = backend.GetRendererVersion();
|
|
357
|
+
return {
|
|
358
|
+
Format: req.Bundle.Format,
|
|
359
|
+
Stills: stills,
|
|
360
|
+
RendererVersion: rendererVersion,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
finally {
|
|
364
|
+
await staged.Cleanup();
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Executes a `/contact-sheet` job: lays out `req.Stills` into an MJ HTML grid of `data:` images
|
|
369
|
+
* with HTML-escaped labels (`mm:ss.ss`, beat, role, format) and captures each sheet at `t=0` via
|
|
370
|
+
* `backend.CaptureStills`.
|
|
371
|
+
*
|
|
372
|
+
* Layout rules (Step 8):
|
|
373
|
+
* - 1536 px wide
|
|
374
|
+
* - Default columns: 3 for `16:9`, 4 for `1:1`, 5 for `9:16`
|
|
375
|
+
* - Default 12 tiles per sheet (`MaxTilesPerSheet ?? 12`)
|
|
376
|
+
* - Over 4 grid sheets throws `RenderWorkerError('TooManyStills', ...)`
|
|
377
|
+
* - When `IncludeTransitionStrip` is true and `TransitionStart`/`TransitionEnd` pairs exist,
|
|
378
|
+
* appends one `TransitionStrip` sheet.
|
|
379
|
+
*/
|
|
380
|
+
export async function RunContactSheetJob(req, backend, signal, options) {
|
|
381
|
+
if (!req || typeof req !== 'object' || !VIDEO_FORMATS.includes(req.Format)) {
|
|
382
|
+
throw new RenderWorkerError('InvalidRequest', 'ContactSheetRequest.Format must be a valid VideoFormat.');
|
|
383
|
+
}
|
|
384
|
+
if (!Array.isArray(req.Stills) || req.Stills.length === 0) {
|
|
385
|
+
throw new RenderWorkerError('InvalidRequest', 'ContactSheetRequest.Stills must be a non-empty array.');
|
|
386
|
+
}
|
|
387
|
+
const columns = req.Columns ?? defaultColumnsForFormat(req.Format);
|
|
388
|
+
const maxTilesPerSheet = req.MaxTilesPerSheet ?? DEFAULT_TILES_PER_SHEET;
|
|
389
|
+
if (!Number.isInteger(columns) ||
|
|
390
|
+
columns < 1 ||
|
|
391
|
+
columns > 12 ||
|
|
392
|
+
!Number.isInteger(maxTilesPerSheet) ||
|
|
393
|
+
maxTilesPerSheet < 1) {
|
|
394
|
+
throw new RenderWorkerError('InvalidRequest', 'Invalid Columns or MaxTilesPerSheet.');
|
|
395
|
+
}
|
|
396
|
+
for (const still of req.Stills) {
|
|
397
|
+
if (!still || typeof still !== 'object' || typeof still.Key !== 'string' || !still.Key.trim()) {
|
|
398
|
+
throw new RenderWorkerError('InvalidRequest', 'Each CapturedStill requires a non-empty Key.');
|
|
399
|
+
}
|
|
400
|
+
if (still.MimeType !== 'image/png' && still.MimeType !== 'image/jpeg') {
|
|
401
|
+
throw new RenderWorkerError('InvalidRequest', `Still "${still.Key}" has invalid MimeType "${String(still.MimeType)}"; must be "image/png" or "image/jpeg".`);
|
|
402
|
+
}
|
|
403
|
+
ValidateStrictBase64(still.ImageBase64, `Stills[${still.Key}].ImageBase64`, false, 'InvalidRequest');
|
|
404
|
+
}
|
|
405
|
+
const gridChunks = chunkArray(req.Stills, maxTilesPerSheet);
|
|
406
|
+
if (gridChunks.length > MAX_CONTACT_SHEET_GRIDS) {
|
|
407
|
+
throw new RenderWorkerError('TooManyStills', `ContactSheetRequest produces ${gridChunks.length} grid sheets, exceeding maximum (${MAX_CONTACT_SHEET_GRIDS}).`);
|
|
408
|
+
}
|
|
409
|
+
const sheets = [];
|
|
410
|
+
for (let i = 0; i < gridChunks.length; i++) {
|
|
411
|
+
const chunk = gridChunks[i];
|
|
412
|
+
const sheet = await renderContactSheetHtmlToImage({
|
|
413
|
+
Format: req.Format,
|
|
414
|
+
Index: i,
|
|
415
|
+
Kind: 'Grid',
|
|
416
|
+
Columns: columns,
|
|
417
|
+
Stills: chunk,
|
|
418
|
+
}, backend, signal, options?.TempRoot);
|
|
419
|
+
sheets.push(sheet);
|
|
420
|
+
}
|
|
421
|
+
if (req.IncludeTransitionStrip) {
|
|
422
|
+
const transitionStills = selectTransitionPairs(req.Stills);
|
|
423
|
+
if (transitionStills.length > 0) {
|
|
424
|
+
const stripSheet = await renderContactSheetHtmlToImage({
|
|
425
|
+
Format: req.Format,
|
|
426
|
+
Index: sheets.length,
|
|
427
|
+
Kind: 'TransitionStrip',
|
|
428
|
+
Columns: 2,
|
|
429
|
+
Stills: transitionStills,
|
|
430
|
+
}, backend, signal, options?.TempRoot);
|
|
431
|
+
sheets.push(stripSheet);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return {
|
|
435
|
+
Format: req.Format,
|
|
436
|
+
Sheets: sheets,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Executes a `/keyframes` job: writes the base64 MP4 to a temp file, probes duration, extracts
|
|
441
|
+
* up to `MaxFrames` (`<= 24`, default `16`) scene-change + uniform-sample keyframes via
|
|
442
|
+
* `backend.ExtractKeyframes`, and optionally builds contact sheets when `BuildContactSheet` is true.
|
|
443
|
+
*/
|
|
444
|
+
export async function RunKeyframesJob(req, backend, signal, options) {
|
|
445
|
+
if (!req || typeof req !== 'object' || typeof req.VideoBase64 !== 'string' || !req.VideoBase64.trim()) {
|
|
446
|
+
throw new RenderWorkerError('InvalidRequest', 'KeyframeRequest.VideoBase64 is required.');
|
|
447
|
+
}
|
|
448
|
+
const videoBytes = ValidateStrictBase64(req.VideoBase64, 'KeyframeRequest.VideoBase64', false, 'InvalidRequest');
|
|
449
|
+
const maxFrames = req.MaxFrames ?? 16;
|
|
450
|
+
if (!Number.isInteger(maxFrames) || maxFrames < 1 || maxFrames > 24) {
|
|
451
|
+
throw new RenderWorkerError('InvalidRequest', 'KeyframeRequest.MaxFrames must be an integer in 1..24.');
|
|
452
|
+
}
|
|
453
|
+
const tempBase = options?.TempRoot ?? os.tmpdir();
|
|
454
|
+
await fs.mkdir(tempBase, { recursive: true });
|
|
455
|
+
const tempDir = await fs.mkdtemp(path.join(tempBase, 'mj-video-keyframes-'));
|
|
456
|
+
try {
|
|
457
|
+
const videoPath = path.join(tempDir, 'input.mp4');
|
|
458
|
+
await fs.writeFile(videoPath, videoBytes);
|
|
459
|
+
const probe = await backend.Probe(videoPath);
|
|
460
|
+
const frames = await backend.ExtractKeyframes(videoPath, path.join(tempDir, 'frames'), maxFrames, signal);
|
|
461
|
+
let sheets;
|
|
462
|
+
if (req.BuildContactSheet && frames.length > 0) {
|
|
463
|
+
const first = frames[0];
|
|
464
|
+
const inferredFormat = inferFormatFromDimensions(first.Width, first.Height);
|
|
465
|
+
const syntheticStills = frames.map((f) => ({
|
|
466
|
+
Key: `kf-${f.Index}`,
|
|
467
|
+
TimestampSeconds: f.TimestampSeconds,
|
|
468
|
+
Role: 'Custom',
|
|
469
|
+
Label: f.Reason,
|
|
470
|
+
MimeType: f.MimeType,
|
|
471
|
+
Width: f.Width,
|
|
472
|
+
Height: f.Height,
|
|
473
|
+
ImageBase64: f.ImageBase64,
|
|
474
|
+
}));
|
|
475
|
+
const sheetRes = await RunContactSheetJob({ Format: inferredFormat, Stills: syntheticStills }, backend, signal, options);
|
|
476
|
+
sheets = sheetRes.Sheets;
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
DurationSeconds: probe.DurationSeconds,
|
|
480
|
+
Frames: frames,
|
|
481
|
+
...(sheets ? { Sheets: sheets } : {}),
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
finally {
|
|
485
|
+
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Queries the render backend health status (`NodeVersion`, `HyperFramesVersion`, `ChromiumVersion`, `FfmpegVersion`).
|
|
490
|
+
*/
|
|
491
|
+
export async function GetWorkerHealth(backend) {
|
|
492
|
+
try {
|
|
493
|
+
return await backend.Health();
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
return {
|
|
497
|
+
Healthy: false,
|
|
498
|
+
NodeVersion: process.version,
|
|
499
|
+
HyperFramesVersion: '',
|
|
500
|
+
Errors: [error instanceof Error ? error.message : String(error)],
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
async function renderContactSheetHtmlToImage(spec, backend, signal, tempRoot) {
|
|
505
|
+
const { width, height } = computeContactSheetDimensions(spec.Format, spec.Columns, spec.Stills.length);
|
|
506
|
+
const html = buildContactSheetHtml(spec, width, height);
|
|
507
|
+
const syntheticBundle = {
|
|
508
|
+
Format: '16:9',
|
|
509
|
+
EntryFile: 'index.html',
|
|
510
|
+
Files: [{ Path: 'index.html', Content: html }],
|
|
511
|
+
Assets: [],
|
|
512
|
+
DataSnapshot: {},
|
|
513
|
+
Seed: 1,
|
|
514
|
+
};
|
|
515
|
+
const staged = await StageRenderBundle(syntheticBundle, {
|
|
516
|
+
TempRoot: tempRoot,
|
|
517
|
+
Geometry: { Width: width, Height: height, Fps: 30, DurationSeconds: 1 },
|
|
518
|
+
});
|
|
519
|
+
try {
|
|
520
|
+
const buffers = await backend.CaptureStills({
|
|
521
|
+
RootDir: staged.RootDir,
|
|
522
|
+
EntryFile: 'index.html',
|
|
523
|
+
Width: width,
|
|
524
|
+
Height: height,
|
|
525
|
+
Fps: 30,
|
|
526
|
+
TimestampsSeconds: [0],
|
|
527
|
+
ImageFormat: 'png',
|
|
528
|
+
}, signal);
|
|
529
|
+
const pngBytes = buffers[0] ?? new Uint8Array();
|
|
530
|
+
return {
|
|
531
|
+
Index: spec.Index,
|
|
532
|
+
Kind: spec.Kind,
|
|
533
|
+
MimeType: 'image/png',
|
|
534
|
+
Width: width,
|
|
535
|
+
Height: height,
|
|
536
|
+
StillKeys: spec.Stills.map((s) => s.Key),
|
|
537
|
+
ImageBase64: Buffer.from(pngBytes).toString('base64'),
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
finally {
|
|
541
|
+
await staged.Cleanup();
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
function computeContactSheetDimensions(format, columns, stillCount) {
|
|
545
|
+
const width = CONTACT_SHEET_WIDTH;
|
|
546
|
+
const rows = Math.max(1, Math.ceil(stillCount / columns));
|
|
547
|
+
const tileWidth = Math.floor((width - (columns + 1) * 16) / columns);
|
|
548
|
+
const aspect = format === '9:16' ? 9 / 16 : format === '1:1' ? 1 : 16 / 9;
|
|
549
|
+
const tileImageHeight = Math.round(tileWidth / aspect);
|
|
550
|
+
const tileTotalHeight = tileImageHeight + 44;
|
|
551
|
+
const height = Math.max(256, rows * tileTotalHeight + (rows + 1) * 16 + 48);
|
|
552
|
+
return { width, height };
|
|
553
|
+
}
|
|
554
|
+
function buildContactSheetHtml(spec, width, height) {
|
|
555
|
+
const tilesHtml = spec.Stills.map((still) => {
|
|
556
|
+
const timeStr = formatTimestampMmSs(still.TimestampSeconds);
|
|
557
|
+
const beatPart = still.BeatKey ? ` | beat:${escapeHtml(still.BeatKey)}` : '';
|
|
558
|
+
const rolePart = ` | ${escapeHtml(still.Role)}`;
|
|
559
|
+
const labelPart = still.Label ? ` | ${escapeHtml(still.Label)}` : '';
|
|
560
|
+
const caption = `${escapeHtml(timeStr)}${beatPart}${rolePart} | ${escapeHtml(spec.Format)}${labelPart}`;
|
|
561
|
+
const dataUri = `data:${still.MimeType};base64,${still.ImageBase64}`;
|
|
562
|
+
return [
|
|
563
|
+
`<div class="tile" data-still-key="${escapeHtml(still.Key)}">`,
|
|
564
|
+
` <img src="${dataUri}" alt="${escapeHtml(still.Key)}" />`,
|
|
565
|
+
` <div class="caption">${caption}</div>`,
|
|
566
|
+
`</div>`,
|
|
567
|
+
].join('\n');
|
|
568
|
+
}).join('\n');
|
|
569
|
+
return [
|
|
570
|
+
'<!doctype html>',
|
|
571
|
+
'<html>',
|
|
572
|
+
'<head>',
|
|
573
|
+
'<meta charset="utf-8" />',
|
|
574
|
+
'<style>',
|
|
575
|
+
` * { box-sizing: border-box; margin: 0; padding: 0; }`,
|
|
576
|
+
` body { width: ${width}px; height: ${height}px; background: #0f172a; color: #f8fafc; font-family: 'MJ Sans'; padding: 16px; }`,
|
|
577
|
+
` .header { font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #94a3b8; }`,
|
|
578
|
+
` .grid { display: grid; grid-template-columns: repeat(${spec.Columns}, 1fr); gap: 16px; }`,
|
|
579
|
+
` .tile { background: #1e293b; border: 1px solid #334155; border-radius: 6px; overflow: hidden; }`,
|
|
580
|
+
` .tile img { display: block; width: 100%; height: auto; }`,
|
|
581
|
+
` .caption { padding: 8px 10px; font-size: 12px; line-height: 1.3; color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }`,
|
|
582
|
+
'</style>',
|
|
583
|
+
'</head>',
|
|
584
|
+
'<body>',
|
|
585
|
+
` <div id="root" data-composition-id="contact-sheet" data-duration="1" data-fps="30" data-width="${width}" data-height="${height}" data-no-timeline>`,
|
|
586
|
+
` <div class="header">${escapeHtml(spec.Kind)} #${spec.Index + 1} (${escapeHtml(spec.Format)})</div>`,
|
|
587
|
+
` <div class="grid">${tilesHtml}</div>`,
|
|
588
|
+
' </div>',
|
|
589
|
+
'</body>',
|
|
590
|
+
'</html>',
|
|
591
|
+
].join('\n');
|
|
592
|
+
}
|
|
593
|
+
function selectTransitionPairs(stills) {
|
|
594
|
+
const result = [];
|
|
595
|
+
for (let i = 0; i < stills.length; i++) {
|
|
596
|
+
const current = stills[i];
|
|
597
|
+
if (current.Role === 'TransitionStart' || current.Role === 'TransitionEnd') {
|
|
598
|
+
result.push(current);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return result;
|
|
602
|
+
}
|
|
603
|
+
function defaultColumnsForFormat(format) {
|
|
604
|
+
switch (format) {
|
|
605
|
+
case '9:16':
|
|
606
|
+
return 5;
|
|
607
|
+
case '1:1':
|
|
608
|
+
return 4;
|
|
609
|
+
case '16:9':
|
|
610
|
+
default:
|
|
611
|
+
return 3;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
function inferFormatFromDimensions(width, height) {
|
|
615
|
+
if (height > width * 1.2) {
|
|
616
|
+
return '9:16';
|
|
617
|
+
}
|
|
618
|
+
if (Math.abs(width - height) <= Math.max(width, height) * 0.1) {
|
|
619
|
+
return '1:1';
|
|
620
|
+
}
|
|
621
|
+
return '16:9';
|
|
622
|
+
}
|
|
623
|
+
function formatTimestampMmSs(seconds) {
|
|
624
|
+
const safe = Math.max(0, seconds);
|
|
625
|
+
const mins = Math.floor(safe / 60);
|
|
626
|
+
const secs = safe - mins * 60;
|
|
627
|
+
return `${String(mins).padStart(2, '0')}:${secs.toFixed(2).padStart(5, '0')}`;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Escapes HTML special characters (`&`, `<`, `>`, `"`, `'`) in contact sheet labels.
|
|
631
|
+
*/
|
|
632
|
+
export function EscapeHtmlForLabel(text) {
|
|
633
|
+
return escapeHtml(text);
|
|
634
|
+
}
|
|
635
|
+
function escapeHtml(text) {
|
|
636
|
+
return text
|
|
637
|
+
.replace(/&/g, '&')
|
|
638
|
+
.replace(/</g, '<')
|
|
639
|
+
.replace(/>/g, '>')
|
|
640
|
+
.replace(/"/g, '"')
|
|
641
|
+
.replace(/'/g, ''');
|
|
642
|
+
}
|
|
643
|
+
function chunkArray(items, chunkSize) {
|
|
644
|
+
const chunks = [];
|
|
645
|
+
for (let i = 0; i < items.length; i += chunkSize) {
|
|
646
|
+
chunks.push(items.slice(i, i + chunkSize));
|
|
647
|
+
}
|
|
648
|
+
return chunks;
|
|
649
|
+
}
|
|
650
|
+
//# sourceMappingURL=jobs.js.map
|