@1agh/maude 0.58.2 → 0.58.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/apps/studio/annotations-bindings.ts +83 -4
- package/apps/studio/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +72 -0
- package/apps/studio/bin/_import-figma.mjs +1121 -0
- package/apps/studio/bin/_video-playwright.mjs +86 -3
- package/apps/studio/bin/import-figma.sh +38 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/client/app.jsx +44 -1
- package/apps/studio/client/export-center.jsx +42 -4
- package/apps/studio/client/panels/CloudBar.jsx +92 -1
- package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
- package/apps/studio/client/panels/GitPanel.jsx +26 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
- package/apps/studio/client/panels/TimelinePanel.jsx +2 -2
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +7 -0
- package/apps/studio/client/styles/4-components.css +134 -0
- package/apps/studio/clip-ops.ts +93 -17
- package/apps/studio/cloud/endpoints.ts +78 -10
- package/apps/studio/cloud/renew.ts +183 -0
- package/apps/studio/context.ts +2 -1
- package/apps/studio/dist/client.bundle.js +1261 -1261
- package/apps/studio/dist/runtime/@remotion_media.js +56 -136
- package/apps/studio/dist/runtime/@remotion_player.js +18 -18
- package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
- package/apps/studio/dist/runtime/remotion.js +12 -12
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +20 -6
- package/apps/studio/exporters/_runtime.ts +19 -0
- package/apps/studio/exporters/degraded.ts +92 -0
- package/apps/studio/exporters/index.ts +5 -0
- package/apps/studio/exporters/jobs.ts +19 -0
- package/apps/studio/exporters/unsupported-media.ts +170 -0
- package/apps/studio/exporters/video-encode-lib.ts +27 -1
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +62 -1
- package/apps/studio/figma/assets.test.ts +372 -0
- package/apps/studio/figma/assets.ts +398 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -0
- package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
- package/apps/studio/figma/comments-to-strokes.ts +173 -0
- package/apps/studio/figma/endpoints.ts +200 -0
- package/apps/studio/figma/sanitize.test.ts +256 -0
- package/apps/studio/figma/sanitize.ts +315 -0
- package/apps/studio/figma/style-map.ts +352 -0
- package/apps/studio/figma/to-artboard.test.ts +808 -0
- package/apps/studio/figma/to-artboard.ts +701 -0
- package/apps/studio/figma/to-render.test.ts +180 -0
- package/apps/studio/figma/to-render.ts +306 -0
- package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
- package/apps/studio/figma/to-strokes.test.ts +705 -0
- package/apps/studio/figma/to-strokes.ts +749 -0
- package/apps/studio/figma/to-tokens.test.ts +321 -0
- package/apps/studio/figma/to-tokens.ts +305 -0
- package/apps/studio/figma/types.ts +539 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +129 -0
- package/apps/studio/sync/asset-push.ts +124 -0
- package/apps/studio/sync/connection-state.ts +11 -0
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +276 -26
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/supervisor.ts +5 -1
- package/apps/studio/sync/workspace-signin.ts +7 -3
- package/apps/studio/test/annotations-bindings.test.ts +150 -12
- package/apps/studio/test/canvas-create-api.test.ts +4 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
- package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
- package/apps/studio/test/clip-addressing.test.ts +6 -1
- package/apps/studio/test/clip-ops.test.ts +5 -1
- package/apps/studio/test/cloud-endpoints.test.ts +96 -0
- package/apps/studio/test/cloud-renew.test.ts +205 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
- package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
- package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
- package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
- package/apps/studio/test/figma-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -0
- package/apps/studio/test/git-cloud-posture.test.ts +50 -0
- package/apps/studio/test/hub-link.test.ts +11 -0
- package/apps/studio/test/import-figma.test.ts +479 -0
- package/apps/studio/test/sync-asset-push.test.ts +124 -0
- package/apps/studio/test/sync-connection-state.test.ts +13 -0
- package/apps/studio/test/sync-hubs-config.test.ts +5 -0
- package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
- package/apps/studio/test/sync-path-pull.test.ts +63 -0
- package/apps/studio/test/sync-presentation.test.ts +77 -0
- package/apps/studio/test/sync-runtime.test.ts +316 -1
- package/apps/studio/test/video-comp.test.ts +23 -1
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/video-comp.tsx +70 -6
- package/apps/studio/whats-new.json +27 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +8 -0
- package/package.json +8 -8
- package/plugins/flow/.claude-plugin/config.schema.json +3 -3
|
@@ -88,7 +88,6 @@ export function getEncodeLibBundle(): Promise<string> {
|
|
|
88
88
|
// binary the latter is the virtual `/$bunfs/root`, so `Bun.build` fails with
|
|
89
89
|
// "failed to open root directory: /$bunfs/root" (the mp4/gif export bug).
|
|
90
90
|
const entry = path.join(DEV_SERVER_ROOT, 'exporters', 'video-encode-lib.ts');
|
|
91
|
-
const cachePath = path.join(tmpdir(), 'maude-video-encode-lib.mjs');
|
|
92
91
|
encodeLibReady = (async () => {
|
|
93
92
|
const built = await Bun.build({
|
|
94
93
|
entrypoints: [entry],
|
|
@@ -102,8 +101,7 @@ export function getEncodeLibBundle(): Promise<string> {
|
|
|
102
101
|
}
|
|
103
102
|
const first = built.outputs[0];
|
|
104
103
|
if (!first) throw new Error('encode-lib bundle produced no outputs');
|
|
105
|
-
|
|
106
|
-
return cachePath;
|
|
104
|
+
return writeHashedBundle(await first.text(), 'maude-video-encode-lib');
|
|
107
105
|
})();
|
|
108
106
|
return encodeLibReady;
|
|
109
107
|
}
|
|
@@ -128,7 +126,6 @@ let webRendererLibReady: Promise<string> | null = null;
|
|
|
128
126
|
export function getWebRendererBundle(): Promise<string> {
|
|
129
127
|
if (webRendererLibReady) return webRendererLibReady;
|
|
130
128
|
const entry = path.join(DEV_SERVER_ROOT, 'exporters', 'video-render-lib.ts');
|
|
131
|
-
const cachePath = path.join(tmpdir(), 'maude-video-render-lib.mjs');
|
|
132
129
|
webRendererLibReady = (async () => {
|
|
133
130
|
// Bun's `external` field is package-name PREFIX matched (runtime-bundle.ts's
|
|
134
131
|
// comment on the same gotcha) — listing 'remotion' also externalizes deep
|
|
@@ -179,12 +176,29 @@ export function getWebRendererBundle(): Promise<string> {
|
|
|
179
176
|
}
|
|
180
177
|
const first = built.outputs[0];
|
|
181
178
|
if (!first) throw new Error('web-renderer bundle produced no outputs');
|
|
182
|
-
|
|
183
|
-
return cachePath;
|
|
179
|
+
return writeHashedBundle(await first.text(), 'maude-video-render-lib');
|
|
184
180
|
})();
|
|
185
181
|
return webRendererLibReady;
|
|
186
182
|
}
|
|
187
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Write a built browser bundle to a CONTENT-ADDRESSED temp path.
|
|
186
|
+
*
|
|
187
|
+
* These used to go to a fixed name (`maude-video-encode-lib.mjs`). Two Maude
|
|
188
|
+
* servers at different versions is a documented normal state in this repo — the
|
|
189
|
+
* desktop app and a terminal dev server — and they share one OS temp dir, so
|
|
190
|
+
* they raced to write that single path. The loser then injected the winner's
|
|
191
|
+
* bundle: an old shim could hand bytes to a newer lib, or vice versa. Nothing
|
|
192
|
+
* about that failure is legible from the outside; it looks like a corrupt
|
|
193
|
+
* export. Hashing the content makes a mismatched pair impossible to construct.
|
|
194
|
+
*/
|
|
195
|
+
async function writeHashedBundle(code: string, stem: string): Promise<string> {
|
|
196
|
+
const hash = new Bun.CryptoHasher('sha256').update(code).digest('hex').slice(0, 12);
|
|
197
|
+
const cachePath = path.join(tmpdir(), `${stem}.${hash}.mjs`);
|
|
198
|
+
if (!existsSync(cachePath)) await Bun.write(cachePath, code);
|
|
199
|
+
return cachePath;
|
|
200
|
+
}
|
|
201
|
+
|
|
188
202
|
/**
|
|
189
203
|
* Returns the path to an IIFE bundle for the given npm package, attaching its
|
|
190
204
|
* exports under `window[globalName]`. Caches under the OS temp dir so a long-
|
|
@@ -75,6 +75,12 @@ export interface SpawnShimOptions {
|
|
|
75
75
|
signal?: AbortSignal;
|
|
76
76
|
/** Fired for each `MAUDE_PROGRESS {"current":N,"total":M}` stdout line. */
|
|
77
77
|
onProgress?: (update: { current: number; total: number }) => void;
|
|
78
|
+
/**
|
|
79
|
+
* Fired for the single `MAUDE_TIMING {…}` line a shim emits at the end.
|
|
80
|
+
* Per-stage costs, so "the export is slow" can be answered with a
|
|
81
|
+
* measurement instead of a guess about which stage owns the time.
|
|
82
|
+
*/
|
|
83
|
+
onTiming?: (timing: Record<string, unknown>) => void;
|
|
78
84
|
}
|
|
79
85
|
|
|
80
86
|
export interface SpawnShimResult {
|
|
@@ -85,6 +91,10 @@ export interface SpawnShimResult {
|
|
|
85
91
|
}
|
|
86
92
|
|
|
87
93
|
const PROGRESS_LINE = /^MAUDE_PROGRESS (.+)$/;
|
|
94
|
+
// Filtered here rather than in the adapters for a load-bearing reason: every
|
|
95
|
+
// video adapter parses `stdoutLines.at(-1)` as its summary JSON, so a trailing
|
|
96
|
+
// diagnostic line on stdout would break EVERY video export.
|
|
97
|
+
const TIMING_LINE = /^MAUDE_TIMING (.+)$/;
|
|
88
98
|
|
|
89
99
|
/**
|
|
90
100
|
* Spawn a render shim (`bin/_{png,pdf,svg,html,pptx,video}-playwright.mjs`)
|
|
@@ -121,6 +131,15 @@ export async function spawnShim(args: string[], opts: SpawnShimOptions): Promise
|
|
|
121
131
|
let buffer = '';
|
|
122
132
|
const consumeLine = (line: string) => {
|
|
123
133
|
if (!line) return;
|
|
134
|
+
const timing = TIMING_LINE.exec(line);
|
|
135
|
+
if (timing) {
|
|
136
|
+
try {
|
|
137
|
+
opts.onTiming?.(JSON.parse(timing[1]) as Record<string, unknown>);
|
|
138
|
+
} catch {
|
|
139
|
+
/* malformed timing line — diagnostics must never break an export */
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
124
143
|
const match = PROGRESS_LINE.exec(line);
|
|
125
144
|
if (!match) {
|
|
126
145
|
stdoutLines.push(line);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// A produced-but-lesser export result, and the remedy for it when one exists.
|
|
2
|
+
//
|
|
3
|
+
// Its own module rather than living in `index.ts`, because `index.ts` re-exports
|
|
4
|
+
// every adapter (including `video.ts`) — so a test that wants `remedyFor` alone
|
|
5
|
+
// would pull the whole adapter graph and hit a circular-init ReferenceError.
|
|
6
|
+
//
|
|
7
|
+
// RCA `issue-mp4-audio-export-html5audio-silent-degrade`.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The file is real and the job is `done` — but it is NOT what was asked for,
|
|
11
|
+
* and saying so is the whole point.
|
|
12
|
+
*
|
|
13
|
+
* An mp4 export of a comp using `remotion`'s `<Audio>` (= `<Html5Audio>`, which
|
|
14
|
+
* `@remotion/web-renderer` rejects outright) degrades to the video-only
|
|
15
|
+
* frame-step path and ships MUTED. Before this type existed, the job reported
|
|
16
|
+
* `done`, the history entry looked clean, `GET /_api/export-jobs` had no field
|
|
17
|
+
* for it, and the only trace was one `console.error` in the desktop app's
|
|
18
|
+
* stderr — so the artifact and its ledger entry were indistinguishable from a
|
|
19
|
+
* good export. This is what makes them distinguishable.
|
|
20
|
+
*/
|
|
21
|
+
export interface ExportDegradation {
|
|
22
|
+
/** The requested audio track is absent from the produced file. */
|
|
23
|
+
audioDropped: boolean;
|
|
24
|
+
/** Human-readable cause, already newline-collapsed by the shim (DDR-054). */
|
|
25
|
+
reason: string;
|
|
26
|
+
/** One-line remedy — present only when the cause is a fixable authoring mistake. */
|
|
27
|
+
remedy?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Turn a renderer failure into a one-line fix, when the failure IS fixable.
|
|
32
|
+
*
|
|
33
|
+
* `renderMediaOnWeb` supports ONLY `@remotion/media` media elements; `remotion`'s
|
|
34
|
+
* `Audio` (= `Html5Audio`) and `OffthreadVideo` are both rejected with a typed
|
|
35
|
+
* error that already names the remedy. This carries that remedy to where a user
|
|
36
|
+
* will actually read it — the job row and the toast — instead of leaving it in a
|
|
37
|
+
* log nobody opens.
|
|
38
|
+
*
|
|
39
|
+
* Returns undefined for causes with no honest one-liner (e.g. the DDR-157
|
|
40
|
+
* recursion overflow, which is data-dependent and not the author's to fix).
|
|
41
|
+
* Inventing a remedy there would be worse than silence.
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* Does this container actually carry an audio track?
|
|
45
|
+
*
|
|
46
|
+
* The last line of defence, and deliberately independent of whether the shim
|
|
47
|
+
* noticed anything: the RCA's whole shape was that every OTHER signal said the
|
|
48
|
+
* export was fine. If audio was asked for and the bytes don't have it, the
|
|
49
|
+
* result is degraded — full stop, no matter what the renderer reported.
|
|
50
|
+
*
|
|
51
|
+
* Byte-scan rather than a demuxer, because the question is binary and a parser
|
|
52
|
+
* dependency (or a native `ffprobe`) would cost far more than it buys:
|
|
53
|
+
* • MP4/ISOBMFF — a track's `hdlr` box names its handler type; audio is `soun`.
|
|
54
|
+
* • WebM/Matroska — audio tracks carry an `A_*` CodecID string (`A_OPUS`, …).
|
|
55
|
+
*
|
|
56
|
+
* Conservative by construction: an unrecognized container returns `true` (assume
|
|
57
|
+
* fine) so this can never invent a degradation it cannot prove.
|
|
58
|
+
*/
|
|
59
|
+
export function hasAudioStream(body: Uint8Array, container: string): boolean {
|
|
60
|
+
const ascii = (bytes: Uint8Array, needle: string): boolean => {
|
|
61
|
+
const pat = new TextEncoder().encode(needle);
|
|
62
|
+
outer: for (let i = 0; i + pat.length <= bytes.length; i += 1) {
|
|
63
|
+
for (let j = 0; j < pat.length; j += 1) {
|
|
64
|
+
if (bytes[i + j] !== pat[j]) continue outer;
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
};
|
|
70
|
+
if (container === 'mp4') return ascii(body, 'soun');
|
|
71
|
+
if (container === 'webm') {
|
|
72
|
+
return ['A_OPUS', 'A_VORBIS', 'A_AAC', 'A_MPEG'].some((c) => ascii(body, c));
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function remedyFor(reason: string): string | undefined {
|
|
78
|
+
if (/Html5Audio/i.test(reason)) {
|
|
79
|
+
return (
|
|
80
|
+
"This comp imports `Audio` from 'remotion', which IS <Html5Audio> — the audio " +
|
|
81
|
+
"renderer can't render it. Import { Audio } from '@remotion/media' instead."
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (/OffthreadVideo/i.test(reason)) {
|
|
85
|
+
return (
|
|
86
|
+
'This comp uses <OffthreadVideo>, which the audio renderer rejects. Import ' +
|
|
87
|
+
"{ Video } from '@remotion/media' instead — only @remotion/media elements " +
|
|
88
|
+
'carry audio through the export.'
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
|
|
11
11
|
import * as canva from './canva.ts';
|
|
12
|
+
import type { ExportDegradation } from './degraded.ts';
|
|
12
13
|
import * as html from './html.ts';
|
|
13
14
|
import * as pdf from './pdf.ts';
|
|
14
15
|
import * as png from './png.ts';
|
|
@@ -72,8 +73,12 @@ export interface ExportResult {
|
|
|
72
73
|
contentType: string;
|
|
73
74
|
/** Payload bytes. T1 stubs return zero-byte placeholders. */
|
|
74
75
|
body: Uint8Array;
|
|
76
|
+
/** Set when the export succeeded but produced less than was asked for. */
|
|
77
|
+
degraded?: ExportDegradation;
|
|
75
78
|
}
|
|
76
79
|
|
|
80
|
+
export type { ExportDegradation } from './degraded.ts';
|
|
81
|
+
|
|
77
82
|
/** Progress + cancellation hooks threaded through to the shim-spawning layer. */
|
|
78
83
|
export interface ExportHooks {
|
|
79
84
|
onProgress?: (update: { current: number; total: number }) => void;
|
|
@@ -30,6 +30,7 @@ import path from 'node:path';
|
|
|
30
30
|
import type { Bus } from '../context.ts';
|
|
31
31
|
import {
|
|
32
32
|
type ExportContext,
|
|
33
|
+
type ExportDegradation,
|
|
33
34
|
type ExportOptions,
|
|
34
35
|
type ExportResult,
|
|
35
36
|
type Format,
|
|
@@ -50,6 +51,8 @@ export interface ExportHistoryEntry {
|
|
|
50
51
|
startedAt?: string;
|
|
51
52
|
finishedAt?: string;
|
|
52
53
|
error?: string;
|
|
54
|
+
/** Present when the export produced less than was asked for (e.g. muted mp4). */
|
|
55
|
+
degraded?: ExportDegradation;
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
export type ExportJobStatus = 'queued' | 'running' | 'done' | 'failed';
|
|
@@ -67,6 +70,13 @@ export interface ExportJob {
|
|
|
67
70
|
filename?: string;
|
|
68
71
|
contentType?: string;
|
|
69
72
|
error?: string;
|
|
73
|
+
/**
|
|
74
|
+
* Set when the export finished but produced less than was asked for. `status`
|
|
75
|
+
* stays `done` (the file is real); this is what tells the UI, the WS
|
|
76
|
+
* subscriber and the history ledger that it is not a clean result.
|
|
77
|
+
* RCA `issue-mp4-audio-export-html5audio-silent-degrade`.
|
|
78
|
+
*/
|
|
79
|
+
degraded?: ExportDegradation;
|
|
70
80
|
}
|
|
71
81
|
|
|
72
82
|
export interface EnqueueArgs {
|
|
@@ -252,6 +262,9 @@ export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQue
|
|
|
252
262
|
startedAt: j.startedAt,
|
|
253
263
|
finishedAt: j.finishedAt,
|
|
254
264
|
error: j.error,
|
|
265
|
+
// A muted mp4 must not look like a clean one in the ledger either — the
|
|
266
|
+
// history entry is what a later session (or an agent) reads back.
|
|
267
|
+
degraded: j.degraded,
|
|
255
268
|
}));
|
|
256
269
|
}
|
|
257
270
|
|
|
@@ -329,6 +342,12 @@ export function createExportJobQueue(bus: Bus, designRoot: string): ExportJobQue
|
|
|
329
342
|
job.finishedAt = new Date().toISOString();
|
|
330
343
|
job.filename = res.filename;
|
|
331
344
|
job.contentType = res.contentType;
|
|
345
|
+
// `done` + `degraded` is a real, deliberate combination: the file exists
|
|
346
|
+
// and is downloadable, but it is missing something that was asked for.
|
|
347
|
+
// Before this, the ONLY trace of a muted mp4 was a console.error line in
|
|
348
|
+
// the desktop app's stderr — see RCA
|
|
349
|
+
// issue-mp4-audio-export-html5audio-silent-degrade.
|
|
350
|
+
if (res.degraded) job.degraded = res.degraded;
|
|
332
351
|
if (res.body.byteLength) {
|
|
333
352
|
const dir = path.join(jobsDir, id);
|
|
334
353
|
await mkdir(dir, { recursive: true });
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// Pre-flight for the two media elements the audio renderer rejects.
|
|
2
|
+
//
|
|
3
|
+
// RCA `issue-mp4-audio-export-html5audio-silent-degrade`: `renderMediaOnWeb` —
|
|
4
|
+
// the ONLY export path that can mux an audio track — supports only
|
|
5
|
+
// `@remotion/media` media elements. `remotion`'s `Audio` (which IS
|
|
6
|
+
// `Html5Audio`) and `OffthreadVideo` are both rejected at render time. The shim
|
|
7
|
+
// catches the throw and degrades to the video-only frame-step path, so the
|
|
8
|
+
// export "succeeds" muted — and ~40x slower (measured: ~37 min vs ~45 s on the
|
|
9
|
+
// sibling artboard).
|
|
10
|
+
//
|
|
11
|
+
// DDR-157's blanket "degrade on ANY renderer failure" is right for the class it
|
|
12
|
+
// was built for: the recursion overflow is data-dependent, manifests late, and
|
|
13
|
+
// the author cannot fix it. `<Html5Audio>` is the opposite — decidable from
|
|
14
|
+
// source before a browser is ever launched, always fails, one-line remedy stated
|
|
15
|
+
// in the error itself. Swallowing THAT into a generic fallback turns a fixable
|
|
16
|
+
// mistake into a mysterious one, so we decide it here instead.
|
|
17
|
+
//
|
|
18
|
+
// Deliberately a regex-over-known-vocabulary scan rather than a real parser,
|
|
19
|
+
// matching the house style of `bin/_canvas-rects-static.mjs`: the vocabulary is
|
|
20
|
+
// fixed (two symbols, one module specifier) and a parser dependency for this
|
|
21
|
+
// would cost more than it buys.
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
|
|
26
|
+
/** The elements `@remotion/web-renderer` refuses, and what to do about each. */
|
|
27
|
+
export type UnsupportedElement = 'Audio' | 'OffthreadVideo';
|
|
28
|
+
|
|
29
|
+
export interface UnsupportedFinding {
|
|
30
|
+
element: UnsupportedElement;
|
|
31
|
+
/** Where the `from 'remotion'` import actually lives — the canvas, or a barrel it re-exports through. */
|
|
32
|
+
sourceFile: string;
|
|
33
|
+
/** True when the canvas reached it through a re-export barrel rather than directly. */
|
|
34
|
+
viaBarrel: boolean;
|
|
35
|
+
/** The name the JSX mounts it under — differs from `element` when aliased. */
|
|
36
|
+
localName: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const CANDIDATE_EXTS = ['.tsx', '.ts', '.jsx', '.js'];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Named bindings imported from the bare `remotion` specifier.
|
|
43
|
+
*
|
|
44
|
+
* Matches `import { A, B as C } from 'remotion'` and the `export { A } from
|
|
45
|
+
* 'remotion'` re-export form, single or double quoted. Deliberately anchored to
|
|
46
|
+
* the EXACT specifier: `@remotion/media` and `@remotion/transitions` are the
|
|
47
|
+
* supported packages and must never match.
|
|
48
|
+
*/
|
|
49
|
+
function bindingsFromRemotion(src: string): Map<string, string> {
|
|
50
|
+
// originalName -> localName. Both halves matter and they are NOT
|
|
51
|
+
// interchangeable: the ORIGINAL name identifies which element this is, but the
|
|
52
|
+
// LOCAL name is what the JSX actually mounts. `import { Audio as Sound }`
|
|
53
|
+
// renders `<Sound>`, so checking for `<Audio>` would miss it entirely.
|
|
54
|
+
const found = new Map<string, string>();
|
|
55
|
+
const re = /(?:import|export)\s*\{([^}]*)\}\s*from\s*['"]remotion['"]/g;
|
|
56
|
+
for (const m of src.matchAll(re)) {
|
|
57
|
+
for (const raw of m[1].split(',')) {
|
|
58
|
+
const [orig, alias] = raw.split(/\s+as\s+/).map((s) => s.trim());
|
|
59
|
+
if (!orig) continue;
|
|
60
|
+
found.set(orig, alias || orig);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return found;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Relative-specifier imports, with their resolved absolute paths where they exist. */
|
|
67
|
+
function localImports(src: string, fromFile: string): string[] {
|
|
68
|
+
const out: string[] = [];
|
|
69
|
+
const re = /(?:import|export)\s*(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)?\s*from\s*['"](\.[^'"]*)['"]/g;
|
|
70
|
+
for (const m of src.matchAll(re)) {
|
|
71
|
+
const resolved = resolveLocal(m[1], fromFile);
|
|
72
|
+
if (resolved) out.push(resolved);
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolveLocal(spec: string, fromFile: string): string | null {
|
|
78
|
+
const base = path.resolve(path.dirname(fromFile), spec);
|
|
79
|
+
const candidates = [
|
|
80
|
+
base,
|
|
81
|
+
...CANDIDATE_EXTS.map((e) => base + e),
|
|
82
|
+
...CANDIDATE_EXTS.map((e) => path.join(base, `index${e}`)),
|
|
83
|
+
];
|
|
84
|
+
for (const c of candidates) {
|
|
85
|
+
if (existsSync(c) && !c.endsWith(path.sep)) {
|
|
86
|
+
try {
|
|
87
|
+
if (readFileSync(c, 'utf8')) return c;
|
|
88
|
+
} catch {
|
|
89
|
+
/* directory or unreadable — keep looking */
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** True when the canvas actually MOUNTS the element (not merely imports it). */
|
|
97
|
+
function mounts(src: string, name: string): boolean {
|
|
98
|
+
return new RegExp(`<${name}[\\s/>]`).test(src);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Scan a canvas for media elements the audio renderer will reject.
|
|
103
|
+
*
|
|
104
|
+
* Follows relative imports ONE hop, because that is the real shape of the bug:
|
|
105
|
+
* `_broadcast.tsx` re-exports `Audio` from `'remotion'`, and every social canvas
|
|
106
|
+
* imports `Audio` from `_broadcast`. A scanner that reads only the canvas file
|
|
107
|
+
* sees a clean local import and misses it entirely.
|
|
108
|
+
*
|
|
109
|
+
* Returns only elements that are both reachable from `'remotion'` AND mounted in
|
|
110
|
+
* the canvas — an unused import is not worth refusing an export over.
|
|
111
|
+
*/
|
|
112
|
+
export function scanUnsupportedMedia(canvasAbsPath: string): UnsupportedFinding[] {
|
|
113
|
+
let src: string;
|
|
114
|
+
try {
|
|
115
|
+
src = readFileSync(canvasAbsPath, 'utf8');
|
|
116
|
+
} catch {
|
|
117
|
+
// Unreadable canvas is not this check's business to report — the export
|
|
118
|
+
// itself will fail loudly a moment later with a better message.
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const findings: UnsupportedFinding[] = [];
|
|
123
|
+
const seen = new Set<UnsupportedElement>();
|
|
124
|
+
|
|
125
|
+
const consider = (names: Map<string, string>, sourceFile: string, viaBarrel: boolean) => {
|
|
126
|
+
for (const el of ['Audio', 'OffthreadVideo'] as const) {
|
|
127
|
+
if (seen.has(el)) continue;
|
|
128
|
+
const localName = names.get(el);
|
|
129
|
+
if (!localName) continue;
|
|
130
|
+
// Mounted under whatever name this file bound it to — see bindingsFromRemotion.
|
|
131
|
+
if (!mounts(src, localName)) continue;
|
|
132
|
+
seen.add(el);
|
|
133
|
+
findings.push({ element: el, sourceFile, viaBarrel, localName });
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
consider(bindingsFromRemotion(src), canvasAbsPath, false);
|
|
138
|
+
|
|
139
|
+
for (const dep of localImports(src, canvasAbsPath)) {
|
|
140
|
+
if (seen.size === 2) break;
|
|
141
|
+
try {
|
|
142
|
+
consider(bindingsFromRemotion(readFileSync(dep, 'utf8')), dep, true);
|
|
143
|
+
} catch {
|
|
144
|
+
/* unreadable dependency — nothing to assert */
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return findings;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The refusal message for a mounted `<Audio>` from `remotion`.
|
|
153
|
+
*
|
|
154
|
+
* Refuse rather than warn: a music bed silently vanishing from a finished cut is
|
|
155
|
+
* worse than an export that did not run, and the remedy is one word. This
|
|
156
|
+
* mirrors the existing "refused with remediation, never silently truncated"
|
|
157
|
+
* posture of `resolveMaxFrames`.
|
|
158
|
+
*/
|
|
159
|
+
export function audioRefusalMessage(f: UnsupportedFinding, canvasRel: string): string {
|
|
160
|
+
const where = f.viaBarrel
|
|
161
|
+
? `${canvasRel} imports it from ${path.basename(f.sourceFile)}, which re-exports it from 'remotion'`
|
|
162
|
+
: `${canvasRel} imports it from 'remotion'`;
|
|
163
|
+
return (
|
|
164
|
+
`this comp mounts <Audio> from 'remotion' — which IS <Html5Audio>, and ` +
|
|
165
|
+
`@remotion/web-renderer rejects it, so the export would silently lose its audio ` +
|
|
166
|
+
`(and run ~40x slower on the frame-step fallback). ${where}. ` +
|
|
167
|
+
`Fix: import { Audio } from '@remotion/media' instead — same props, drop-in. ` +
|
|
168
|
+
`To export muted anyway, pass options.allowUnsupportedMedia.`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
@@ -121,7 +121,18 @@ const api: MaudeEnc = {
|
|
|
121
121
|
if (!codec) throw new Error('no encodable video codec available in this browser');
|
|
122
122
|
|
|
123
123
|
const output = new Output({ format: outFormat, target: new BufferTarget() });
|
|
124
|
-
|
|
124
|
+
// `latencyMode: 'quality'` is pinned, not left to the default. Per WebCodecs
|
|
125
|
+
// §7.11 `realtime` explicitly permits the encoder to DROP frames to hit a
|
|
126
|
+
// bitrate/framerate target, while `quality` MUST NOT — and mediabunny flips
|
|
127
|
+
// MediaStream-driven sources to `realtime` automatically. We push frames
|
|
128
|
+
// through a CanvasSource so we are not in that case today, but a future
|
|
129
|
+
// refactor that routes through a stream would silently start dropping
|
|
130
|
+
// frames from an export with no error anywhere.
|
|
131
|
+
const source = new CanvasSource(canvas, {
|
|
132
|
+
codec,
|
|
133
|
+
bitrate: QUALITY_HIGH,
|
|
134
|
+
latencyMode: 'quality',
|
|
135
|
+
});
|
|
125
136
|
output.addVideoTrack(source, { frameRate: fps });
|
|
126
137
|
await output.start();
|
|
127
138
|
const container = outFormat instanceof Mp4OutputFormat ? 'mp4' : 'webm';
|
|
@@ -132,6 +143,21 @@ const api: MaudeEnc = {
|
|
|
132
143
|
async addVideoFrame(b64png) {
|
|
133
144
|
if (!vstate) throw new Error('startVideo not called');
|
|
134
145
|
const bmp = await decodeToBitmap(b64png);
|
|
146
|
+
// REJECT a mismatched frame instead of rescaling it. `drawImage` with
|
|
147
|
+
// explicit dimensions silently resamples anything that does not match the
|
|
148
|
+
// encoder canvas, so a capture whose clip drifted by a pixel produced an
|
|
149
|
+
// invisible resample seam rather than an error — the kind of defect that
|
|
150
|
+
// survives every check we have and shows up as "the export looks slightly
|
|
151
|
+
// off". Loud is the only useful behaviour here.
|
|
152
|
+
if (bmp.width !== vstate.canvas.width || bmp.height !== vstate.canvas.height) {
|
|
153
|
+
const got = `${bmp.width}×${bmp.height}`;
|
|
154
|
+
const want = `${vstate.canvas.width}×${vstate.canvas.height}`;
|
|
155
|
+
if ('close' in bmp) bmp.close();
|
|
156
|
+
throw new Error(
|
|
157
|
+
`frame ${vstate.frame} is ${got} but the encoder canvas is ${want} — ` +
|
|
158
|
+
'refusing to resample. The capture clip changed mid-export.'
|
|
159
|
+
);
|
|
160
|
+
}
|
|
135
161
|
vstate.ctx.drawImage(bmp, 0, 0, vstate.canvas.width, vstate.canvas.height);
|
|
136
162
|
if ('close' in bmp) bmp.close();
|
|
137
163
|
await vstate.source.add(vstate.frame / vstate.fps, 1 / vstate.fps);
|
|
@@ -92,6 +92,12 @@ async function renderVideo(opts: RenderVideoOpts): Promise<RenderVideoResult> {
|
|
|
92
92
|
isProduction: true,
|
|
93
93
|
licenseKey: opts.licenseKey ?? 'free-license',
|
|
94
94
|
logLevel: 'error',
|
|
95
|
+
// Remotion defaults this to 'medium', which yields the event loop every
|
|
96
|
+
// ~33 ms to keep a page interactive while it renders. Nobody is looking at
|
|
97
|
+
// this page — it is a headless capture Chromium with no UI — so the courtesy
|
|
98
|
+
// is pure overhead. 'disabled' is Remotion's documented setting for exactly
|
|
99
|
+
// this case (client-side rendering / page-responsiveness).
|
|
100
|
+
pageResponsiveness: 'disabled',
|
|
95
101
|
});
|
|
96
102
|
const blob = await result.getBlob();
|
|
97
103
|
const buf = new Uint8Array(await blob.arrayBuffer());
|
|
@@ -21,6 +21,7 @@ import path from 'node:path';
|
|
|
21
21
|
|
|
22
22
|
import { getEncodeLibBundle, getWebRendererBundle } from './_browser-bundles.ts';
|
|
23
23
|
import { exportShimPath, runShim } from './_runtime.ts';
|
|
24
|
+
import { type ExportDegradation, hasAudioStream, remedyFor } from './degraded.ts';
|
|
24
25
|
import {
|
|
25
26
|
canvasShellUrl,
|
|
26
27
|
type ExportContext,
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
type ExportResult,
|
|
30
31
|
} from './index.ts';
|
|
31
32
|
import type { Target } from './scope.ts';
|
|
33
|
+
import { audioRefusalMessage, scanUnsupportedMedia } from './unsupported-media.ts';
|
|
32
34
|
|
|
33
35
|
// DDR-045: resolve via DEV_SERVER_ROOT, never `import.meta.dir`. See _runtime.ts.
|
|
34
36
|
const VIDEO_PLAYWRIGHT = exportShimPath('_video-playwright.mjs');
|
|
@@ -67,6 +69,30 @@ async function runVideo(
|
|
|
67
69
|
);
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
// Pre-flight the two elements the audio renderer rejects, BEFORE launching a
|
|
73
|
+
// browser (RCA issue-mp4-audio-export-html5audio-silent-degrade). This is
|
|
74
|
+
// decidable from source and always fails, so discovering it 37 minutes later
|
|
75
|
+
// via a muted file is pure waste. GIF is silent by format, so it is exempt.
|
|
76
|
+
let preStamped: ExportDegradation | undefined;
|
|
77
|
+
if (format !== 'gif' && options.allowUnsupportedMedia !== true) {
|
|
78
|
+
const canvasAbs = path.resolve(ctx.repoRoot, el.file);
|
|
79
|
+
for (const finding of scanUnsupportedMedia(canvasAbs)) {
|
|
80
|
+
if (finding.element === 'Audio' && options.audio !== false) {
|
|
81
|
+
// Refuse. A vanished music bed is worse than an export that did not run.
|
|
82
|
+
throw new Error(audioRefusalMessage(finding, el.file));
|
|
83
|
+
}
|
|
84
|
+
if (finding.element === 'OffthreadVideo') {
|
|
85
|
+
// Warn, don't refuse — <OffthreadVideo> is often legitimately silent
|
|
86
|
+
// b-roll, so blocking the export would be the wrong trade.
|
|
87
|
+
preStamped = {
|
|
88
|
+
audioDropped: true,
|
|
89
|
+
reason: `<OffthreadVideo> in ${el.file} is not supported by @remotion/web-renderer`,
|
|
90
|
+
remedy: remedyFor('OffthreadVideo'),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
70
96
|
// gif has no renderMediaOnWeb container support (and no audio need — GIF is a
|
|
71
97
|
// silent format), so it never needs the render-lib bundle; mp4/webm build it
|
|
72
98
|
// alongside the frame-step encode-lib (the shim decides per-target at runtime
|
|
@@ -150,6 +176,13 @@ async function runVideo(
|
|
|
150
176
|
// the fast one-pass renderer finishes before progress matters and reports
|
|
151
177
|
// none until done (it exposes no per-frame hook) — acceptable.
|
|
152
178
|
onProgress: hooks?.onProgress,
|
|
179
|
+
// One line per export naming where the time actually went, and which of
|
|
180
|
+
// the two capture paths ran. Previously neither was recoverable after the
|
|
181
|
+
// fact — "which path did this take?" needed a dig through the desktop
|
|
182
|
+
// app's stderr, if it was still there.
|
|
183
|
+
onTiming: (timing) => {
|
|
184
|
+
console.error(`⏱ ${format} export: ${JSON.stringify(timing)}`);
|
|
185
|
+
},
|
|
153
186
|
});
|
|
154
187
|
const body = new Uint8Array(readFileSync(outPath));
|
|
155
188
|
// The container can differ from the request (mp4 → webm fallback when the
|
|
@@ -159,6 +192,9 @@ async function runVideo(
|
|
|
159
192
|
// issue-video-mp4-rendermediaonweb-stack-overflow) — surfaced here so the
|
|
160
193
|
// dropped audio isn't silent (job stderr / logs; a UI toast is a follow-up).
|
|
161
194
|
let ext: string = format;
|
|
195
|
+
// A pre-flight finding stands unless the shim reports its own, more specific
|
|
196
|
+
// cause below — never let a clean-looking summary erase a known defect.
|
|
197
|
+
let degraded: ExportDegradation | undefined = preStamped;
|
|
162
198
|
try {
|
|
163
199
|
const lastLine = stdoutLines.at(-1) ?? '{}';
|
|
164
200
|
const summary = JSON.parse(lastLine) as {
|
|
@@ -169,19 +205,44 @@ async function runVideo(
|
|
|
169
205
|
};
|
|
170
206
|
if (summary.container) ext = summary.container;
|
|
171
207
|
if (summary.degraded) {
|
|
208
|
+
const reason = summary.fallbackReason ?? 'unknown';
|
|
172
209
|
console.error(
|
|
173
210
|
`⚠ ${format} export degraded: the audio renderer failed ` +
|
|
174
|
-
`(${
|
|
211
|
+
`(${reason}), so this file was captured ` +
|
|
175
212
|
'frame-by-frame and has no audio.'
|
|
176
213
|
);
|
|
214
|
+
// This used to stop at the console.error above — the flags were parsed
|
|
215
|
+
// and then dropped on the floor, so `jobs.ts` set `done` with nothing to
|
|
216
|
+
// say otherwise and the user got a muted file with a green checkmark.
|
|
217
|
+
// RCA issue-mp4-audio-export-html5audio-silent-degrade.
|
|
218
|
+
degraded = {
|
|
219
|
+
audioDropped: summary.audioDropped !== false,
|
|
220
|
+
reason,
|
|
221
|
+
remedy: remedyFor(reason),
|
|
222
|
+
};
|
|
177
223
|
}
|
|
178
224
|
} catch {
|
|
179
225
|
/* keep the requested ext */
|
|
180
226
|
}
|
|
227
|
+
// Artifact-level backstop. Everything above trusts a report; this checks the
|
|
228
|
+
// bytes. The RCA's defining property was that every other signal said the
|
|
229
|
+
// export was fine — so the last word belongs to the file itself.
|
|
230
|
+
if (!degraded && wantAudio && format !== 'gif' && !hasAudioStream(body, ext)) {
|
|
231
|
+
degraded = {
|
|
232
|
+
audioDropped: true,
|
|
233
|
+
reason: `the produced ${ext} has no audio track, though audio was requested`,
|
|
234
|
+
remedy:
|
|
235
|
+
'Check that the comp mounts <Audio>/<Video> from @remotion/media — ' +
|
|
236
|
+
"elements imported from 'remotion' are dropped by the audio renderer.",
|
|
237
|
+
};
|
|
238
|
+
console.error(`⚠ ${format} export produced no audio track despite audio being requested.`);
|
|
239
|
+
}
|
|
240
|
+
|
|
181
241
|
return {
|
|
182
242
|
filename: `${el.canvasSlug}.${ext}`,
|
|
183
243
|
contentType: CONTENT_TYPE[ext] ?? 'application/octet-stream',
|
|
184
244
|
body,
|
|
245
|
+
degraded,
|
|
185
246
|
};
|
|
186
247
|
} finally {
|
|
187
248
|
rmSync(tmp, { recursive: true, force: true });
|