voxflow 1.15.2 → 1.15.3
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/dist/index.js +1 -1
- package/lib/commands/slice-stage.js +63 -0
- package/lib/stage-core/local-render.js +268 -0
- package/lib/stage-core/server.js +171 -0
- package/lib/stage-ui/slice/template.js +489 -7
- package/package.json +1 -1
|
@@ -19,8 +19,11 @@ const { watchDeckFile } = require('../stage-core/watcher');
|
|
|
19
19
|
const { createEventBus } = require('../stage-core/event-bus');
|
|
20
20
|
const { createSnapshotStore } = require('../stage-core/snapshot-store');
|
|
21
21
|
const { createCloudRenderClient } = require('../stage-core/cloud-render');
|
|
22
|
+
const { startLocalRender, getJobStatus } = require('../stage-core/local-render');
|
|
23
|
+
const { validatePaperSlideDeck, isV2LayoutTreeDeck } = require('../internal/deck-validator');
|
|
22
24
|
const { renderSliceStageHtml } = require('../stage-ui/slice/template');
|
|
23
25
|
const { emit: emitTelemetry } = require('../core/telemetry');
|
|
26
|
+
const { readCachedToken } = require('../core/auth');
|
|
24
27
|
|
|
25
28
|
// Sourced from the canonical registry at repo root. Previously this list
|
|
26
29
|
// silently fell out of sync (lagged at 6 themes while the rest of the repo
|
|
@@ -137,12 +140,72 @@ async function startSliceStage(opts) {
|
|
|
137
140
|
// user logged into; the page never sees the JWT.
|
|
138
141
|
const cloudRender = opts.cloudRender || createCloudRenderClient();
|
|
139
142
|
|
|
143
|
+
// ─── Local render bridge ───────────────────────────────────────────────
|
|
144
|
+
// Wires @remotion/renderer into stage so the page can produce mp4 without
|
|
145
|
+
// touching the cloud. The bridge captures the deck snapshot at submit
|
|
146
|
+
// time and proxies progress events through the existing SSE bus.
|
|
147
|
+
const localRenderBridge = opts.localRender || {
|
|
148
|
+
start({ output, onProgress, onDone, onError }) {
|
|
149
|
+
if (!snapshot.deck) {
|
|
150
|
+
const err = new Error('No deck loaded — fix the JSON parse error first.');
|
|
151
|
+
err.code = 'invalid_deck';
|
|
152
|
+
throw err;
|
|
153
|
+
}
|
|
154
|
+
return startLocalRender({
|
|
155
|
+
deck: snapshot.deck,
|
|
156
|
+
deckPath,
|
|
157
|
+
output,
|
|
158
|
+
onProgress,
|
|
159
|
+
onDone,
|
|
160
|
+
onError,
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
status: (jobId) => getJobStatus(jobId),
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// ─── Inline deck save bridge (Task B) ──────────────────────────────────
|
|
167
|
+
// Validates the incoming deck against the same schema the cloud route
|
|
168
|
+
// uses, then writes atomically (tmp → rename). The watcher already
|
|
169
|
+
// broadcasts the resulting change to SSE — no extra fan-out needed.
|
|
170
|
+
const deckSaverBridge = opts.deckSaver || {
|
|
171
|
+
save(deck) {
|
|
172
|
+
if (isV2LayoutTreeDeck(deck)) {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
message: 'V2 LayoutTree decks are not supported for inline edit yet — edit deck.json directly.',
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
validatePaperSlideDeck(deck);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
return { ok: false, message: err.message, errors: [err.message] };
|
|
182
|
+
}
|
|
183
|
+
const tmp = `${deckPath}.tmp-${process.pid}-${Date.now()}`;
|
|
184
|
+
const out = JSON.stringify(deck, null, 2) + '\n';
|
|
185
|
+
fs.writeFileSync(tmp, out, 'utf8');
|
|
186
|
+
fs.renameSync(tmp, deckPath);
|
|
187
|
+
return { ok: true };
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// Boot-time auth probe so the UI can emphasise local vs cloud render.
|
|
192
|
+
// We treat any cached, non-expired token as "logged in"; the actual
|
|
193
|
+
// request flow still revalidates on /api/quota-balance.
|
|
194
|
+
const tokenAvailable = (() => {
|
|
195
|
+
if (typeof opts.tokenAvailable === 'boolean') return opts.tokenAvailable;
|
|
196
|
+
try { return !!readCachedToken(); } catch { return false; }
|
|
197
|
+
})();
|
|
198
|
+
|
|
140
199
|
const handle = await startStageServer({
|
|
141
200
|
uiHtml: initialHtml,
|
|
142
201
|
getDeckSnapshot: () => snapshot,
|
|
143
202
|
subscribe: bus.subscribe,
|
|
144
203
|
snapshots: snapshotsBridge,
|
|
145
204
|
cloudRender,
|
|
205
|
+
localRender: localRenderBridge,
|
|
206
|
+
deckSaver: deckSaverBridge,
|
|
207
|
+
publishEvent: bus.publish,
|
|
208
|
+
tokenAvailable,
|
|
146
209
|
preferredPort,
|
|
147
210
|
maxPortTries,
|
|
148
211
|
});
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stage → @remotion/renderer bridge for the *offline* Slice renderer.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: stage's existing "Render mp4" button proxies to the cloud
|
|
7
|
+
* backend (Fly render-worker), which requires `voxflow login` and burns 500
|
|
8
|
+
* quota per call. The Phase 0 promise was offline-first authoring — if a
|
|
9
|
+
* skill-driven user never logged in, the only render path on the stage page
|
|
10
|
+
* was broken. This module wires the same `@remotion/renderer` flow that
|
|
11
|
+
* `voxflow slice render` already uses (see `slice-render.js`) into stage,
|
|
12
|
+
* so the page can produce an mp4 against the pre-bundled Remotion serveUrl
|
|
13
|
+
* at `cli/dist/remotion-bundle/` with no network round-trip.
|
|
14
|
+
*
|
|
15
|
+
* Public surface:
|
|
16
|
+
* startLocalRender({ deck, output, onProgress, onDone, onError })
|
|
17
|
+
* → { jobId } — non-blocking, returns immediately
|
|
18
|
+
* getJobStatus(jobId) → { state, progress, framesRendered,
|
|
19
|
+
* framesTotal, outputPath, error }
|
|
20
|
+
*
|
|
21
|
+
* State machine per job:
|
|
22
|
+
* pending → preparing → rendering → completed
|
|
23
|
+
* └→ failed
|
|
24
|
+
*
|
|
25
|
+
* Concurrency: jobs run sequentially on the stage server's event loop —
|
|
26
|
+
* `@remotion/renderer` spawns headless Chromium in subprocess workers and is
|
|
27
|
+
* CPU-bound, so parallel renders would thrash. The stage server is for
|
|
28
|
+
* single-author iteration; queueing is acceptable.
|
|
29
|
+
*
|
|
30
|
+
* Reuses helpers from slice-render.js (single source of truth):
|
|
31
|
+
* - buildInputProps — validator deck → PaperSlideDeckProps
|
|
32
|
+
* - resolveServeUrl — find dist/remotion-bundle in dev + ncc layouts
|
|
33
|
+
* - chromeBinaryExists — detect first-run cold start (so we can warn)
|
|
34
|
+
* - THEME_TO_DECK_ID — slice theme id → Remotion composition id
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const crypto = require('crypto');
|
|
38
|
+
const fs = require('fs');
|
|
39
|
+
const path = require('path');
|
|
40
|
+
|
|
41
|
+
const {
|
|
42
|
+
buildInputProps,
|
|
43
|
+
resolveServeUrl,
|
|
44
|
+
chromeBinaryExists,
|
|
45
|
+
THEME_TO_DECK_ID,
|
|
46
|
+
DEFAULT_THEME,
|
|
47
|
+
} = require('../commands/slice-render');
|
|
48
|
+
|
|
49
|
+
// In-memory job table. We never persist jobs — a stage restart wipes history,
|
|
50
|
+
// which is fine because the produced mp4 lives on disk under the user's deck
|
|
51
|
+
// directory. The page only knows about jobs while it's open.
|
|
52
|
+
const jobs = new Map();
|
|
53
|
+
|
|
54
|
+
function makeJobId() {
|
|
55
|
+
// crypto.randomUUID is Node 19+; the cli's engines field requires 20+, so
|
|
56
|
+
// it's always available. Tests can stub the module to inject deterministic
|
|
57
|
+
// ids if needed (see local-render.test.js).
|
|
58
|
+
return crypto.randomUUID();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Default output path: sibling of the deck file, same basename, `.mp4` ext.
|
|
63
|
+
* /tmp/.../my-deck.json → /tmp/.../my-deck.mp4
|
|
64
|
+
*
|
|
65
|
+
* Falls back to `out.mp4` if the deck wasn't loaded from disk (deck-in-memory
|
|
66
|
+
* is hypothetical at the moment; current stage always has a deckPath, but we
|
|
67
|
+
* future-proof for an MCP-piped variant).
|
|
68
|
+
*/
|
|
69
|
+
function defaultOutputPath(deckPath) {
|
|
70
|
+
if (deckPath && typeof deckPath === 'string') {
|
|
71
|
+
const dir = path.dirname(deckPath);
|
|
72
|
+
const base = path.basename(deckPath, path.extname(deckPath));
|
|
73
|
+
return path.join(dir, `${base}.mp4`);
|
|
74
|
+
}
|
|
75
|
+
return path.resolve(process.cwd(), 'out.mp4');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Lazy-load @remotion/renderer. Doing this at module load would force every
|
|
80
|
+
* stage boot to pay the ~30 MB resolver cost; we defer until the first job.
|
|
81
|
+
* The renderer is a peer of slice-render.js (same version) so the bundle
|
|
82
|
+
* deduplicates in node_modules.
|
|
83
|
+
*/
|
|
84
|
+
function loadRenderer() {
|
|
85
|
+
return require('@remotion/renderer');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Start an offline Remotion render against the pre-bundled serveUrl.
|
|
90
|
+
*
|
|
91
|
+
* @param {object} opts
|
|
92
|
+
* @param {object} opts.deck Validator-shaped deck (cards + theme).
|
|
93
|
+
* @param {string} [opts.deckPath] For default output path inference.
|
|
94
|
+
* @param {string} [opts.output] Optional explicit output path.
|
|
95
|
+
* @param {(p:object) => void} [opts.onProgress]
|
|
96
|
+
* Called on every Remotion progress tick. Payload:
|
|
97
|
+
* { jobId, progress (0..1), framesRendered, framesTotal }
|
|
98
|
+
* @param {(d:object) => void} [opts.onDone]
|
|
99
|
+
* Called with { jobId, outputPath, totalMs, sizeBytes } when the mp4 is written.
|
|
100
|
+
* @param {(e:object) => void} [opts.onError]
|
|
101
|
+
* Called with { jobId, message } on any failure (synchronous or async).
|
|
102
|
+
* @returns {{ jobId:string, outputPath:string }}
|
|
103
|
+
* Returned synchronously so the HTTP route can respond immediately while
|
|
104
|
+
* the render proceeds in the background.
|
|
105
|
+
*/
|
|
106
|
+
function startLocalRender(opts) {
|
|
107
|
+
const deck = opts && opts.deck;
|
|
108
|
+
if (!deck || !Array.isArray(deck.cards)) {
|
|
109
|
+
const err = new Error('startLocalRender: deck.cards required');
|
|
110
|
+
err.code = 'invalid_deck';
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
const theme = deck.theme || DEFAULT_THEME;
|
|
114
|
+
const deckId = THEME_TO_DECK_ID[theme];
|
|
115
|
+
if (!deckId) {
|
|
116
|
+
const err = new Error(`Unknown theme "${theme}". Valid: ${Object.keys(THEME_TO_DECK_ID).join(', ')}`);
|
|
117
|
+
err.code = 'invalid_theme';
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const outputPath = path.resolve(
|
|
122
|
+
opts.output || defaultOutputPath(opts.deckPath)
|
|
123
|
+
);
|
|
124
|
+
const outputDir = path.dirname(outputPath);
|
|
125
|
+
if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
|
|
126
|
+
|
|
127
|
+
const jobId = makeJobId();
|
|
128
|
+
const job = {
|
|
129
|
+
jobId,
|
|
130
|
+
state: 'pending',
|
|
131
|
+
progress: 0,
|
|
132
|
+
framesRendered: 0,
|
|
133
|
+
framesTotal: 0,
|
|
134
|
+
outputPath,
|
|
135
|
+
theme,
|
|
136
|
+
deckId,
|
|
137
|
+
error: null,
|
|
138
|
+
startedAt: Date.now(),
|
|
139
|
+
finishedAt: null,
|
|
140
|
+
sizeBytes: null,
|
|
141
|
+
};
|
|
142
|
+
jobs.set(jobId, job);
|
|
143
|
+
|
|
144
|
+
// Kick off the actual work on the next tick so the caller's POST handler
|
|
145
|
+
// can flush the response with the jobId before any renderer init starts.
|
|
146
|
+
setImmediate(() => {
|
|
147
|
+
runRender({
|
|
148
|
+
job,
|
|
149
|
+
deck,
|
|
150
|
+
onProgress: opts.onProgress,
|
|
151
|
+
onDone: opts.onDone,
|
|
152
|
+
onError: opts.onError,
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
return { jobId, outputPath };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function runRender({ job, deck, onProgress, onDone, onError }) {
|
|
160
|
+
const { jobId, outputPath, deckId } = job;
|
|
161
|
+
try {
|
|
162
|
+
job.state = 'preparing';
|
|
163
|
+
const serveUrl = resolveServeUrl();
|
|
164
|
+
const inputProps = buildInputProps(deck);
|
|
165
|
+
|
|
166
|
+
const renderer = loadRenderer();
|
|
167
|
+
job.coldStart = !chromeBinaryExists();
|
|
168
|
+
|
|
169
|
+
const composition = await renderer.selectComposition({
|
|
170
|
+
serveUrl,
|
|
171
|
+
id: deckId,
|
|
172
|
+
inputProps,
|
|
173
|
+
});
|
|
174
|
+
job.framesTotal = composition.durationInFrames;
|
|
175
|
+
job.state = 'rendering';
|
|
176
|
+
if (typeof onProgress === 'function') {
|
|
177
|
+
try {
|
|
178
|
+
onProgress({
|
|
179
|
+
jobId,
|
|
180
|
+
progress: 0,
|
|
181
|
+
framesRendered: 0,
|
|
182
|
+
framesTotal: job.framesTotal,
|
|
183
|
+
});
|
|
184
|
+
} catch { /* swallow consumer errors */ }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
await renderer.renderMedia({
|
|
188
|
+
composition,
|
|
189
|
+
serveUrl,
|
|
190
|
+
codec: 'h264',
|
|
191
|
+
outputLocation: outputPath,
|
|
192
|
+
inputProps,
|
|
193
|
+
imageFormat: 'jpeg',
|
|
194
|
+
jpegQuality: 85,
|
|
195
|
+
concurrency: 4,
|
|
196
|
+
x264Preset: 'ultrafast',
|
|
197
|
+
chromiumOptions: { gl: 'swiftshader' },
|
|
198
|
+
offthreadVideoCacheSizeInBytes: 512 * 1024 * 1024,
|
|
199
|
+
onProgress: ({ progress, renderedFrames }) => {
|
|
200
|
+
job.progress = progress;
|
|
201
|
+
job.framesRendered = renderedFrames;
|
|
202
|
+
if (typeof onProgress === 'function') {
|
|
203
|
+
try {
|
|
204
|
+
onProgress({
|
|
205
|
+
jobId,
|
|
206
|
+
progress,
|
|
207
|
+
framesRendered: renderedFrames,
|
|
208
|
+
framesTotal: job.framesTotal,
|
|
209
|
+
});
|
|
210
|
+
} catch { /* swallow */ }
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const stat = fs.statSync(outputPath);
|
|
216
|
+
job.state = 'completed';
|
|
217
|
+
job.progress = 1;
|
|
218
|
+
job.sizeBytes = stat.size;
|
|
219
|
+
job.finishedAt = Date.now();
|
|
220
|
+
if (typeof onDone === 'function') {
|
|
221
|
+
try {
|
|
222
|
+
onDone({
|
|
223
|
+
jobId,
|
|
224
|
+
outputPath,
|
|
225
|
+
totalMs: job.finishedAt - job.startedAt,
|
|
226
|
+
sizeBytes: job.sizeBytes,
|
|
227
|
+
});
|
|
228
|
+
} catch { /* swallow */ }
|
|
229
|
+
}
|
|
230
|
+
} catch (err) {
|
|
231
|
+
job.state = 'failed';
|
|
232
|
+
job.error = err && err.message ? err.message : String(err);
|
|
233
|
+
job.finishedAt = Date.now();
|
|
234
|
+
if (typeof onError === 'function') {
|
|
235
|
+
try { onError({ jobId, message: job.error }); } catch { /* swallow */ }
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function getJobStatus(jobId) {
|
|
241
|
+
const job = jobs.get(jobId);
|
|
242
|
+
if (!job) return null;
|
|
243
|
+
return {
|
|
244
|
+
jobId: job.jobId,
|
|
245
|
+
state: job.state,
|
|
246
|
+
progress: job.progress,
|
|
247
|
+
framesRendered: job.framesRendered,
|
|
248
|
+
framesTotal: job.framesTotal,
|
|
249
|
+
outputPath: job.outputPath,
|
|
250
|
+
error: job.error,
|
|
251
|
+
sizeBytes: job.sizeBytes,
|
|
252
|
+
startedAt: job.startedAt,
|
|
253
|
+
finishedAt: job.finishedAt,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Test-only: nuke the job table between cases. Not exposed in module exports
|
|
258
|
+
* publicly to discourage callers from clearing state in production paths. */
|
|
259
|
+
function _resetJobsForTests() {
|
|
260
|
+
jobs.clear();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
module.exports = {
|
|
264
|
+
startLocalRender,
|
|
265
|
+
getJobStatus,
|
|
266
|
+
defaultOutputPath,
|
|
267
|
+
_resetJobsForTests,
|
|
268
|
+
};
|
package/lib/stage-core/server.js
CHANGED
|
@@ -32,8 +32,28 @@ const { findAvailablePort } = require('./port');
|
|
|
32
32
|
* and GET /api/quota-balance — all proxies to the upstream API. Shape:
|
|
33
33
|
* { submit(deck), status(jobId), quota() }. Lets users go from "deck I
|
|
34
34
|
* like" → mp4 without leaving stage; the proxy keeps the JWT off the page.
|
|
35
|
+
* @param {object} [opts.localRender] Optional local-render handle.
|
|
36
|
+
* If provided, exposes POST /api/render-local and GET /api/render-local/:id/status.
|
|
37
|
+
* Shape: { start({onProgress, onDone, onError, output}), status(jobId) }.
|
|
38
|
+
* Used to render mp4 with `@remotion/renderer` *without* any cloud
|
|
39
|
+
* round-trip — required for offline-first authoring (skill-driven users
|
|
40
|
+
* may never have run `voxflow login`). Reuses the same SSE channel
|
|
41
|
+
* `subscribe` feeds for progress fan-out.
|
|
42
|
+
* @param {object} [opts.deckSaver] Optional inline-edit handle.
|
|
43
|
+
* If provided, exposes POST /api/deck → write new deck JSON to disk.
|
|
44
|
+
* Shape: { save(deck) → { ok:true } | { ok:false, errors:[...] } }.
|
|
45
|
+
* The file watcher picks up the resulting change and broadcasts deck SSE,
|
|
46
|
+
* so no extra fan-out is needed here.
|
|
47
|
+
* @param {(event:object) => void} [opts.publishEvent]
|
|
48
|
+
* Optional event-bus publisher — when provided, local-render progress
|
|
49
|
+
* events are fanned out through the same `subscribe` channel as deck
|
|
50
|
+
* events, so the page can listen on one EventSource for everything.
|
|
35
51
|
* @param {number} [opts.preferredPort=5180]
|
|
36
52
|
* @param {number} [opts.maxPortTries=10]
|
|
53
|
+
* @param {boolean} [opts.tokenAvailable]
|
|
54
|
+
* Optional flag baked into the boot snapshot returned by GET /api/auth-state.
|
|
55
|
+
* The page reads this once on load to decide whether to emphasise the
|
|
56
|
+
* "local" or "cloud" render button. Cheap; no JWT ever leaves the server.
|
|
37
57
|
* @returns {Promise<{server:http.Server, port:number, url:string, close:() => Promise<void>}>}
|
|
38
58
|
*/
|
|
39
59
|
async function startStageServer(opts) {
|
|
@@ -43,6 +63,10 @@ async function startStageServer(opts) {
|
|
|
43
63
|
subscribe,
|
|
44
64
|
snapshots = null,
|
|
45
65
|
cloudRender = null,
|
|
66
|
+
localRender = null,
|
|
67
|
+
deckSaver = null,
|
|
68
|
+
publishEvent = null,
|
|
69
|
+
tokenAvailable = false,
|
|
46
70
|
preferredPort = 5180,
|
|
47
71
|
maxPortTries = 10,
|
|
48
72
|
} = opts;
|
|
@@ -273,6 +297,153 @@ async function startStageServer(opts) {
|
|
|
273
297
|
return;
|
|
274
298
|
}
|
|
275
299
|
|
|
300
|
+
// ─── Auth-state probe ───────────────────────────────────────────────────
|
|
301
|
+
// Single GET so the page can decide local-vs-cloud render emphasis on
|
|
302
|
+
// load. No JWT or refresh-token bytes ever leave the server.
|
|
303
|
+
if (req.method === 'GET' && req.url === '/api/auth-state') {
|
|
304
|
+
sendJson(200, { code: 'success', tokenAvailable: !!tokenAvailable });
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ─── Local render (offline, @remotion/renderer) ─────────────────────────
|
|
309
|
+
// POST /api/render-local → start a job, return { jobId }
|
|
310
|
+
// GET /api/render-local/:id/status → poll status
|
|
311
|
+
//
|
|
312
|
+
// Progress + done events are pushed onto the deck SSE channel via
|
|
313
|
+
// publishEvent so the page listens on a single EventSource.
|
|
314
|
+
if (localRender && req.method === 'POST' && req.url === '/api/render-local') {
|
|
315
|
+
const chunks = [];
|
|
316
|
+
let total = 0;
|
|
317
|
+
const MAX = 64 * 1024;
|
|
318
|
+
req.on('data', (c) => {
|
|
319
|
+
total += c.length;
|
|
320
|
+
if (total > MAX) { req.destroy(); return; }
|
|
321
|
+
chunks.push(c);
|
|
322
|
+
});
|
|
323
|
+
req.on('end', () => {
|
|
324
|
+
let body = {};
|
|
325
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
326
|
+
if (raw) {
|
|
327
|
+
try { body = JSON.parse(raw); }
|
|
328
|
+
catch { return sendJson(400, { code: 'bad_json', message: 'render-local body must be JSON' }); }
|
|
329
|
+
}
|
|
330
|
+
const output = typeof body.output === 'string' && body.output.trim()
|
|
331
|
+
? body.output.trim() : null;
|
|
332
|
+
|
|
333
|
+
let started;
|
|
334
|
+
try {
|
|
335
|
+
started = localRender.start({
|
|
336
|
+
output,
|
|
337
|
+
onProgress(p) {
|
|
338
|
+
if (typeof publishEvent === 'function') {
|
|
339
|
+
publishEvent({
|
|
340
|
+
type: 'render-local-progress',
|
|
341
|
+
jobId: p.jobId,
|
|
342
|
+
progress: p.progress,
|
|
343
|
+
framesRendered: p.framesRendered,
|
|
344
|
+
framesTotal: p.framesTotal,
|
|
345
|
+
ts: Date.now(),
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
onDone(d) {
|
|
350
|
+
if (typeof publishEvent === 'function') {
|
|
351
|
+
publishEvent({
|
|
352
|
+
type: 'render-local-done',
|
|
353
|
+
jobId: d.jobId,
|
|
354
|
+
outputPath: d.outputPath,
|
|
355
|
+
totalMs: d.totalMs,
|
|
356
|
+
sizeBytes: d.sizeBytes,
|
|
357
|
+
ts: Date.now(),
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
onError(e) {
|
|
362
|
+
if (typeof publishEvent === 'function') {
|
|
363
|
+
publishEvent({
|
|
364
|
+
type: 'render-local-error',
|
|
365
|
+
jobId: e.jobId,
|
|
366
|
+
message: e.message,
|
|
367
|
+
ts: Date.now(),
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
} catch (err) {
|
|
373
|
+
return sendJson(400, {
|
|
374
|
+
code: err.code || 'render_local_failed',
|
|
375
|
+
message: err.message || 'could not start local render',
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
return sendJson(200, {
|
|
379
|
+
code: 'success',
|
|
380
|
+
jobId: started.jobId,
|
|
381
|
+
outputPath: started.outputPath,
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (localRender && req.method === 'GET' && req.url.startsWith('/api/render-local/')) {
|
|
388
|
+
// expected: /api/render-local/:id/status
|
|
389
|
+
const rest = req.url.slice('/api/render-local/'.length);
|
|
390
|
+
const slashIdx = rest.indexOf('/');
|
|
391
|
+
const id = slashIdx === -1 ? rest : rest.slice(0, slashIdx);
|
|
392
|
+
const tail = slashIdx === -1 ? '' : rest.slice(slashIdx + 1);
|
|
393
|
+
if (!id || tail !== 'status') {
|
|
394
|
+
sendJson(404, { code: 'not_found', message: `No route: ${req.method} ${req.url}` });
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const status = localRender.status(decodeURIComponent(id));
|
|
398
|
+
if (!status) {
|
|
399
|
+
sendJson(404, { code: 'job_not_found', message: `No local-render job: ${id}` });
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
sendJson(200, { code: 'success', ...status });
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ─── Inline deck save (Task B) ──────────────────────────────────────────
|
|
407
|
+
// POST /api/deck body: full deck JSON → validates + writes to disk.
|
|
408
|
+
// The file watcher picks up the write and broadcasts the deck event, so
|
|
409
|
+
// the page hot-reloads via the same path as an external editor save.
|
|
410
|
+
if (deckSaver && req.method === 'POST' && req.url === '/api/deck') {
|
|
411
|
+
const chunks = [];
|
|
412
|
+
let total = 0;
|
|
413
|
+
// Decks are small (5-8 cards, ~5 KB). 256 KB cap is plenty headroom
|
|
414
|
+
// even with V2 layout-tree decks while still defending against pasted
|
|
415
|
+
// monsters.
|
|
416
|
+
const MAX = 256 * 1024;
|
|
417
|
+
req.on('data', (c) => {
|
|
418
|
+
total += c.length;
|
|
419
|
+
if (total > MAX) { req.destroy(); return; }
|
|
420
|
+
chunks.push(c);
|
|
421
|
+
});
|
|
422
|
+
req.on('end', () => {
|
|
423
|
+
let body;
|
|
424
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); }
|
|
425
|
+
catch { return sendJson(400, { code: 'bad_json', message: 'deck body must be JSON' }); }
|
|
426
|
+
const deck = body && body.deck;
|
|
427
|
+
if (!deck || typeof deck !== 'object') {
|
|
428
|
+
return sendJson(400, { code: 'invalid_deck', message: 'body.deck (object) required' });
|
|
429
|
+
}
|
|
430
|
+
let result;
|
|
431
|
+
try { result = deckSaver.save(deck); }
|
|
432
|
+
catch (err) {
|
|
433
|
+
return sendJson(500, { code: 'deck_save_failed', message: err.message || 'save error' });
|
|
434
|
+
}
|
|
435
|
+
if (!result || result.ok !== true) {
|
|
436
|
+
return sendJson(400, {
|
|
437
|
+
code: 'invalid_deck',
|
|
438
|
+
message: (result && result.message) || 'deck failed validation',
|
|
439
|
+
errors: (result && result.errors) || undefined,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
return sendJson(200, { code: 'success' });
|
|
443
|
+
});
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
276
447
|
if (req.method === 'GET' && req.url === '/events') {
|
|
277
448
|
res.writeHead(200, {
|
|
278
449
|
'Content-Type': 'text/event-stream; charset=utf-8',
|