@editframe/elements 0.35.0-beta → 0.36.0-beta
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/elements/EFImage.js +11 -2
- package/dist/elements/EFImage.js.map +1 -1
- package/dist/elements/EFTemporal.js +1 -0
- package/dist/elements/EFTemporal.js.map +1 -1
- package/dist/elements/EFTimegroup.d.ts +40 -6
- package/dist/elements/EFTimegroup.js +127 -8
- package/dist/elements/EFTimegroup.js.map +1 -1
- package/dist/elements/updateAnimations.js +38 -15
- package/dist/elements/updateAnimations.js.map +1 -1
- package/dist/gui/EFWorkbench.js +10 -12
- package/dist/gui/EFWorkbench.js.map +1 -1
- package/dist/gui/TWMixin.js +1 -1
- package/dist/gui/TWMixin.js.map +1 -1
- package/dist/preview/FrameController.js +6 -1
- package/dist/preview/FrameController.js.map +1 -1
- package/dist/preview/encoding/canvasEncoder.js.map +1 -1
- package/dist/preview/encoding/mainThreadEncoder.js +3 -0
- package/dist/preview/encoding/mainThreadEncoder.js.map +1 -1
- package/dist/preview/renderTimegroupPreview.js +57 -55
- package/dist/preview/renderTimegroupPreview.js.map +1 -1
- package/dist/preview/renderTimegroupToCanvas.js +22 -23
- package/dist/preview/renderTimegroupToCanvas.js.map +1 -1
- package/dist/preview/renderTimegroupToVideo.d.ts +2 -1
- package/dist/preview/renderTimegroupToVideo.js +77 -40
- package/dist/preview/renderTimegroupToVideo.js.map +1 -1
- package/dist/preview/rendering/renderToImage.d.ts +1 -0
- package/dist/preview/rendering/renderToImage.js +1 -26
- package/dist/preview/rendering/renderToImage.js.map +1 -1
- package/dist/preview/rendering/renderToImageForeignObject.js +34 -6
- package/dist/preview/rendering/renderToImageForeignObject.js.map +1 -1
- package/dist/preview/rendering/serializeTimelineDirect.js +379 -0
- package/dist/preview/rendering/serializeTimelineDirect.js.map +1 -0
- package/dist/render/EFRenderAPI.js +45 -0
- package/dist/render/EFRenderAPI.js.map +1 -1
- package/dist/style.css +12 -0
- package/package.json +2 -2
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import { FrameController } from "./FrameController.js";
|
|
2
1
|
import { logger } from "./logger.js";
|
|
3
2
|
import { createPreviewContainer } from "./previewTypes.js";
|
|
4
|
-
import {
|
|
5
|
-
import { inlineImages } from "./rendering/inlineImages.js";
|
|
6
|
-
import { renderToImageDirect } from "./rendering/renderToImage.js";
|
|
3
|
+
import { RenderContext } from "./RenderContext.js";
|
|
7
4
|
import { resetRenderState } from "./renderTimegroupToCanvas.js";
|
|
5
|
+
import { serializeTimelineToDataUri } from "./rendering/serializeTimelineDirect.js";
|
|
8
6
|
import { AudioBufferSource, BufferTarget, CanvasSource, Mp4OutputFormat, Output, StreamTarget, canEncodeAudio, getEncodableAudioCodecs } from "mediabunny";
|
|
9
7
|
|
|
10
8
|
//#region src/preview/renderTimegroupToVideo.ts
|
|
@@ -35,14 +33,20 @@ function resolveConfig(timegroup, options) {
|
|
|
35
33
|
const returnBuffer = options.returnBuffer ?? false;
|
|
36
34
|
const preferredAudioCodecs = options.preferredAudioCodecs ?? ["aac", "opus"];
|
|
37
35
|
const benchmarkMode = options.benchmarkMode ?? false;
|
|
36
|
+
const progressPreviewInterval = options.progressPreviewInterval ?? 60;
|
|
38
37
|
const totalDurationMs = timegroup.durationMs;
|
|
39
38
|
if (!totalDurationMs || totalDurationMs <= 0) throw new Error("Timegroup has no duration");
|
|
40
39
|
const startMs = Math.max(0, options.fromMs ?? 0);
|
|
41
40
|
const endMs = options.toMs !== void 0 ? Math.min(options.toMs, totalDurationMs) : totalDurationMs;
|
|
42
41
|
const renderDurationMs = endMs - startMs;
|
|
43
42
|
if (renderDurationMs <= 0) throw new Error(`Invalid render range: from ${startMs}ms to ${endMs}ms`);
|
|
44
|
-
|
|
45
|
-
const
|
|
43
|
+
timegroup.offsetHeight;
|
|
44
|
+
const timegroupWidth = timegroup.offsetWidth;
|
|
45
|
+
const timegroupHeight = timegroup.offsetHeight;
|
|
46
|
+
console.log(`[renderTimegroupToVideo] Timegroup dimensions: ${timegroupWidth}x${timegroupHeight}`);
|
|
47
|
+
console.log(`[renderTimegroupToVideo] Computed style:`, getComputedStyle(timegroup).width, getComputedStyle(timegroup).height);
|
|
48
|
+
console.log(`[renderTimegroupToVideo] BoundingClientRect:`, timegroup.getBoundingClientRect());
|
|
49
|
+
if (!timegroupWidth || !timegroupHeight) throw new Error(`Timegroup has no dimensions (${timegroupWidth}x${timegroupHeight}). Ensure the timegroup element is in the document and has explicit width/height styles (e.g., class="w-[1920px] h-[1080px]")`);
|
|
46
50
|
const width = Math.floor(timegroupWidth * scale);
|
|
47
51
|
const height = Math.floor(timegroupHeight * scale);
|
|
48
52
|
const videoWidth = width % 2 === 0 ? width : width - 1;
|
|
@@ -58,6 +62,8 @@ function resolveConfig(timegroup, options) {
|
|
|
58
62
|
startMs,
|
|
59
63
|
endMs,
|
|
60
64
|
renderDurationMs,
|
|
65
|
+
width,
|
|
66
|
+
height,
|
|
61
67
|
videoWidth,
|
|
62
68
|
videoHeight,
|
|
63
69
|
totalFrames: Math.ceil(renderDurationMs / frameDurationMs),
|
|
@@ -70,7 +76,8 @@ function resolveConfig(timegroup, options) {
|
|
|
70
76
|
blockingTimeoutMs,
|
|
71
77
|
returnBuffer,
|
|
72
78
|
preferredAudioCodecs,
|
|
73
|
-
benchmarkMode
|
|
79
|
+
benchmarkMode,
|
|
80
|
+
progressPreviewInterval
|
|
74
81
|
};
|
|
75
82
|
}
|
|
76
83
|
function isFileSystemAccessSupported() {
|
|
@@ -198,26 +205,32 @@ async function renderTimegroupToVideo(timegroup, options = {}) {
|
|
|
198
205
|
}
|
|
199
206
|
await output.start();
|
|
200
207
|
}
|
|
201
|
-
const
|
|
202
|
-
await renderClone.seekForRender(initialTimeMs);
|
|
203
|
-
const { container: cloneContainer, syncState } = buildCloneStructure(renderClone, initialTimeMs);
|
|
204
|
-
const frameController = new FrameController(renderClone);
|
|
205
|
-
const width = timegroup.offsetWidth || 1920;
|
|
206
|
-
const height = timegroup.offsetHeight || 1080;
|
|
208
|
+
const renderContext = new RenderContext();
|
|
207
209
|
const previewContainer = createPreviewContainer({
|
|
208
|
-
width,
|
|
209
|
-
height,
|
|
210
|
+
width: timegroup.offsetWidth || 1920,
|
|
211
|
+
height: timegroup.offsetHeight || 1080,
|
|
210
212
|
background: getComputedStyle(timegroup).background || "#000"
|
|
211
213
|
});
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
previewContainer.
|
|
215
|
-
previewContainer.
|
|
216
|
-
|
|
214
|
+
console.log(`[renderTimegroupToVideo] Using direct timeline serialization`);
|
|
215
|
+
previewContainer.appendChild(renderClone);
|
|
216
|
+
previewContainer.classList.add("ef-render-clone-container");
|
|
217
|
+
previewContainer.style.cssText += ";position:fixed;left:-99999px;top:-99999px;pointer-events:none;";
|
|
218
|
+
document.body.appendChild(previewContainer);
|
|
219
|
+
renderClone.offsetHeight;
|
|
220
|
+
console.log(`[renderTimegroupToVideo] Attached previewContainer to document.body (off-screen) for style computation`);
|
|
217
221
|
const renderStartTime = performance.now();
|
|
218
|
-
let lastFramePreviewUrl;
|
|
219
222
|
let lastRenderedAudioEndMs = config.startMs;
|
|
220
223
|
const audioChunkDurationMs = 2e3;
|
|
224
|
+
let thumbCanvas = null;
|
|
225
|
+
let thumbCtx = null;
|
|
226
|
+
if (onProgress && config.progressPreviewInterval > 0) {
|
|
227
|
+
const previewWidth = 160;
|
|
228
|
+
const previewHeight = Math.round(previewWidth * (config.videoHeight / config.videoWidth));
|
|
229
|
+
thumbCanvas = document.createElement("canvas");
|
|
230
|
+
thumbCanvas.width = previewWidth;
|
|
231
|
+
thumbCanvas.height = previewHeight;
|
|
232
|
+
thumbCtx = thumbCanvas.getContext("2d");
|
|
233
|
+
}
|
|
221
234
|
let totalSeekMs = 0;
|
|
222
235
|
let totalSyncMs = 0;
|
|
223
236
|
let totalRenderMs = 0;
|
|
@@ -226,10 +239,9 @@ async function renderTimegroupToVideo(timegroup, options = {}) {
|
|
|
226
239
|
const seekQueue = [];
|
|
227
240
|
const renderTasks = [];
|
|
228
241
|
const MAX_SEEK = 1;
|
|
229
|
-
const MAX_RENDER =
|
|
242
|
+
const MAX_RENDER = 4;
|
|
230
243
|
let nextSeekFrame = 0;
|
|
231
244
|
let nextRenderFrame = 0;
|
|
232
|
-
await inlineImages(previewContainer);
|
|
233
245
|
for (let completedFrames = 0; completedFrames < config.totalFrames; completedFrames++) {
|
|
234
246
|
checkCancelled();
|
|
235
247
|
const frameIndex = completedFrames;
|
|
@@ -249,14 +261,29 @@ async function renderTimegroupToVideo(timegroup, options = {}) {
|
|
|
249
261
|
const renderTimeMs = timestamps[renderFrameIndex];
|
|
250
262
|
const renderTimestampS = renderFrameIndex * config.frameDurationMs / 1e3;
|
|
251
263
|
const renderPromise = seekQueue.shift().then(async () => {
|
|
252
|
-
await frameController.renderFrame(renderTimeMs, { waitForLitUpdate: false });
|
|
253
264
|
const syncStart = performance.now();
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
265
|
+
const dataUri = await serializeTimelineToDataUri(renderClone, config.width, config.height, {
|
|
266
|
+
renderContext,
|
|
267
|
+
canvasScale: config.scale,
|
|
268
|
+
timeMs: renderTimeMs
|
|
269
|
+
});
|
|
270
|
+
const syncTime = performance.now() - syncStart;
|
|
271
|
+
totalSyncMs += syncTime;
|
|
257
272
|
const renderStart = performance.now();
|
|
258
|
-
const image$1 =
|
|
259
|
-
|
|
273
|
+
const image$1 = new Image();
|
|
274
|
+
await new Promise((resolve, reject) => {
|
|
275
|
+
image$1.onload = () => resolve();
|
|
276
|
+
image$1.onerror = (e) => {
|
|
277
|
+
console.error(`[Frame ${renderFrameIndex}] Image load error:`, e);
|
|
278
|
+
console.error(`[Frame ${renderFrameIndex}] Data URI preview:`, dataUri.substring(0, 200) + "...");
|
|
279
|
+
reject(/* @__PURE__ */ new Error(`Failed to load image from data URI`));
|
|
280
|
+
};
|
|
281
|
+
image$1.src = dataUri;
|
|
282
|
+
});
|
|
283
|
+
const renderTime = performance.now() - renderStart;
|
|
284
|
+
totalRenderMs += renderTime;
|
|
285
|
+
if (renderFrameIndex % 30 === 0) console.log(`[Frame ${renderFrameIndex}] Image loaded: ${image$1.width}x${image$1.height}`);
|
|
286
|
+
if (renderFrameIndex % 30 === 0) console.log(`[Frame ${renderFrameIndex}] serialize=${syncTime.toFixed(1)}ms`);
|
|
260
287
|
return image$1;
|
|
261
288
|
});
|
|
262
289
|
renderTasks.push({
|
|
@@ -292,15 +319,7 @@ async function renderTimegroupToVideo(timegroup, options = {}) {
|
|
|
292
319
|
const msPerFrame = elapsedMs / currentFrame;
|
|
293
320
|
const estimatedRemainingMs = (config.totalFrames - currentFrame) * msPerFrame;
|
|
294
321
|
const speedMultiplier = renderedMs / elapsedMs;
|
|
295
|
-
if (
|
|
296
|
-
const previewWidth = 160;
|
|
297
|
-
const previewHeight = Math.round(previewWidth * (config.videoHeight / config.videoWidth));
|
|
298
|
-
const thumbCanvas = document.createElement("canvas");
|
|
299
|
-
thumbCanvas.width = previewWidth;
|
|
300
|
-
thumbCanvas.height = previewHeight;
|
|
301
|
-
thumbCanvas.getContext("2d").drawImage(image, 0, 0, previewWidth, previewHeight);
|
|
302
|
-
lastFramePreviewUrl = thumbCanvas.toDataURL("image/jpeg", .7);
|
|
303
|
-
}
|
|
322
|
+
if (thumbCanvas && thumbCtx && frameIndex % config.progressPreviewInterval === 0) thumbCtx.drawImage(image, 0, 0, thumbCanvas.width, thumbCanvas.height);
|
|
304
323
|
onProgress?.({
|
|
305
324
|
progress,
|
|
306
325
|
currentFrame,
|
|
@@ -310,7 +329,7 @@ async function renderTimegroupToVideo(timegroup, options = {}) {
|
|
|
310
329
|
elapsedMs,
|
|
311
330
|
estimatedRemainingMs,
|
|
312
331
|
speedMultiplier,
|
|
313
|
-
|
|
332
|
+
framePreviewCanvas: thumbCanvas || void 0
|
|
314
333
|
});
|
|
315
334
|
}
|
|
316
335
|
if (audioSource && lastRenderedAudioEndMs < config.endMs) try {
|
|
@@ -318,6 +337,23 @@ async function renderTimegroupToVideo(timegroup, options = {}) {
|
|
|
318
337
|
if (audioBuffer && audioBuffer.length > 0) await audioSource.add(audioBuffer);
|
|
319
338
|
} catch (e) {}
|
|
320
339
|
const totalTime = performance.now() - renderStartTime;
|
|
340
|
+
const avgSeek = totalSeekMs / config.totalFrames;
|
|
341
|
+
const avgSync = totalSyncMs / config.totalFrames;
|
|
342
|
+
const avgRender = totalRenderMs / config.totalFrames;
|
|
343
|
+
const avgEncode = totalEncodeMs / config.totalFrames;
|
|
344
|
+
const avgTotal = totalTime / config.totalFrames;
|
|
345
|
+
const untracked = totalTime - (totalSeekMs + totalSyncMs + totalRenderMs + totalEncodeMs);
|
|
346
|
+
console.log(`\n=== Video Export Performance Breakdown ===`);
|
|
347
|
+
console.log(`Mode: Direct Serialization`);
|
|
348
|
+
console.log(`Total frames: ${config.totalFrames}`);
|
|
349
|
+
console.log(`Total time: ${totalTime.toFixed(0)}ms (${avgTotal.toFixed(1)}ms/frame)`);
|
|
350
|
+
console.log(`\nPer-stage totals:`);
|
|
351
|
+
console.log(` Seek: ${totalSeekMs.toFixed(0)}ms (${(totalSeekMs / totalTime * 100).toFixed(1)}%) - avg ${avgSeek.toFixed(1)}ms/frame`);
|
|
352
|
+
console.log(` Serialize: ${totalSyncMs.toFixed(0)}ms (${(totalSyncMs / totalTime * 100).toFixed(1)}%) - avg ${avgSync.toFixed(1)}ms/frame`);
|
|
353
|
+
console.log(` Render: ${totalRenderMs.toFixed(0)}ms (${(totalRenderMs / totalTime * 100).toFixed(1)}%) - avg ${avgRender.toFixed(1)}ms/frame`);
|
|
354
|
+
console.log(` Encode: ${totalEncodeMs.toFixed(0)}ms (${(totalEncodeMs / totalTime * 100).toFixed(1)}%) - avg ${avgEncode.toFixed(1)}ms/frame`);
|
|
355
|
+
console.log(` Other: ${untracked.toFixed(0)}ms (${(untracked / totalTime * 100).toFixed(1)}%)`);
|
|
356
|
+
console.log(`==========================================\n`);
|
|
321
357
|
logger.debug(`[renderTimegroupToVideo] ${config.totalFrames} frames: seek=${totalSeekMs.toFixed(0)}ms, sync=${totalSyncMs.toFixed(0)}ms, render=${totalRenderMs.toFixed(0)}ms, encode=${totalEncodeMs.toFixed(0)}ms, total=${totalTime.toFixed(0)}ms`);
|
|
322
358
|
if (config.benchmarkMode) return;
|
|
323
359
|
await output.finalize();
|
|
@@ -330,8 +366,9 @@ async function renderTimegroupToVideo(timegroup, options = {}) {
|
|
|
330
366
|
return;
|
|
331
367
|
}
|
|
332
368
|
} finally {
|
|
333
|
-
|
|
369
|
+
renderContext.dispose();
|
|
334
370
|
cleanupRenderClone();
|
|
371
|
+
if (previewContainer.parentNode) previewContainer.parentNode.removeChild(previewContainer);
|
|
335
372
|
}
|
|
336
373
|
}
|
|
337
374
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"renderTimegroupToVideo.js","names":["timestamps: number[]","output: Output | null","videoSource: CanvasSource | null","audioSource: AudioBufferSource | null","target: BufferTarget | StreamTarget | null","fileStream: { writable: WritableStream<Uint8Array>; close: () => Promise<void> } | null","encodingCanvas: OffscreenCanvas | null","encodingCtx: OffscreenCanvasRenderingContext2D | null","videoConfig: VideoEncodingConfig","lastFramePreviewUrl: string | undefined","seekQueue: Promise<void>[]","renderTasks: RenderTask[]","image"],"sources":["../../src/preview/renderTimegroupToVideo.ts"],"sourcesContent":["/**\n * Video rendering for timegroups.\n * \n * Uses the EXACT same rendering path as thumbnail generation (captureFromClone),\n * ensuring consistency between preview thumbnails and exported video.\n */\n\nimport { logger } from \"./logger.js\";\nimport {\n Output,\n Mp4OutputFormat,\n BufferTarget,\n StreamTarget,\n CanvasSource,\n AudioBufferSource,\n QUALITY_HIGH,\n canEncodeAudio,\n getEncodableAudioCodecs,\n type VideoEncodingConfig,\n type AudioEncodingConfig,\n type AudioCodec,\n} from \"mediabunny\";\nimport type { EFTimegroup } from \"../elements/EFTimegroup.js\";\nimport type { EFVideo } from \"../elements/EFVideo.js\";\nimport {\n resetRenderState,\n type ContentReadyMode,\n} from \"./renderTimegroupToCanvas.js\";\nimport {\n buildCloneStructure,\n syncStyles,\n collectDocumentStyles,\n overrideRootCloneStyles,\n // NOTE: Video export does NOT use removeHiddenNodesForSerialization because the\n // concurrent pipeline has multiple frames in flight sharing the same container.\n // If frame N removes node X and frame N+1 needs X, N+1's serialization would be wrong.\n // Instead, hidden nodes get display:none which is sufficient for correctness.\n // Live preview (renderTimegroupToCanvas) uses the full remove/restore optimization.\n} from \"./renderTimegroupPreview.js\";\nimport { renderToImageDirect } from \"./rendering/renderToImage.js\";\nimport { createPreviewContainer } from \"./previewTypes.js\";\nimport { inlineImages } from \"./rendering/inlineImages.js\";\nimport { FrameController } from \"./FrameController.js\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface RenderProgress {\n progress: number;\n currentFrame: number;\n totalFrames: number;\n renderedMs: number;\n totalDurationMs: number;\n elapsedMs: number;\n estimatedRemainingMs: number;\n speedMultiplier: number;\n framePreviewUrl?: string;\n}\n\nexport interface RenderToVideoOptions {\n fps?: number;\n codec?: \"avc\" | \"hevc\" | \"vp9\" | \"av1\" | \"vp8\";\n bitrate?: number;\n filename?: string;\n scale?: number;\n keyFrameInterval?: number;\n fromMs?: number;\n toMs?: number;\n onProgress?: (progress: RenderProgress) => void;\n streaming?: boolean;\n signal?: AbortSignal;\n includeAudio?: boolean;\n audioBitrate?: number;\n contentReadyMode?: ContentReadyMode;\n blockingTimeoutMs?: number;\n returnBuffer?: boolean;\n preferredAudioCodecs?: AudioCodec[];\n benchmarkMode?: boolean;\n customWritableStream?: WritableStream<Uint8Array>; // For programmatic streaming (CLI/Playwright)\n}\n\n// ============================================================================\n// Errors\n// ============================================================================\n\nexport class NoSupportedAudioCodecError extends Error {\n constructor(requestedCodecs: AudioCodec[], availableCodecs: AudioCodec[]) {\n super(\n `No supported audio codec found. Requested: [${requestedCodecs.join(\", \")}], ` +\n `Available: [${availableCodecs.length > 0 ? availableCodecs.join(\", \") : \"none\"}]`\n );\n this.name = \"NoSupportedAudioCodecError\";\n }\n}\n\nexport class RenderCancelledError extends Error {\n constructor() {\n super(\"Render cancelled\");\n this.name = \"RenderCancelledError\";\n }\n}\n\n// ============================================================================\n// Configuration\n// ============================================================================\n\ninterface ResolvedConfig {\n fps: number;\n codec: \"avc\" | \"hevc\" | \"vp9\" | \"av1\" | \"vp8\";\n bitrate: number;\n filename: string;\n scale: number;\n keyFrameInterval: number;\n startMs: number;\n endMs: number;\n renderDurationMs: number;\n videoWidth: number;\n videoHeight: number;\n totalFrames: number;\n frameDurationMs: number;\n frameDurationS: number;\n streaming: boolean;\n includeAudio: boolean;\n audioBitrate: number;\n contentReadyMode: ContentReadyMode;\n blockingTimeoutMs: number;\n returnBuffer: boolean;\n preferredAudioCodecs: AudioCodec[];\n benchmarkMode: boolean;\n}\n\nfunction resolveConfig(\n timegroup: EFTimegroup,\n options: RenderToVideoOptions,\n): ResolvedConfig {\n const fps = options.fps ?? timegroup.effectiveFps ?? 30;\n const codec = options.codec ?? \"avc\";\n const bitrate = options.bitrate ?? 8_000_000;\n const filename = options.filename ?? \"timegroup-video.mp4\";\n const scale = options.scale ?? 1;\n const keyFrameInterval = options.keyFrameInterval ?? 2;\n const streaming = options.streaming ?? true;\n const includeAudio = options.includeAudio ?? true;\n const audioBitrate = options.audioBitrate ?? 128_000;\n const contentReadyMode = options.contentReadyMode ?? \"blocking\";\n const blockingTimeoutMs = options.blockingTimeoutMs ?? 5000;\n const returnBuffer = options.returnBuffer ?? false;\n const preferredAudioCodecs = options.preferredAudioCodecs ?? [\"aac\", \"opus\"];\n const benchmarkMode = options.benchmarkMode ?? false;\n\n const totalDurationMs = timegroup.durationMs;\n if (!totalDurationMs || totalDurationMs <= 0) {\n throw new Error(\"Timegroup has no duration\");\n }\n\n const startMs = Math.max(0, options.fromMs ?? 0);\n const endMs = options.toMs !== undefined ? Math.min(options.toMs, totalDurationMs) : totalDurationMs;\n const renderDurationMs = endMs - startMs;\n \n if (renderDurationMs <= 0) {\n throw new Error(`Invalid render range: from ${startMs}ms to ${endMs}ms`);\n }\n\n const timegroupWidth = timegroup.offsetWidth || 1920;\n const timegroupHeight = timegroup.offsetHeight || 1080;\n const width = Math.floor(timegroupWidth * scale);\n const height = Math.floor(timegroupHeight * scale);\n\n const videoWidth = width % 2 === 0 ? width : width - 1;\n const videoHeight = height % 2 === 0 ? height : height - 1;\n\n const frameDurationMs = 1000 / fps;\n const totalFrames = Math.ceil(renderDurationMs / frameDurationMs);\n const frameDurationS = frameDurationMs / 1000;\n\n return {\n fps,\n codec,\n bitrate,\n filename,\n scale,\n keyFrameInterval,\n startMs,\n endMs,\n renderDurationMs,\n videoWidth,\n videoHeight,\n totalFrames,\n frameDurationMs,\n frameDurationS,\n streaming,\n includeAudio,\n audioBitrate,\n contentReadyMode,\n blockingTimeoutMs,\n returnBuffer,\n preferredAudioCodecs,\n benchmarkMode,\n };\n}\n\n// ============================================================================\n// Helpers\n// ============================================================================\n\nfunction isFileSystemAccessSupported(): boolean {\n return typeof window !== \"undefined\" && \"showSaveFilePicker\" in window;\n}\n\nasync function getFileWritableStream(\n filename: string,\n): Promise<{ writable: WritableStream<Uint8Array>; close: () => Promise<void> } | null> {\n if (!isFileSystemAccessSupported()) {\n return null;\n }\n\n try {\n const fileHandle = await (window as any).showSaveFilePicker({\n suggestedName: filename,\n types: [{ description: \"MP4 Video\", accept: { \"video/mp4\": [\".mp4\"] } }],\n });\n const writable = await fileHandle.createWritable();\n return { writable, close: async () => { await writable.close(); } };\n } catch (e) {\n if ((e as Error).name !== \"AbortError\") {\n logger.warn(\"[renderToVideo] File System Access failed:\", e);\n }\n return null;\n }\n}\n\nasync function selectAudioCodec(\n preferredCodecs: AudioCodec[],\n encodingOptions: { numberOfChannels: number; sampleRate: number; bitrate: number },\n): Promise<AudioCodec> {\n for (const codec of preferredCodecs) {\n try {\n const isSupported = await canEncodeAudio(codec, encodingOptions);\n if (isSupported) return codec;\n } catch (e) {\n logger.warn(`[selectAudioCodec] Check failed for ${codec}:`, e);\n }\n }\n const availableCodecs = await getEncodableAudioCodecs(undefined, encodingOptions);\n throw new NoSupportedAudioCodecError(preferredCodecs, availableCodecs);\n}\n\nfunction downloadBlob(blob: Blob, filename: string): void {\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = filename;\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n URL.revokeObjectURL(url);\n}\n\n// ============================================================================\n// Public API\n// ============================================================================\n\nexport async function getSupportedAudioCodecs(options?: {\n numberOfChannels?: number;\n sampleRate?: number;\n bitrate?: number;\n}): Promise<AudioCodec[]> {\n const { numberOfChannels = 2, sampleRate = 48000, bitrate = 128000 } = options ?? {};\n return getEncodableAudioCodecs(undefined, { numberOfChannels, sampleRate, bitrate });\n}\n\n/**\n * Renders a timegroup to an MP4 video file.\n * \n * Uses the EXACT same code path as thumbnail generation (captureFromClone).\n * This ensures consistency - if thumbnails work, video export works.\n */\nexport async function renderTimegroupToVideo(\n timegroup: EFTimegroup,\n options: RenderToVideoOptions = {},\n): Promise<Uint8Array | undefined> {\n const config = resolveConfig(timegroup, options);\n const { signal, onProgress } = options;\n \n const checkCancelled = () => {\n if (signal?.aborted) throw new RenderCancelledError();\n };\n \n resetRenderState();\n \n // =========================================================================\n // Create render clone - EXACT same as captureBatch in EFTimegroup\n // =========================================================================\n const { clone: renderClone, cleanup: cleanupRenderClone } =\n await timegroup.createRenderClone();\n \n // Pre-fetch main video segments for all timestamps\n // This ensures all segments are cached before rendering starts,\n // avoiding network delays during the frame loop\n const timestamps: number[] = [];\n for (let i = 0; i < config.totalFrames; i++) {\n timestamps.push(config.startMs + i * config.frameDurationMs);\n }\n \n const videoElements = renderClone.querySelectorAll(\"ef-video\");\n if (videoElements.length > 0) {\n logger.debug(`[renderTimegroupToVideo] Prefetching main video segments for ${videoElements.length} video(s)...`);\n await Promise.all(\n Array.from(videoElements).map((video) =>\n (video as EFVideo).prefetchMainVideoSegments(timestamps),\n ),\n );\n logger.debug(`[renderTimegroupToVideo] Prefetch complete`);\n }\n \n // =========================================================================\n // Set up video encoding\n // =========================================================================\n let output: Output | null = null;\n let videoSource: CanvasSource | null = null;\n let audioSource: AudioBufferSource | null = null;\n let target: BufferTarget | StreamTarget | null = null;\n let fileStream: { writable: WritableStream<Uint8Array>; close: () => Promise<void> } | null = null;\n let useStreaming = false;\n let encodingCanvas: OffscreenCanvas | null = null;\n let encodingCtx: OffscreenCanvasRenderingContext2D | null = null;\n \n if (!config.benchmarkMode) {\n // Check for custom writable stream first (for programmatic streaming)\n if (options.customWritableStream) {\n target = new StreamTarget(options.customWritableStream as any);\n output = new Output({\n format: new Mp4OutputFormat({ fastStart: \"fragmented\" }),\n target,\n });\n useStreaming = true;\n } else if (config.streaming) {\n fileStream = await getFileWritableStream(config.filename);\n useStreaming = fileStream !== null;\n \n if (useStreaming && fileStream) {\n target = new StreamTarget(fileStream.writable as any);\n output = new Output({\n format: new Mp4OutputFormat({ fastStart: \"fragmented\" }),\n target,\n });\n }\n }\n \n if (!target) {\n target = new BufferTarget();\n output = new Output({ format: new Mp4OutputFormat(), target });\n }\n \n encodingCanvas = new OffscreenCanvas(config.videoWidth, config.videoHeight);\n encodingCtx = encodingCanvas.getContext(\"2d\");\n if (!encodingCtx) {\n cleanupRenderClone();\n throw new Error(\"Failed to get encoding canvas context\");\n }\n \n if (!output) {\n throw new Error(\"Output not initialized\");\n }\n \n const videoConfig: VideoEncodingConfig = {\n codec: config.codec,\n bitrate: config.bitrate,\n keyFrameInterval: config.keyFrameInterval,\n };\n videoSource = new CanvasSource(encodingCanvas, videoConfig);\n output.addVideoTrack(videoSource);\n \n if (config.includeAudio) {\n const selectedCodec = await selectAudioCodec(config.preferredAudioCodecs, {\n numberOfChannels: 2,\n sampleRate: 48000,\n bitrate: config.audioBitrate,\n });\n const audioConfig: AudioEncodingConfig = {\n codec: selectedCodec,\n bitrate: config.audioBitrate,\n };\n audioSource = new AudioBufferSource(audioConfig);\n output.addAudioTrack(audioSource);\n }\n \n await output.start();\n }\n \n // =========================================================================\n // Build clone structure ONCE - reuse like live preview does\n // =========================================================================\n const initialTimeMs = config.startMs;\n await renderClone.seekForRender(initialTimeMs);\n const { container: cloneContainer, syncState } = buildCloneStructure(renderClone, initialTimeMs);\n \n // Create FrameController for coordinating element rendering\n const frameController = new FrameController(renderClone);\n \n // Create preview container with proper styling\n const width = timegroup.offsetWidth || 1920;\n const height = timegroup.offsetHeight || 1080;\n const previewContainer = createPreviewContainer({\n width,\n height,\n background: getComputedStyle(timegroup).background || \"#000\",\n });\n \n // Inject document styles\n const styleEl = document.createElement(\"style\");\n styleEl.textContent = collectDocumentStyles();\n previewContainer.appendChild(styleEl);\n previewContainer.appendChild(cloneContainer);\n overrideRootCloneStyles(syncState, true);\n \n // =========================================================================\n // Frame loop - DEEP PIPELINE: overlap encode + render + prepare\n // =========================================================================\n const renderStartTime = performance.now();\n let lastFramePreviewUrl: string | undefined;\n let lastRenderedAudioEndMs = config.startMs;\n const audioChunkDurationMs = 2000;\n \n let totalSeekMs = 0;\n let totalSyncMs = 0;\n let totalRenderMs = 0;\n let totalEncodeMs = 0;\n \n try {\n // ========================================================================\n // DEEP PIPELINE: 3-4 frames ahead with operation queues\n // ========================================================================\n // Maintain queues of in-flight work (like the reference architecture)\n type RenderTask = { frameIndex: number; timeMs: number; timestampS: number; promise: Promise<HTMLImageElement> };\n const seekQueue: Promise<void>[] = [];\n const renderTasks: RenderTask[] = [];\n \n // Pipeline depth configuration\n // NOTE: Set to 1 for correctness - parallel seeks cause duplicate frames\n // TODO: Investigate why parallel seeks don't work with the clone structure\n const MAX_SEEK = 1;\n const MAX_RENDER = 1;\n \n let nextSeekFrame = 0;\n let nextRenderFrame = 0;\n \n // Inline external images once (they're the same for all frames)\n await inlineImages(previewContainer);\n \n for (let completedFrames = 0; completedFrames < config.totalFrames; completedFrames++) {\n checkCancelled();\n \n const frameIndex = completedFrames;\n const timeMs = timestamps[frameIndex]!;\n const timestampS = (frameIndex * config.frameDurationMs) / 1000;\n \n // =====================================================================\n // STAGE 1: Fill seek queue (don't block!)\n // =====================================================================\n while (seekQueue.length < MAX_SEEK && nextSeekFrame < config.totalFrames) {\n const seekFrameIndex = nextSeekFrame;\n const seekTimeMs = timestamps[seekFrameIndex]!;\n \n const seekStart = performance.now();\n const seekPromise = renderClone.seekForRender(seekTimeMs).then(() => {\n totalSeekMs += performance.now() - seekStart;\n });\n seekQueue.push(seekPromise);\n nextSeekFrame++;\n }\n \n // =====================================================================\n // STAGE 2: Fill render queue (don't block!)\n // =====================================================================\n while (renderTasks.length < MAX_RENDER && seekQueue.length > 0 && nextRenderFrame < config.totalFrames) {\n const renderFrameIndex = nextRenderFrame;\n const renderTimeMs = timestamps[renderFrameIndex]!;\n const renderTimestampS = (renderFrameIndex * config.frameDurationMs) / 1000;\n const seekPromise = seekQueue.shift()!;\n \n const renderPromise = seekPromise.then(async () => {\n // Ensure all FrameRenderable elements are ready before capturing state\n await frameController.renderFrame(renderTimeMs, { waitForLitUpdate: false });\n \n const syncStart = performance.now();\n syncStyles(syncState, renderTimeMs);\n overrideRootCloneStyles(syncState, true);\n totalSyncMs += performance.now() - syncStart;\n \n const renderStart = performance.now();\n const image = await renderToImageDirect(previewContainer, width, height);\n totalRenderMs += performance.now() - renderStart;\n \n return image;\n });\n \n renderTasks.push({\n frameIndex: renderFrameIndex,\n timeMs: renderTimeMs,\n timestampS: renderTimestampS,\n promise: renderPromise,\n });\n nextRenderFrame++;\n }\n \n // =====================================================================\n // STAGE 3: Await the render for THIS frame (in strict order)\n // =====================================================================\n const taskIndex = renderTasks.findIndex((t) => t.frameIndex === frameIndex);\n if (taskIndex === -1) {\n throw new Error(`No render task found for frame ${frameIndex}`);\n }\n \n const task = renderTasks[taskIndex]!;\n const image = await task.promise;\n renderTasks.splice(taskIndex, 1);\n \n // =====================================================================\n // STAGE 4: Render audio chunk if needed\n // =====================================================================\n if (audioSource && timeMs >= lastRenderedAudioEndMs + audioChunkDurationMs) {\n const chunkEndMs = Math.min(timeMs + audioChunkDurationMs, config.endMs);\n try {\n const audioBuffer = await timegroup.renderAudio(lastRenderedAudioEndMs, chunkEndMs);\n if (audioBuffer && audioBuffer.length > 0) {\n await audioSource.add(audioBuffer);\n }\n } catch (e) { /* Audio render failures are non-fatal */ }\n lastRenderedAudioEndMs = chunkEndMs;\n }\n \n // =====================================================================\n // STAGE 5: Encode frame (sequential, maintains order)\n // =====================================================================\n if (videoSource && output && encodingCtx) {\n const encodeStart = performance.now();\n encodingCtx.drawImage(\n image,\n 0, 0, image.width, image.height,\n 0, 0, config.videoWidth, config.videoHeight,\n );\n await videoSource.add(timestampS, config.frameDurationS);\n totalEncodeMs += performance.now() - encodeStart;\n }\n \n // =====================================================================\n // STAGE 6: Progress reporting\n // =====================================================================\n const currentFrame = frameIndex + 1;\n const progress = currentFrame / config.totalFrames;\n const renderedMs = currentFrame * config.frameDurationMs;\n const elapsedMs = performance.now() - renderStartTime;\n const msPerFrame = elapsedMs / currentFrame;\n const remainingFrames = config.totalFrames - currentFrame;\n const estimatedRemainingMs = remainingFrames * msPerFrame;\n const speedMultiplier = renderedMs / elapsedMs;\n \n if (onProgress && frameIndex % 10 === 0) {\n const previewWidth = 160;\n const previewHeight = Math.round(previewWidth * (config.videoHeight / config.videoWidth));\n const thumbCanvas = document.createElement(\"canvas\");\n thumbCanvas.width = previewWidth;\n thumbCanvas.height = previewHeight;\n const thumbCtx = thumbCanvas.getContext(\"2d\")!;\n thumbCtx.drawImage(image, 0, 0, previewWidth, previewHeight);\n lastFramePreviewUrl = thumbCanvas.toDataURL(\"image/jpeg\", 0.7);\n }\n \n onProgress?.({\n progress,\n currentFrame,\n totalFrames: config.totalFrames,\n renderedMs,\n totalDurationMs: config.renderDurationMs,\n elapsedMs,\n estimatedRemainingMs,\n speedMultiplier,\n framePreviewUrl: lastFramePreviewUrl,\n });\n }\n \n // Render remaining audio\n if (audioSource && lastRenderedAudioEndMs < config.endMs) {\n try {\n const audioBuffer = await timegroup.renderAudio(lastRenderedAudioEndMs, config.endMs);\n if (audioBuffer && audioBuffer.length > 0) {\n await audioSource.add(audioBuffer);\n }\n } catch (e) { /* Audio render failures are non-fatal */ }\n }\n \n const totalTime = performance.now() - renderStartTime;\n logger.debug(\n `[renderTimegroupToVideo] ${config.totalFrames} frames: ` +\n `seek=${totalSeekMs.toFixed(0)}ms, sync=${totalSyncMs.toFixed(0)}ms, ` +\n `render=${totalRenderMs.toFixed(0)}ms, encode=${totalEncodeMs.toFixed(0)}ms, ` +\n `total=${totalTime.toFixed(0)}ms`\n );\n \n if (config.benchmarkMode) {\n return undefined;\n }\n \n await output!.finalize();\n \n if (useStreaming) {\n // Streaming mode: chunks already sent via customWritableStream or file stream\n return undefined;\n } else {\n const bufferTarget = target as BufferTarget;\n const videoBuffer = bufferTarget.buffer;\n if (!videoBuffer) {\n throw new Error(\"Video encoding failed: no buffer produced\");\n }\n \n if (config.returnBuffer) {\n return new Uint8Array(videoBuffer);\n }\n \n const videoBlob = new Blob([videoBuffer], { type: \"video/mp4\" });\n downloadBlob(videoBlob, config.filename);\n return undefined;\n }\n \n } finally {\n frameController.abort();\n cleanupRenderClone();\n }\n}\n\nexport { QUALITY_HIGH };\nexport type { AudioCodec };\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"renderTimegroupToVideo.js","names":["timestamps: number[]","output: Output | null","videoSource: CanvasSource | null","audioSource: AudioBufferSource | null","target: BufferTarget | StreamTarget | null","fileStream: { writable: WritableStream<Uint8Array>; close: () => Promise<void> } | null","encodingCanvas: OffscreenCanvas | null","encodingCtx: OffscreenCanvasRenderingContext2D | null","videoConfig: VideoEncodingConfig","thumbCanvas: HTMLCanvasElement | null","thumbCtx: CanvasRenderingContext2D | null","seekQueue: Promise<void>[]","renderTasks: RenderTask[]","image"],"sources":["../../src/preview/renderTimegroupToVideo.ts"],"sourcesContent":["/**\n * Video rendering for timegroups using direct serialization.\n * \n * Architecture:\n * - Creates a render clone of the timeline\n * - For each frame:\n * 1. Seeks the clone to the target time\n * 2. Executes frame tasks (SVG updates, canvas draws, etc.)\n * 3. Serializes the live DOM directly to SVG+foreignObject data URI\n * 4. Renders to image and encodes to video\n * \n * RenderContext provides pixel caching across frames for performance.\n */\n\nimport { logger } from \"./logger.js\";\nimport {\n Output,\n Mp4OutputFormat,\n BufferTarget,\n StreamTarget,\n CanvasSource,\n AudioBufferSource,\n QUALITY_HIGH,\n canEncodeAudio,\n getEncodableAudioCodecs,\n type VideoEncodingConfig,\n type AudioEncodingConfig,\n type AudioCodec,\n} from \"mediabunny\";\nimport type { EFTimegroup } from \"../elements/EFTimegroup.js\";\nimport type { EFVideo } from \"../elements/EFVideo.js\";\nimport {\n resetRenderState,\n type ContentReadyMode,\n} from \"./renderTimegroupToCanvas.js\";\nimport { serializeTimelineToDataUri } from \"./rendering/serializeTimelineDirect.js\";\nimport { createPreviewContainer } from \"./previewTypes.js\";\nimport { RenderContext } from \"./RenderContext.js\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface RenderProgress {\n progress: number;\n currentFrame: number;\n totalFrames: number;\n renderedMs: number;\n totalDurationMs: number;\n elapsedMs: number;\n estimatedRemainingMs: number;\n speedMultiplier: number;\n framePreviewCanvas?: HTMLCanvasElement; // Canvas with current frame (updated async, no encoding cost)\n}\n\nexport interface RenderToVideoOptions {\n fps?: number;\n codec?: \"avc\" | \"hevc\" | \"vp9\" | \"av1\" | \"vp8\";\n bitrate?: number;\n filename?: string;\n scale?: number;\n keyFrameInterval?: number;\n fromMs?: number;\n toMs?: number;\n onProgress?: (progress: RenderProgress) => void;\n streaming?: boolean;\n signal?: AbortSignal;\n includeAudio?: boolean;\n audioBitrate?: number;\n contentReadyMode?: ContentReadyMode;\n blockingTimeoutMs?: number;\n returnBuffer?: boolean;\n preferredAudioCodecs?: AudioCodec[];\n benchmarkMode?: boolean;\n customWritableStream?: WritableStream<Uint8Array>; // For programmatic streaming (CLI/Playwright)\n progressPreviewInterval?: number; // How often to generate preview thumbnails (default: 60 frames, 0 = disabled)\n}\n\n// ============================================================================\n// Errors\n// ============================================================================\n\nexport class NoSupportedAudioCodecError extends Error {\n constructor(requestedCodecs: AudioCodec[], availableCodecs: AudioCodec[]) {\n super(\n `No supported audio codec found. Requested: [${requestedCodecs.join(\", \")}], ` +\n `Available: [${availableCodecs.length > 0 ? availableCodecs.join(\", \") : \"none\"}]`\n );\n this.name = \"NoSupportedAudioCodecError\";\n }\n}\n\nexport class RenderCancelledError extends Error {\n constructor() {\n super(\"Render cancelled\");\n this.name = \"RenderCancelledError\";\n }\n}\n\n// ============================================================================\n// Configuration\n// ============================================================================\n\ninterface ResolvedConfig {\n fps: number;\n codec: \"avc\" | \"hevc\" | \"vp9\" | \"av1\" | \"vp8\";\n bitrate: number;\n filename: string;\n scale: number;\n keyFrameInterval: number;\n startMs: number;\n endMs: number;\n renderDurationMs: number;\n width: number;\n height: number;\n videoWidth: number;\n videoHeight: number;\n totalFrames: number;\n frameDurationMs: number;\n frameDurationS: number;\n streaming: boolean;\n includeAudio: boolean;\n audioBitrate: number;\n contentReadyMode: ContentReadyMode;\n blockingTimeoutMs: number;\n returnBuffer: boolean;\n preferredAudioCodecs: AudioCodec[];\n benchmarkMode: boolean;\n progressPreviewInterval: number;\n}\n\nfunction resolveConfig(\n timegroup: EFTimegroup,\n options: RenderToVideoOptions,\n): ResolvedConfig {\n const fps = options.fps ?? timegroup.effectiveFps ?? 30;\n const codec = options.codec ?? \"avc\";\n const bitrate = options.bitrate ?? 8_000_000;\n const filename = options.filename ?? \"timegroup-video.mp4\";\n const scale = options.scale ?? 1;\n const keyFrameInterval = options.keyFrameInterval ?? 2;\n const streaming = options.streaming ?? true;\n const includeAudio = options.includeAudio ?? true;\n const audioBitrate = options.audioBitrate ?? 128_000;\n const contentReadyMode = options.contentReadyMode ?? \"blocking\";\n const blockingTimeoutMs = options.blockingTimeoutMs ?? 5000;\n const returnBuffer = options.returnBuffer ?? false;\n const preferredAudioCodecs = options.preferredAudioCodecs ?? [\"aac\", \"opus\"];\n const benchmarkMode = options.benchmarkMode ?? false;\n // Preview generation now uses canvas reference (no encoding) - cheap to enable!\n // Defaults to 60 frames (every 2 seconds at 30fps). Set to 0 to disable.\n const progressPreviewInterval = options.progressPreviewInterval ?? 60;\n\n const totalDurationMs = timegroup.durationMs;\n if (!totalDurationMs || totalDurationMs <= 0) {\n throw new Error(\"Timegroup has no duration\");\n }\n\n const startMs = Math.max(0, options.fromMs ?? 0);\n const endMs = options.toMs !== undefined ? Math.min(options.toMs, totalDurationMs) : totalDurationMs;\n const renderDurationMs = endMs - startMs;\n \n if (renderDurationMs <= 0) {\n throw new Error(`Invalid render range: from ${startMs}ms to ${endMs}ms`);\n }\n\n // Force layout reflow before reading dimensions\n void timegroup.offsetHeight;\n \n const timegroupWidth = timegroup.offsetWidth;\n const timegroupHeight = timegroup.offsetHeight;\n \n console.log(`[renderTimegroupToVideo] Timegroup dimensions: ${timegroupWidth}x${timegroupHeight}`);\n console.log(`[renderTimegroupToVideo] Computed style:`, getComputedStyle(timegroup).width, getComputedStyle(timegroup).height);\n console.log(`[renderTimegroupToVideo] BoundingClientRect:`, timegroup.getBoundingClientRect());\n \n if (!timegroupWidth || !timegroupHeight) {\n throw new Error(\n `Timegroup has no dimensions (${timegroupWidth}x${timegroupHeight}). ` +\n `Ensure the timegroup element is in the document and has explicit width/height styles ` +\n `(e.g., class=\"w-[1920px] h-[1080px]\")`\n );\n }\n const width = Math.floor(timegroupWidth * scale);\n const height = Math.floor(timegroupHeight * scale);\n\n const videoWidth = width % 2 === 0 ? width : width - 1;\n const videoHeight = height % 2 === 0 ? height : height - 1;\n\n const frameDurationMs = 1000 / fps;\n const totalFrames = Math.ceil(renderDurationMs / frameDurationMs);\n const frameDurationS = frameDurationMs / 1000;\n\n return {\n fps,\n codec,\n bitrate,\n filename,\n scale,\n keyFrameInterval,\n startMs,\n endMs,\n renderDurationMs,\n width,\n height,\n videoWidth,\n videoHeight,\n totalFrames,\n frameDurationMs,\n frameDurationS,\n streaming,\n includeAudio,\n audioBitrate,\n contentReadyMode,\n blockingTimeoutMs,\n returnBuffer,\n preferredAudioCodecs,\n benchmarkMode,\n progressPreviewInterval,\n };\n}\n\n// ============================================================================\n// Helpers\n// ============================================================================\n\nfunction isFileSystemAccessSupported(): boolean {\n return typeof window !== \"undefined\" && \"showSaveFilePicker\" in window;\n}\n\nasync function getFileWritableStream(\n filename: string,\n): Promise<{ writable: WritableStream<Uint8Array>; close: () => Promise<void> } | null> {\n if (!isFileSystemAccessSupported()) {\n return null;\n }\n\n try {\n const fileHandle = await (window as any).showSaveFilePicker({\n suggestedName: filename,\n types: [{ description: \"MP4 Video\", accept: { \"video/mp4\": [\".mp4\"] } }],\n });\n const writable = await fileHandle.createWritable();\n return { writable, close: async () => { await writable.close(); } };\n } catch (e) {\n if ((e as Error).name !== \"AbortError\") {\n logger.warn(\"[renderToVideo] File System Access failed:\", e);\n }\n return null;\n }\n}\n\nasync function selectAudioCodec(\n preferredCodecs: AudioCodec[],\n encodingOptions: { numberOfChannels: number; sampleRate: number; bitrate: number },\n): Promise<AudioCodec> {\n for (const codec of preferredCodecs) {\n try {\n const isSupported = await canEncodeAudio(codec, encodingOptions);\n if (isSupported) return codec;\n } catch (e) {\n logger.warn(`[selectAudioCodec] Check failed for ${codec}:`, e);\n }\n }\n const availableCodecs = await getEncodableAudioCodecs(undefined, encodingOptions);\n throw new NoSupportedAudioCodecError(preferredCodecs, availableCodecs);\n}\n\nfunction downloadBlob(blob: Blob, filename: string): void {\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = filename;\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n URL.revokeObjectURL(url);\n}\n\n// ============================================================================\n// Public API\n// ============================================================================\n\nexport async function getSupportedAudioCodecs(options?: {\n numberOfChannels?: number;\n sampleRate?: number;\n bitrate?: number;\n}): Promise<AudioCodec[]> {\n const { numberOfChannels = 2, sampleRate = 48000, bitrate = 128000 } = options ?? {};\n return getEncodableAudioCodecs(undefined, { numberOfChannels, sampleRate, bitrate });\n}\n\n/**\n * Renders a timegroup to an MP4 video file.\n * \n * Uses the EXACT same code path as thumbnail generation (captureFromClone).\n * This ensures consistency - if thumbnails work, video export works.\n */\nexport async function renderTimegroupToVideo(\n timegroup: EFTimegroup,\n options: RenderToVideoOptions = {},\n): Promise<Uint8Array | undefined> {\n const config = resolveConfig(timegroup, options);\n const { signal, onProgress } = options;\n \n const checkCancelled = () => {\n if (signal?.aborted) throw new RenderCancelledError();\n };\n \n resetRenderState();\n \n // =========================================================================\n // Create render clone - EXACT same as captureBatch in EFTimegroup\n // =========================================================================\n const { clone: renderClone, cleanup: cleanupRenderClone } =\n await timegroup.createRenderClone();\n \n // Pre-fetch main video segments for all timestamps\n // This ensures all segments are cached before rendering starts,\n // avoiding network delays during the frame loop\n const timestamps: number[] = [];\n for (let i = 0; i < config.totalFrames; i++) {\n timestamps.push(config.startMs + i * config.frameDurationMs);\n }\n \n const videoElements = renderClone.querySelectorAll(\"ef-video\");\n if (videoElements.length > 0) {\n logger.debug(`[renderTimegroupToVideo] Prefetching main video segments for ${videoElements.length} video(s)...`);\n await Promise.all(\n Array.from(videoElements).map((video) =>\n (video as EFVideo).prefetchMainVideoSegments(timestamps),\n ),\n );\n logger.debug(`[renderTimegroupToVideo] Prefetch complete`);\n }\n \n // =========================================================================\n // Set up video encoding\n // =========================================================================\n let output: Output | null = null;\n let videoSource: CanvasSource | null = null;\n let audioSource: AudioBufferSource | null = null;\n let target: BufferTarget | StreamTarget | null = null;\n let fileStream: { writable: WritableStream<Uint8Array>; close: () => Promise<void> } | null = null;\n let useStreaming = false;\n let encodingCanvas: OffscreenCanvas | null = null;\n let encodingCtx: OffscreenCanvasRenderingContext2D | null = null;\n \n if (!config.benchmarkMode) {\n // Check for custom writable stream first (for programmatic streaming)\n if (options.customWritableStream) {\n target = new StreamTarget(options.customWritableStream as any);\n output = new Output({\n format: new Mp4OutputFormat({ fastStart: \"fragmented\" }),\n target,\n });\n useStreaming = true;\n } else if (config.streaming) {\n fileStream = await getFileWritableStream(config.filename);\n useStreaming = fileStream !== null;\n \n if (useStreaming && fileStream) {\n target = new StreamTarget(fileStream.writable as any);\n output = new Output({\n format: new Mp4OutputFormat({ fastStart: \"fragmented\" }),\n target,\n });\n }\n }\n \n if (!target) {\n target = new BufferTarget();\n output = new Output({ format: new Mp4OutputFormat(), target });\n }\n \n encodingCanvas = new OffscreenCanvas(config.videoWidth, config.videoHeight);\n encodingCtx = encodingCanvas.getContext(\"2d\");\n if (!encodingCtx) {\n cleanupRenderClone();\n throw new Error(\"Failed to get encoding canvas context\");\n }\n \n if (!output) {\n throw new Error(\"Output not initialized\");\n }\n \n const videoConfig: VideoEncodingConfig = {\n codec: config.codec,\n bitrate: config.bitrate,\n keyFrameInterval: config.keyFrameInterval,\n };\n videoSource = new CanvasSource(encodingCanvas, videoConfig);\n output.addVideoTrack(videoSource);\n \n if (config.includeAudio) {\n const selectedCodec = await selectAudioCodec(config.preferredAudioCodecs, {\n numberOfChannels: 2,\n sampleRate: 48000,\n bitrate: config.audioBitrate,\n });\n const audioConfig: AudioEncodingConfig = {\n codec: selectedCodec,\n bitrate: config.audioBitrate,\n };\n audioSource = new AudioBufferSource(audioConfig);\n output.addAudioTrack(audioSource);\n }\n \n await output.start();\n }\n \n // =========================================================================\n // Setup for per-frame passive structure rebuilding (like live preview)\n // =========================================================================\n // Create RenderContext for caching across all frames\n const renderContext = new RenderContext();\n \n // Create preview container with proper styling (reusable, content rebuilt each frame)\n // Use unscaled dimensions for the preview container (which holds the full-size clone)\n const containerWidth = timegroup.offsetWidth || 1920;\n const containerHeight = timegroup.offsetHeight || 1080;\n const previewContainer = createPreviewContainer({\n width: containerWidth,\n height: containerHeight,\n background: getComputedStyle(timegroup).background || \"#000\",\n });\n \n // Setup for direct serialization\n console.log(`[renderTimegroupToVideo] Using direct timeline serialization`);\n \n // Attach renderClone to container\n previewContainer.appendChild(renderClone);\n \n // CRITICAL: Add ef-render-clone-container class so isRenderClone() returns true\n // This affects animation tracking - without it, the animation system treats the clone\n // as the prime timeline, which causes incorrect behavior\n previewContainer.classList.add('ef-render-clone-container');\n \n // CRITICAL: Attach container to document so getComputedStyle returns actual values\n // Without this, all computed styles are empty strings!\n // Hide the container OFF-SCREEN but do NOT use visibility:hidden because:\n // 1. visibility:hidden is inherited by all children\n // 2. seekForRender checks getComputedStyle().visibility and skips \"hidden\" subtrees\n // 3. This would cause FrameController to skip rendering all nested content\n previewContainer.style.cssText += ';position:fixed;left:-99999px;top:-99999px;pointer-events:none;';\n document.body.appendChild(previewContainer);\n \n // Force layout/reflow so getComputedStyle returns correct values\n void renderClone.offsetHeight;\n console.log(`[renderTimegroupToVideo] Attached previewContainer to document.body (off-screen) for style computation`);\n \n // =========================================================================\n // Frame loop - DEEP PIPELINE: overlap encode + render + prepare\n // =========================================================================\n const renderStartTime = performance.now();\n let lastRenderedAudioEndMs = config.startMs;\n const audioChunkDurationMs = 2000;\n \n // Reusable thumbnail canvas for preview (no encoding, just draw to canvas)\n let thumbCanvas: HTMLCanvasElement | null = null;\n let thumbCtx: CanvasRenderingContext2D | null = null;\n if (onProgress && config.progressPreviewInterval > 0) {\n const previewWidth = 160;\n const previewHeight = Math.round(previewWidth * (config.videoHeight / config.videoWidth));\n thumbCanvas = document.createElement(\"canvas\");\n thumbCanvas.width = previewWidth;\n thumbCanvas.height = previewHeight;\n thumbCtx = thumbCanvas.getContext(\"2d\");\n }\n \n let totalSeekMs = 0;\n let totalSyncMs = 0;\n let totalRenderMs = 0;\n let totalEncodeMs = 0;\n \n try {\n // ========================================================================\n // DEEP PIPELINE: 3-4 frames ahead with operation queues\n // ========================================================================\n // Maintain queues of in-flight work (like the reference architecture)\n type RenderTask = { frameIndex: number; timeMs: number; timestampS: number; promise: Promise<HTMLImageElement> };\n const seekQueue: Promise<void>[] = [];\n const renderTasks: RenderTask[] = [];\n \n // Pipeline depth configuration\n // MAX_SEEK must be 1: Only one clone exists, so seeks must be sequential\n // MAX_RENDER can be higher: serializeElement captures DOM state synchronously,\n // then canvas encoding and image loading happen async and don't touch the clone\n const MAX_SEEK = 1;\n const MAX_RENDER = 4; // Allow 4 frames to encode/load in parallel (seek, serialize, encode, load)\n \n let nextSeekFrame = 0;\n let nextRenderFrame = 0;\n \n for (let completedFrames = 0; completedFrames < config.totalFrames; completedFrames++) {\n checkCancelled();\n \n const frameIndex = completedFrames;\n const timeMs = timestamps[frameIndex]!;\n const timestampS = (frameIndex * config.frameDurationMs) / 1000;\n \n // =====================================================================\n // STAGE 1: Fill seek queue (don't block!)\n // =====================================================================\n while (seekQueue.length < MAX_SEEK && nextSeekFrame < config.totalFrames) {\n const seekFrameIndex = nextSeekFrame;\n const seekTimeMs = timestamps[seekFrameIndex]!;\n \n const seekStart = performance.now();\n const seekPromise = renderClone.seekForRender(seekTimeMs).then(() => {\n totalSeekMs += performance.now() - seekStart;\n });\n seekQueue.push(seekPromise);\n nextSeekFrame++;\n }\n \n // =====================================================================\n // STAGE 2: Fill render queue (don't block!)\n // =====================================================================\n while (renderTasks.length < MAX_RENDER && seekQueue.length > 0 && nextRenderFrame < config.totalFrames) {\n const renderFrameIndex = nextRenderFrame;\n const renderTimeMs = timestamps[renderFrameIndex]!;\n const renderTimestampS = (renderFrameIndex * config.frameDurationMs) / 1000;\n const seekPromise = seekQueue.shift()!;\n \n const renderPromise = seekPromise.then(async () => {\n // NOTE: seekForRender() has already:\n // 1. Called frameController.renderFrame() to coordinate FrameRenderable elements\n // 2. Awaited #executeCustomFrameTasks() so frame tasks are complete\n // Clone's DOM now reflects all changes from frame tasks\n \n // Direct serialization: serialize timeline to data URI in one pass\n const syncStart = performance.now();\n const dataUri = await serializeTimelineToDataUri(renderClone, config.width, config.height, {\n renderContext,\n canvasScale: config.scale,\n timeMs: renderTimeMs,\n });\n const syncTime = performance.now() - syncStart;\n totalSyncMs += syncTime;\n \n // Create image from data URI\n const renderStart = performance.now();\n const image = new Image();\n await new Promise<void>((resolve, reject) => {\n image.onload = () => resolve();\n image.onerror = (e) => {\n console.error(`[Frame ${renderFrameIndex}] Image load error:`, e);\n console.error(`[Frame ${renderFrameIndex}] Data URI preview:`, dataUri.substring(0, 200) + '...');\n reject(new Error(`Failed to load image from data URI`));\n };\n image.src = dataUri;\n });\n const renderTime = performance.now() - renderStart;\n totalRenderMs += renderTime;\n \n if (renderFrameIndex % 30 === 0) {\n console.log(`[Frame ${renderFrameIndex}] Image loaded: ${image.width}x${image.height}`);\n }\n \n // Log detailed timing every 30 frames to see breakdown\n if (renderFrameIndex % 30 === 0) {\n console.log(`[Frame ${renderFrameIndex}] serialize=${syncTime.toFixed(1)}ms`);\n }\n \n return image;\n });\n \n renderTasks.push({\n frameIndex: renderFrameIndex,\n timeMs: renderTimeMs,\n timestampS: renderTimestampS,\n promise: renderPromise,\n });\n nextRenderFrame++;\n }\n \n // =====================================================================\n // STAGE 3: Await the render for THIS frame (in strict order)\n // =====================================================================\n const taskIndex = renderTasks.findIndex((t) => t.frameIndex === frameIndex);\n if (taskIndex === -1) {\n throw new Error(`No render task found for frame ${frameIndex}`);\n }\n \n const task = renderTasks[taskIndex]!;\n const image = await task.promise;\n renderTasks.splice(taskIndex, 1);\n \n // =====================================================================\n // STAGE 4: Render audio chunk if needed\n // =====================================================================\n if (audioSource && timeMs >= lastRenderedAudioEndMs + audioChunkDurationMs) {\n const chunkEndMs = Math.min(timeMs + audioChunkDurationMs, config.endMs);\n try {\n const audioBuffer = await timegroup.renderAudio(lastRenderedAudioEndMs, chunkEndMs);\n if (audioBuffer && audioBuffer.length > 0) {\n await audioSource.add(audioBuffer);\n }\n } catch (e) { /* Audio render failures are non-fatal */ }\n lastRenderedAudioEndMs = chunkEndMs;\n }\n \n // =====================================================================\n // STAGE 5: Encode frame (sequential, maintains order)\n // =====================================================================\n if (videoSource && output && encodingCtx) {\n const encodeStart = performance.now();\n encodingCtx.drawImage(\n image,\n 0, 0, image.width, image.height,\n 0, 0, config.videoWidth, config.videoHeight,\n );\n await videoSource.add(timestampS, config.frameDurationS);\n totalEncodeMs += performance.now() - encodeStart;\n }\n \n // =====================================================================\n // STAGE 6: Progress reporting\n // =====================================================================\n const currentFrame = frameIndex + 1;\n const progress = currentFrame / config.totalFrames;\n const renderedMs = currentFrame * config.frameDurationMs;\n const elapsedMs = performance.now() - renderStartTime;\n const msPerFrame = elapsedMs / currentFrame;\n const remainingFrames = config.totalFrames - currentFrame;\n const estimatedRemainingMs = remainingFrames * msPerFrame;\n const speedMultiplier = renderedMs / elapsedMs;\n \n // Update preview canvas if enabled (just draw, no encoding - super fast!)\n // The canvas reference is passed to onProgress and can be displayed directly in UI\n if (thumbCanvas && thumbCtx && frameIndex % config.progressPreviewInterval === 0) {\n thumbCtx.drawImage(image, 0, 0, thumbCanvas.width, thumbCanvas.height);\n }\n \n onProgress?.({\n progress,\n currentFrame,\n totalFrames: config.totalFrames,\n renderedMs,\n totalDurationMs: config.renderDurationMs,\n elapsedMs,\n estimatedRemainingMs,\n speedMultiplier,\n framePreviewCanvas: thumbCanvas || undefined, // Pass canvas reference (no encoding!)\n });\n }\n \n // Render remaining audio\n if (audioSource && lastRenderedAudioEndMs < config.endMs) {\n try {\n const audioBuffer = await timegroup.renderAudio(lastRenderedAudioEndMs, config.endMs);\n if (audioBuffer && audioBuffer.length > 0) {\n await audioSource.add(audioBuffer);\n }\n } catch (e) { /* Audio render failures are non-fatal */ }\n }\n \n const totalTime = performance.now() - renderStartTime;\n \n // Calculate percentages and averages for performance analysis\n const avgSeek = totalSeekMs / config.totalFrames;\n const avgSync = totalSyncMs / config.totalFrames;\n const avgRender = totalRenderMs / config.totalFrames;\n const avgEncode = totalEncodeMs / config.totalFrames;\n const avgTotal = totalTime / config.totalFrames;\n \n const tracked = totalSeekMs + totalSyncMs + totalRenderMs + totalEncodeMs;\n const untracked = totalTime - tracked;\n \n console.log(`\\n=== Video Export Performance Breakdown ===`);\n console.log(`Mode: Direct Serialization`);\n console.log(`Total frames: ${config.totalFrames}`);\n console.log(`Total time: ${totalTime.toFixed(0)}ms (${avgTotal.toFixed(1)}ms/frame)`);\n console.log(`\\nPer-stage totals:`);\n console.log(` Seek: ${totalSeekMs.toFixed(0)}ms (${(totalSeekMs/totalTime*100).toFixed(1)}%) - avg ${avgSeek.toFixed(1)}ms/frame`);\n console.log(` Serialize: ${totalSyncMs.toFixed(0)}ms (${(totalSyncMs/totalTime*100).toFixed(1)}%) - avg ${avgSync.toFixed(1)}ms/frame`);\n console.log(` Render: ${totalRenderMs.toFixed(0)}ms (${(totalRenderMs/totalTime*100).toFixed(1)}%) - avg ${avgRender.toFixed(1)}ms/frame`);\n console.log(` Encode: ${totalEncodeMs.toFixed(0)}ms (${(totalEncodeMs/totalTime*100).toFixed(1)}%) - avg ${avgEncode.toFixed(1)}ms/frame`);\n console.log(` Other: ${untracked.toFixed(0)}ms (${(untracked/totalTime*100).toFixed(1)}%)`);\n console.log(`==========================================\\n`);\n \n logger.debug(\n `[renderTimegroupToVideo] ${config.totalFrames} frames: ` +\n `seek=${totalSeekMs.toFixed(0)}ms, sync=${totalSyncMs.toFixed(0)}ms, ` +\n `render=${totalRenderMs.toFixed(0)}ms, encode=${totalEncodeMs.toFixed(0)}ms, ` +\n `total=${totalTime.toFixed(0)}ms`\n );\n \n if (config.benchmarkMode) {\n return undefined;\n }\n \n await output!.finalize();\n \n if (useStreaming) {\n // Streaming mode: chunks already sent via customWritableStream or file stream\n return undefined;\n } else {\n const bufferTarget = target as BufferTarget;\n const videoBuffer = bufferTarget.buffer;\n if (!videoBuffer) {\n throw new Error(\"Video encoding failed: no buffer produced\");\n }\n \n if (config.returnBuffer) {\n return new Uint8Array(videoBuffer);\n }\n \n const videoBlob = new Blob([videoBuffer], { type: \"video/mp4\" });\n downloadBlob(videoBlob, config.filename);\n return undefined;\n }\n \n } finally {\n renderContext.dispose();\n cleanupRenderClone();\n // Remove preview container if it was attached to document\n if (previewContainer.parentNode) {\n previewContainer.parentNode.removeChild(previewContainer);\n }\n }\n}\n\nexport { QUALITY_HIGH };\nexport type { AudioCodec };\n"],"mappings":";;;;;;;;AAkFA,IAAa,6BAAb,cAAgD,MAAM;CACpD,YAAY,iBAA+B,iBAA+B;AACxE,QACE,+CAA+C,gBAAgB,KAAK,KAAK,CAAC,iBAC3D,gBAAgB,SAAS,IAAI,gBAAgB,KAAK,KAAK,GAAG,OAAO,GACjF;AACD,OAAK,OAAO;;;AAIhB,IAAa,uBAAb,cAA0C,MAAM;CAC9C,cAAc;AACZ,QAAM,mBAAmB;AACzB,OAAK,OAAO;;;AAoChB,SAAS,cACP,WACA,SACgB;CAChB,MAAM,MAAM,QAAQ,OAAO,UAAU,gBAAgB;CACrD,MAAM,QAAQ,QAAQ,SAAS;CAC/B,MAAM,UAAU,QAAQ,WAAW;CACnC,MAAM,WAAW,QAAQ,YAAY;CACrC,MAAM,QAAQ,QAAQ,SAAS;CAC/B,MAAM,mBAAmB,QAAQ,oBAAoB;CACrD,MAAM,YAAY,QAAQ,aAAa;CACvC,MAAM,eAAe,QAAQ,gBAAgB;CAC7C,MAAM,eAAe,QAAQ,gBAAgB;CAC7C,MAAM,mBAAmB,QAAQ,oBAAoB;CACrD,MAAM,oBAAoB,QAAQ,qBAAqB;CACvD,MAAM,eAAe,QAAQ,gBAAgB;CAC7C,MAAM,uBAAuB,QAAQ,wBAAwB,CAAC,OAAO,OAAO;CAC5E,MAAM,gBAAgB,QAAQ,iBAAiB;CAG/C,MAAM,0BAA0B,QAAQ,2BAA2B;CAEnE,MAAM,kBAAkB,UAAU;AAClC,KAAI,CAAC,mBAAmB,mBAAmB,EACzC,OAAM,IAAI,MAAM,4BAA4B;CAG9C,MAAM,UAAU,KAAK,IAAI,GAAG,QAAQ,UAAU,EAAE;CAChD,MAAM,QAAQ,QAAQ,SAAS,SAAY,KAAK,IAAI,QAAQ,MAAM,gBAAgB,GAAG;CACrF,MAAM,mBAAmB,QAAQ;AAEjC,KAAI,oBAAoB,EACtB,OAAM,IAAI,MAAM,8BAA8B,QAAQ,QAAQ,MAAM,IAAI;AAI1E,CAAK,UAAU;CAEf,MAAM,iBAAiB,UAAU;CACjC,MAAM,kBAAkB,UAAU;AAElC,SAAQ,IAAI,kDAAkD,eAAe,GAAG,kBAAkB;AAClG,SAAQ,IAAI,4CAA4C,iBAAiB,UAAU,CAAC,OAAO,iBAAiB,UAAU,CAAC,OAAO;AAC9H,SAAQ,IAAI,gDAAgD,UAAU,uBAAuB,CAAC;AAE9F,KAAI,CAAC,kBAAkB,CAAC,gBACtB,OAAM,IAAI,MACR,gCAAgC,eAAe,GAAG,gBAAgB,+HAGnE;CAEH,MAAM,QAAQ,KAAK,MAAM,iBAAiB,MAAM;CAChD,MAAM,SAAS,KAAK,MAAM,kBAAkB,MAAM;CAElD,MAAM,aAAa,QAAQ,MAAM,IAAI,QAAQ,QAAQ;CACrD,MAAM,cAAc,SAAS,MAAM,IAAI,SAAS,SAAS;CAEzD,MAAM,kBAAkB,MAAO;AAI/B,QAAO;EACL;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,aAjBkB,KAAK,KAAK,mBAAmB,gBAAgB;EAkB/D;EACA,gBAlBqB,kBAAkB;EAmBvC;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACD;;AAOH,SAAS,8BAAuC;AAC9C,QAAO,OAAO,WAAW,eAAe,wBAAwB;;AAGlE,eAAe,sBACb,UACsF;AACtF,KAAI,CAAC,6BAA6B,CAChC,QAAO;AAGT,KAAI;EAKF,MAAM,WAAW,OAJE,MAAO,OAAe,mBAAmB;GAC1D,eAAe;GACf,OAAO,CAAC;IAAE,aAAa;IAAa,QAAQ,EAAE,aAAa,CAAC,OAAO,EAAE;IAAE,CAAC;GACzE,CAAC,EACgC,gBAAgB;AAClD,SAAO;GAAE;GAAU,OAAO,YAAY;AAAE,UAAM,SAAS,OAAO;;GAAK;UAC5D,GAAG;AACV,MAAK,EAAY,SAAS,aACxB,QAAO,KAAK,8CAA8C,EAAE;AAE9D,SAAO;;;AAIX,eAAe,iBACb,iBACA,iBACqB;AACrB,MAAK,MAAM,SAAS,gBAClB,KAAI;AAEF,MADoB,MAAM,eAAe,OAAO,gBAAgB,CAC/C,QAAO;UACjB,GAAG;AACV,SAAO,KAAK,uCAAuC,MAAM,IAAI,EAAE;;AAInE,OAAM,IAAI,2BAA2B,iBADb,MAAM,wBAAwB,QAAW,gBAAgB,CACX;;AAGxE,SAAS,aAAa,MAAY,UAAwB;CACxD,MAAM,MAAM,IAAI,gBAAgB,KAAK;CACrC,MAAM,IAAI,SAAS,cAAc,IAAI;AACrC,GAAE,OAAO;AACT,GAAE,WAAW;AACb,UAAS,KAAK,YAAY,EAAE;AAC5B,GAAE,OAAO;AACT,UAAS,KAAK,YAAY,EAAE;AAC5B,KAAI,gBAAgB,IAAI;;;;;;;;AAsB1B,eAAsB,uBACpB,WACA,UAAgC,EAAE,EACD;CACjC,MAAM,SAAS,cAAc,WAAW,QAAQ;CAChD,MAAM,EAAE,QAAQ,eAAe;CAE/B,MAAM,uBAAuB;AAC3B,MAAI,QAAQ,QAAS,OAAM,IAAI,sBAAsB;;AAGvD,mBAAkB;CAKlB,MAAM,EAAE,OAAO,aAAa,SAAS,uBACnC,MAAM,UAAU,mBAAmB;CAKrC,MAAMA,aAAuB,EAAE;AAC/B,MAAK,IAAI,IAAI,GAAG,IAAI,OAAO,aAAa,IACtC,YAAW,KAAK,OAAO,UAAU,IAAI,OAAO,gBAAgB;CAG9D,MAAM,gBAAgB,YAAY,iBAAiB,WAAW;AAC9D,KAAI,cAAc,SAAS,GAAG;AAC5B,SAAO,MAAM,gEAAgE,cAAc,OAAO,cAAc;AAChH,QAAM,QAAQ,IACZ,MAAM,KAAK,cAAc,CAAC,KAAK,UAC5B,MAAkB,0BAA0B,WAAW,CACzD,CACF;AACD,SAAO,MAAM,6CAA6C;;CAM5D,IAAIC,SAAwB;CAC5B,IAAIC,cAAmC;CACvC,IAAIC,cAAwC;CAC5C,IAAIC,SAA6C;CACjD,IAAIC,aAA0F;CAC9F,IAAI,eAAe;CACnB,IAAIC,iBAAyC;CAC7C,IAAIC,cAAwD;AAE5D,KAAI,CAAC,OAAO,eAAe;AAEzB,MAAI,QAAQ,sBAAsB;AAChC,YAAS,IAAI,aAAa,QAAQ,qBAA4B;AAC9D,YAAS,IAAI,OAAO;IAClB,QAAQ,IAAI,gBAAgB,EAAE,WAAW,cAAc,CAAC;IACxD;IACD,CAAC;AACF,kBAAe;aACN,OAAO,WAAW;AAC3B,gBAAa,MAAM,sBAAsB,OAAO,SAAS;AACzD,kBAAe,eAAe;AAE9B,OAAI,gBAAgB,YAAY;AAC9B,aAAS,IAAI,aAAa,WAAW,SAAgB;AACrD,aAAS,IAAI,OAAO;KAClB,QAAQ,IAAI,gBAAgB,EAAE,WAAW,cAAc,CAAC;KACxD;KACD,CAAC;;;AAIN,MAAI,CAAC,QAAQ;AACX,YAAS,IAAI,cAAc;AAC3B,YAAS,IAAI,OAAO;IAAE,QAAQ,IAAI,iBAAiB;IAAE;IAAQ,CAAC;;AAGhE,mBAAiB,IAAI,gBAAgB,OAAO,YAAY,OAAO,YAAY;AAC3E,gBAAc,eAAe,WAAW,KAAK;AAC7C,MAAI,CAAC,aAAa;AAChB,uBAAoB;AACpB,SAAM,IAAI,MAAM,wCAAwC;;AAG1D,MAAI,CAAC,OACH,OAAM,IAAI,MAAM,yBAAyB;EAG3C,MAAMC,cAAmC;GACvC,OAAO,OAAO;GACd,SAAS,OAAO;GAChB,kBAAkB,OAAO;GAC1B;AACD,gBAAc,IAAI,aAAa,gBAAgB,YAAY;AAC3D,SAAO,cAAc,YAAY;AAEjC,MAAI,OAAO,cAAc;AAUvB,iBAAc,IAAI,kBAJuB;IACvC,OANoB,MAAM,iBAAiB,OAAO,sBAAsB;KACxE,kBAAkB;KAClB,YAAY;KACZ,SAAS,OAAO;KACjB,CAAC;IAGA,SAAS,OAAO;IACjB,CAC+C;AAChD,UAAO,cAAc,YAAY;;AAGnC,QAAM,OAAO,OAAO;;CAOtB,MAAM,gBAAgB,IAAI,eAAe;CAMzC,MAAM,mBAAmB,uBAAuB;EAC9C,OAHqB,UAAU,eAAe;EAI9C,QAHsB,UAAU,gBAAgB;EAIhD,YAAY,iBAAiB,UAAU,CAAC,cAAc;EACvD,CAAC;AAGF,SAAQ,IAAI,+DAA+D;AAG3E,kBAAiB,YAAY,YAAY;AAKzC,kBAAiB,UAAU,IAAI,4BAA4B;AAQ3D,kBAAiB,MAAM,WAAW;AAClC,UAAS,KAAK,YAAY,iBAAiB;AAG3C,CAAK,YAAY;AACjB,SAAQ,IAAI,yGAAyG;CAKrH,MAAM,kBAAkB,YAAY,KAAK;CACzC,IAAI,yBAAyB,OAAO;CACpC,MAAM,uBAAuB;CAG7B,IAAIC,cAAwC;CAC5C,IAAIC,WAA4C;AAChD,KAAI,cAAc,OAAO,0BAA0B,GAAG;EACpD,MAAM,eAAe;EACrB,MAAM,gBAAgB,KAAK,MAAM,gBAAgB,OAAO,cAAc,OAAO,YAAY;AACzF,gBAAc,SAAS,cAAc,SAAS;AAC9C,cAAY,QAAQ;AACpB,cAAY,SAAS;AACrB,aAAW,YAAY,WAAW,KAAK;;CAGzC,IAAI,cAAc;CAClB,IAAI,cAAc;CAClB,IAAI,gBAAgB;CACpB,IAAI,gBAAgB;AAEpB,KAAI;EAMF,MAAMC,YAA6B,EAAE;EACrC,MAAMC,cAA4B,EAAE;EAMpC,MAAM,WAAW;EACjB,MAAM,aAAa;EAEnB,IAAI,gBAAgB;EACpB,IAAI,kBAAkB;AAEtB,OAAK,IAAI,kBAAkB,GAAG,kBAAkB,OAAO,aAAa,mBAAmB;AACrF,mBAAgB;GAEhB,MAAM,aAAa;GACnB,MAAM,SAAS,WAAW;GAC1B,MAAM,aAAc,aAAa,OAAO,kBAAmB;AAK3D,UAAO,UAAU,SAAS,YAAY,gBAAgB,OAAO,aAAa;IAExE,MAAM,aAAa,WADI;IAGvB,MAAM,YAAY,YAAY,KAAK;IACnC,MAAM,cAAc,YAAY,cAAc,WAAW,CAAC,WAAW;AACnE,oBAAe,YAAY,KAAK,GAAG;MACnC;AACF,cAAU,KAAK,YAAY;AAC3B;;AAMF,UAAO,YAAY,SAAS,cAAc,UAAU,SAAS,KAAK,kBAAkB,OAAO,aAAa;IACtG,MAAM,mBAAmB;IACzB,MAAM,eAAe,WAAW;IAChC,MAAM,mBAAoB,mBAAmB,OAAO,kBAAmB;IAGvE,MAAM,gBAFc,UAAU,OAAO,CAEH,KAAK,YAAY;KAOjD,MAAM,YAAY,YAAY,KAAK;KACnC,MAAM,UAAU,MAAM,2BAA2B,aAAa,OAAO,OAAO,OAAO,QAAQ;MACzF;MACA,aAAa,OAAO;MACpB,QAAQ;MACT,CAAC;KACF,MAAM,WAAW,YAAY,KAAK,GAAG;AACrC,oBAAe;KAGf,MAAM,cAAc,YAAY,KAAK;KACrC,MAAMC,UAAQ,IAAI,OAAO;AACzB,WAAM,IAAI,SAAe,SAAS,WAAW;AAC3C,cAAM,eAAe,SAAS;AAC9B,cAAM,WAAW,MAAM;AACrB,eAAQ,MAAM,UAAU,iBAAiB,sBAAsB,EAAE;AACjE,eAAQ,MAAM,UAAU,iBAAiB,sBAAsB,QAAQ,UAAU,GAAG,IAAI,GAAG,MAAM;AACjG,8BAAO,IAAI,MAAM,qCAAqC,CAAC;;AAEzD,cAAM,MAAM;OACZ;KACF,MAAM,aAAa,YAAY,KAAK,GAAG;AACvC,sBAAiB;AAEjB,SAAI,mBAAmB,OAAO,EAC5B,SAAQ,IAAI,UAAU,iBAAiB,kBAAkBA,QAAM,MAAM,GAAGA,QAAM,SAAS;AAIzF,SAAI,mBAAmB,OAAO,EAC5B,SAAQ,IAAI,UAAU,iBAAiB,cAAc,SAAS,QAAQ,EAAE,CAAC,IAAI;AAG/E,YAAOA;MACP;AAEF,gBAAY,KAAK;KACf,YAAY;KACZ,QAAQ;KACR,YAAY;KACZ,SAAS;KACV,CAAC;AACF;;GAMF,MAAM,YAAY,YAAY,WAAW,MAAM,EAAE,eAAe,WAAW;AAC3E,OAAI,cAAc,GAChB,OAAM,IAAI,MAAM,kCAAkC,aAAa;GAIjE,MAAM,QAAQ,MADD,YAAY,WACA;AACzB,eAAY,OAAO,WAAW,EAAE;AAKhC,OAAI,eAAe,UAAU,yBAAyB,sBAAsB;IAC1E,MAAM,aAAa,KAAK,IAAI,SAAS,sBAAsB,OAAO,MAAM;AACxE,QAAI;KACF,MAAM,cAAc,MAAM,UAAU,YAAY,wBAAwB,WAAW;AACnF,SAAI,eAAe,YAAY,SAAS,EACtC,OAAM,YAAY,IAAI,YAAY;aAE7B,GAAG;AACZ,6BAAyB;;AAM3B,OAAI,eAAe,UAAU,aAAa;IACxC,MAAM,cAAc,YAAY,KAAK;AACrC,gBAAY,UACV,OACA,GAAG,GAAG,MAAM,OAAO,MAAM,QACzB,GAAG,GAAG,OAAO,YAAY,OAAO,YACjC;AACD,UAAM,YAAY,IAAI,YAAY,OAAO,eAAe;AACxD,qBAAiB,YAAY,KAAK,GAAG;;GAMvC,MAAM,eAAe,aAAa;GAClC,MAAM,WAAW,eAAe,OAAO;GACvC,MAAM,aAAa,eAAe,OAAO;GACzC,MAAM,YAAY,YAAY,KAAK,GAAG;GACtC,MAAM,aAAa,YAAY;GAE/B,MAAM,wBADkB,OAAO,cAAc,gBACE;GAC/C,MAAM,kBAAkB,aAAa;AAIrC,OAAI,eAAe,YAAY,aAAa,OAAO,4BAA4B,EAC7E,UAAS,UAAU,OAAO,GAAG,GAAG,YAAY,OAAO,YAAY,OAAO;AAGxE,gBAAa;IACX;IACA;IACA,aAAa,OAAO;IACpB;IACA,iBAAiB,OAAO;IACxB;IACA;IACA;IACA,oBAAoB,eAAe;IACpC,CAAC;;AAIJ,MAAI,eAAe,yBAAyB,OAAO,MACjD,KAAI;GACF,MAAM,cAAc,MAAM,UAAU,YAAY,wBAAwB,OAAO,MAAM;AACrF,OAAI,eAAe,YAAY,SAAS,EACtC,OAAM,YAAY,IAAI,YAAY;WAE7B,GAAG;EAGd,MAAM,YAAY,YAAY,KAAK,GAAG;EAGtC,MAAM,UAAU,cAAc,OAAO;EACrC,MAAM,UAAU,cAAc,OAAO;EACrC,MAAM,YAAY,gBAAgB,OAAO;EACzC,MAAM,YAAY,gBAAgB,OAAO;EACzC,MAAM,WAAW,YAAY,OAAO;EAGpC,MAAM,YAAY,aADF,cAAc,cAAc,gBAAgB;AAG5D,UAAQ,IAAI,+CAA+C;AAC3D,UAAQ,IAAI,6BAA6B;AACzC,UAAQ,IAAI,iBAAiB,OAAO,cAAc;AAClD,UAAQ,IAAI,eAAe,UAAU,QAAQ,EAAE,CAAC,MAAM,SAAS,QAAQ,EAAE,CAAC,WAAW;AACrF,UAAQ,IAAI,sBAAsB;AAClC,UAAQ,IAAI,gBAAgB,YAAY,QAAQ,EAAE,CAAC,OAAO,cAAY,YAAU,KAAK,QAAQ,EAAE,CAAC,WAAW,QAAQ,QAAQ,EAAE,CAAC,UAAU;AACxI,UAAQ,IAAI,gBAAgB,YAAY,QAAQ,EAAE,CAAC,OAAO,cAAY,YAAU,KAAK,QAAQ,EAAE,CAAC,WAAW,QAAQ,QAAQ,EAAE,CAAC,UAAU;AACxI,UAAQ,IAAI,aAAa,cAAc,QAAQ,EAAE,CAAC,OAAO,gBAAc,YAAU,KAAK,QAAQ,EAAE,CAAC,WAAW,UAAU,QAAQ,EAAE,CAAC,UAAU;AAC3I,UAAQ,IAAI,aAAa,cAAc,QAAQ,EAAE,CAAC,OAAO,gBAAc,YAAU,KAAK,QAAQ,EAAE,CAAC,WAAW,UAAU,QAAQ,EAAE,CAAC,UAAU;AAC3I,UAAQ,IAAI,aAAa,UAAU,QAAQ,EAAE,CAAC,OAAO,YAAU,YAAU,KAAK,QAAQ,EAAE,CAAC,IAAI;AAC7F,UAAQ,IAAI,+CAA+C;AAE3D,SAAO,MACL,4BAA4B,OAAO,YAAY,gBACvC,YAAY,QAAQ,EAAE,CAAC,WAAW,YAAY,QAAQ,EAAE,CAAC,aACvD,cAAc,QAAQ,EAAE,CAAC,aAAa,cAAc,QAAQ,EAAE,CAAC,YAChE,UAAU,QAAQ,EAAE,CAAC,IAC/B;AAED,MAAI,OAAO,cACT;AAGF,QAAM,OAAQ,UAAU;AAExB,MAAI,aAEF;OACK;GAEL,MAAM,cADe,OACY;AACjC,OAAI,CAAC,YACH,OAAM,IAAI,MAAM,4CAA4C;AAG9D,OAAI,OAAO,aACT,QAAO,IAAI,WAAW,YAAY;AAIpC,gBADkB,IAAI,KAAK,CAAC,YAAY,EAAE,EAAE,MAAM,aAAa,CAAC,EACxC,OAAO,SAAS;AACxC;;WAGM;AACR,gBAAc,SAAS;AACvB,sBAAoB;AAEpB,MAAI,iBAAiB,WACnB,kBAAiB,WAAW,YAAY,iBAAiB"}
|
|
@@ -89,32 +89,7 @@ async function renderToImage(container, width, height, options) {
|
|
|
89
89
|
const { dataUri } = await serializeToSvgDataUri(clone, width, height);
|
|
90
90
|
return loadImageFromDataUri(dataUri);
|
|
91
91
|
}
|
|
92
|
-
/**
|
|
93
|
-
* Render a pre-built clone container to an image WITHOUT cloning it again.
|
|
94
|
-
* This is the fast path for reusing clone structures across frames.
|
|
95
|
-
*
|
|
96
|
-
* Key difference from renderToImage:
|
|
97
|
-
* - Does NOT call cloneNode (avoids expensive DOM duplication)
|
|
98
|
-
* - Converts canvases to images in-place, then restores them after serialization
|
|
99
|
-
* - Assumes the container already has refreshed canvas content
|
|
100
|
-
*
|
|
101
|
-
* @param container - Pre-built clone container with refreshed canvas content
|
|
102
|
-
* @param width - Output width
|
|
103
|
-
* @param height - Output height
|
|
104
|
-
* @returns Promise resolving to an HTMLImageElement
|
|
105
|
-
*/
|
|
106
|
-
async function renderToImageDirect(container, width, height) {
|
|
107
|
-
defaultProfiler.incrementRenderCount();
|
|
108
|
-
const { dataUri, restore } = await serializeToSvgDataUri(container, width, height, {
|
|
109
|
-
inlineImages: true,
|
|
110
|
-
logEarlyRenders: true
|
|
111
|
-
});
|
|
112
|
-
restore();
|
|
113
|
-
const image = await loadImageFromDataUri(dataUri);
|
|
114
|
-
defaultProfiler.shouldLogByFrameCount(100);
|
|
115
|
-
return image;
|
|
116
|
-
}
|
|
117
92
|
|
|
118
93
|
//#endregion
|
|
119
|
-
export { loadImageFromDataUri, renderToImage
|
|
94
|
+
export { loadImageFromDataUri, renderToImage };
|
|
120
95
|
//# sourceMappingURL=renderToImage.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"renderToImage.js","names":["current: Element | null","visibleIndices: number[]","visibleCanvases: HTMLCanvasElement[]"],"sources":["../../../src/preview/rendering/renderToImage.ts"],"sourcesContent":["/**\n * Public rendering API facade.\n * Dispatches to native or foreignObject rendering paths based on settings.\n */\n\nimport type { ForeignObjectRenderOptions } from \"./types.js\";\nimport { renderToImageNative } from \"./renderToImageNative.js\";\nimport { serializeToSvgDataUri } from \"./renderToImageForeignObject.js\";\nimport { inlineImages } from \"./inlineImages.js\";\nimport { getEffectiveRenderMode } from \"../renderers.js\";\nimport { encodeCanvasesInParallel } from \"../encoding/canvasEncoder.js\";\nimport { defaultProfiler } from \"../RenderProfiler.js\";\n\n/**\n * Check if an element or any of its ancestors has display:none.\n * Used to skip encoding hidden canvases.\n */\nfunction isElementHidden(element: Element): boolean {\n let current: Element | null = element;\n while (current) {\n if (current instanceof HTMLElement && current.style.display === \"none\") {\n return true;\n }\n current = current.parentElement;\n }\n return false;\n}\n\n/**\n * Load an image from a data URI. Returns a Promise that resolves when loaded.\n */\nexport function loadImageFromDataUri(dataUri: string): Promise<HTMLImageElement> {\n const img = new Image();\n const imageLoadStart = performance.now();\n \n return new Promise<HTMLImageElement>((resolve, reject) => {\n img.onload = () => {\n defaultProfiler.addTime(\"imageLoad\", performance.now() - imageLoadStart);\n resolve(img);\n };\n img.onerror = reject;\n img.src = dataUri;\n });\n}\n\n/**\n * Render HTML content to an image (or canvas) for drawing.\n * \n * Supports two rendering modes (configurable via previewSettings):\n * - \"native\": Chrome's experimental drawElementImage API (fastest when available)\n * - \"foreignObject\": SVG foreignObject serialization (fallback, works everywhere)\n * \n * @param container - The HTML element to render\n * @param width - Target width in logical pixels\n * @param height - Target height in logical pixels\n * @param options - Rendering options\n * @returns HTMLCanvasElement when using native, HTMLImageElement when using foreignObject\n */\nexport async function renderToImage(\n container: HTMLElement,\n width: number,\n height: number,\n options?: ForeignObjectRenderOptions,\n): Promise<HTMLImageElement | HTMLCanvasElement> {\n const renderMode = getEffectiveRenderMode();\n \n // Native HTML-in-Canvas API path (fastest, requires Chrome flag)\n if (renderMode === \"native\") {\n return renderToImageNative(container, width, height, options);\n }\n \n // Fallback: SVG foreignObject serialization\n // Clone the container first (don't modify original)\n // Note: cloneNode doesn't copy canvas pixels, so we encode from original canvases\n const allOriginalCanvases = Array.from(container.querySelectorAll(\"canvas\"));\n const clone = container.cloneNode(true) as HTMLElement;\n const allClonedCanvases = Array.from(clone.querySelectorAll(\"canvas\"));\n \n // Filter out hidden canvases - they have display:none and won't render anyway\n // Keep track of indices to match with cloned canvases\n const visibleIndices: number[] = [];\n const visibleCanvases: HTMLCanvasElement[] = [];\n for (let i = 0; i < allOriginalCanvases.length; i++) {\n const canvas = allOriginalCanvases[i]!;\n if (!isElementHidden(canvas)) {\n visibleIndices.push(i);\n visibleCanvases.push(canvas);\n }\n }\n \n // Encode visible original canvases\n // Pass through renderContext and sourceMap for caching\n const canvasScale = options?.canvasScale ?? 1;\n const canvasStart = performance.now();\n const encodedResults = await encodeCanvasesInParallel(visibleCanvases, { \n scale: canvasScale,\n renderContext: options?.renderContext,\n sourceMap: options?.sourceMap,\n });\n \n // Map encoded results to corresponding cloned canvases using tracked indices\n for (let j = 0; j < visibleCanvases.length; j++) {\n const srcCanvas = visibleCanvases[j]!;\n const originalIndex = visibleIndices[j]!;\n const dstCanvas = allClonedCanvases[originalIndex];\n const encoded = encodedResults.find((r) => r.canvas === srcCanvas);\n \n if (!dstCanvas || !encoded) continue;\n \n try {\n const img = document.createElement(\"img\");\n img.src = encoded.dataUrl;\n img.width = srcCanvas.width;\n img.height = srcCanvas.height;\n const style = dstCanvas.getAttribute(\"style\");\n if (style) img.setAttribute(\"style\", style);\n dstCanvas.parentNode?.replaceChild(img, dstCanvas);\n } catch {\n // Cross-origin or other error - skip\n }\n }\n defaultProfiler.addTime(\"canvasEncode\", performance.now() - canvasStart);\n\n // Inline external images in the clone\n const inlineStart = performance.now();\n await inlineImages(clone);\n defaultProfiler.addTime(\"inline\", performance.now() - inlineStart);\n\n // Use common serialization pipeline (no restore needed since we're working on a clone)\n const { dataUri } = await serializeToSvgDataUri(clone, width, height);\n \n // Load as image\n return loadImageFromDataUri(dataUri);\n}\n\n/**\n * Render a pre-built clone container to an image WITHOUT cloning it again.\n * This is the fast path for reusing clone structures across frames.\n * \n * Key difference from renderToImage:\n * - Does NOT call cloneNode (avoids expensive DOM duplication)\n * - Converts canvases to images in-place, then restores them after serialization\n * - Assumes the container already has refreshed canvas content\n * \n * @param container - Pre-built clone container with refreshed canvas content\n * @param width - Output width\n * @param height - Output height\n * @returns Promise resolving to an HTMLImageElement\n */\nexport async function renderToImageDirect(\n container: HTMLElement,\n width: number,\n height: number,\n): Promise<HTMLImageElement> {\n defaultProfiler.incrementRenderCount();\n \n // Use common serialization pipeline (modifies in-place, restores after)\n const { dataUri, restore } = await serializeToSvgDataUri(container, width, height, {\n inlineImages: true,\n logEarlyRenders: true,\n });\n restore();\n \n // Load as image\n const image = await loadImageFromDataUri(dataUri);\n \n // Log timing breakdown periodically\n defaultProfiler.shouldLogByFrameCount(100);\n \n return image;\n}\n\n/**\n * Prepare a frame's data URI without waiting for image load.\n * Returns the data URI asynchronously (after parallel canvas encoding and serialization) for pipelined loading.\n * The DOM is restored before this function returns.\n */\nexport async function prepareFrameDataUri(\n container: HTMLElement,\n width: number,\n height: number,\n): Promise<string> {\n defaultProfiler.incrementRenderCount();\n \n // Use common serialization pipeline (modifies in-place, restores after)\n const { dataUri, restore } = await serializeToSvgDataUri(container, width, height);\n restore();\n \n return dataUri;\n}\n"],"mappings":";;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"renderToImage.js","names":["current: Element | null","visibleIndices: number[]","visibleCanvases: HTMLCanvasElement[]"],"sources":["../../../src/preview/rendering/renderToImage.ts"],"sourcesContent":["/**\n * Public rendering API facade.\n * Dispatches to native or foreignObject rendering paths based on settings.\n */\n\nimport type { ForeignObjectRenderOptions } from \"./types.js\";\nimport type { RenderContext } from \"../RenderContext.js\";\nimport { renderToImageNative } from \"./renderToImageNative.js\";\nimport { serializeToSvgDataUri } from \"./renderToImageForeignObject.js\";\nimport { inlineImages } from \"./inlineImages.js\";\nimport { getEffectiveRenderMode } from \"../renderers.js\";\nimport { encodeCanvasesInParallel } from \"../encoding/canvasEncoder.js\";\nimport { defaultProfiler } from \"../RenderProfiler.js\";\n\n/**\n * Check if an element or any of its ancestors has display:none.\n * Used to skip encoding hidden canvases.\n */\nfunction isElementHidden(element: Element): boolean {\n let current: Element | null = element;\n while (current) {\n if (current instanceof HTMLElement && current.style.display === \"none\") {\n return true;\n }\n current = current.parentElement;\n }\n return false;\n}\n\n/**\n * Load an image from a data URI. Returns a Promise that resolves when loaded.\n */\nexport function loadImageFromDataUri(dataUri: string): Promise<HTMLImageElement> {\n const img = new Image();\n const imageLoadStart = performance.now();\n \n return new Promise<HTMLImageElement>((resolve, reject) => {\n img.onload = () => {\n defaultProfiler.addTime(\"imageLoad\", performance.now() - imageLoadStart);\n resolve(img);\n };\n img.onerror = reject;\n img.src = dataUri;\n });\n}\n\n/**\n * Render HTML content to an image (or canvas) for drawing.\n * \n * Supports two rendering modes (configurable via previewSettings):\n * - \"native\": Chrome's experimental drawElementImage API (fastest when available)\n * - \"foreignObject\": SVG foreignObject serialization (fallback, works everywhere)\n * \n * @param container - The HTML element to render\n * @param width - Target width in logical pixels\n * @param height - Target height in logical pixels\n * @param options - Rendering options\n * @returns HTMLCanvasElement when using native, HTMLImageElement when using foreignObject\n */\nexport async function renderToImage(\n container: HTMLElement,\n width: number,\n height: number,\n options?: ForeignObjectRenderOptions,\n): Promise<HTMLImageElement | HTMLCanvasElement> {\n const renderMode = getEffectiveRenderMode();\n \n // Native HTML-in-Canvas API path (fastest, requires Chrome flag)\n if (renderMode === \"native\") {\n return renderToImageNative(container, width, height, options);\n }\n \n // Fallback: SVG foreignObject serialization\n // Clone the container first (don't modify original)\n // Note: cloneNode doesn't copy canvas pixels, so we encode from original canvases\n const allOriginalCanvases = Array.from(container.querySelectorAll(\"canvas\"));\n const clone = container.cloneNode(true) as HTMLElement;\n const allClonedCanvases = Array.from(clone.querySelectorAll(\"canvas\"));\n \n // Filter out hidden canvases - they have display:none and won't render anyway\n // Keep track of indices to match with cloned canvases\n const visibleIndices: number[] = [];\n const visibleCanvases: HTMLCanvasElement[] = [];\n for (let i = 0; i < allOriginalCanvases.length; i++) {\n const canvas = allOriginalCanvases[i]!;\n if (!isElementHidden(canvas)) {\n visibleIndices.push(i);\n visibleCanvases.push(canvas);\n }\n }\n \n // Encode visible original canvases\n // Pass through renderContext and sourceMap for caching\n const canvasScale = options?.canvasScale ?? 1;\n const canvasStart = performance.now();\n const encodedResults = await encodeCanvasesInParallel(visibleCanvases, { \n scale: canvasScale,\n renderContext: options?.renderContext,\n sourceMap: options?.sourceMap,\n });\n \n // Map encoded results to corresponding cloned canvases using tracked indices\n for (let j = 0; j < visibleCanvases.length; j++) {\n const srcCanvas = visibleCanvases[j]!;\n const originalIndex = visibleIndices[j]!;\n const dstCanvas = allClonedCanvases[originalIndex];\n const encoded = encodedResults.find((r) => r.canvas === srcCanvas);\n \n if (!dstCanvas || !encoded) continue;\n \n try {\n const img = document.createElement(\"img\");\n img.src = encoded.dataUrl;\n img.width = srcCanvas.width;\n img.height = srcCanvas.height;\n const style = dstCanvas.getAttribute(\"style\");\n if (style) img.setAttribute(\"style\", style);\n dstCanvas.parentNode?.replaceChild(img, dstCanvas);\n } catch {\n // Cross-origin or other error - skip\n }\n }\n defaultProfiler.addTime(\"canvasEncode\", performance.now() - canvasStart);\n\n // Inline external images in the clone\n const inlineStart = performance.now();\n await inlineImages(clone);\n defaultProfiler.addTime(\"inline\", performance.now() - inlineStart);\n\n // Use common serialization pipeline (no restore needed since we're working on a clone)\n const { dataUri } = await serializeToSvgDataUri(clone, width, height);\n \n // Load as image\n return loadImageFromDataUri(dataUri);\n}\n\n/**\n * Render a pre-built clone container to an image WITHOUT cloning it again.\n * This is the fast path for reusing clone structures across frames.\n * \n * Key difference from renderToImage:\n * - Does NOT call cloneNode (avoids expensive DOM duplication)\n * - Converts canvases to images in-place, then restores them after serialization\n * - Assumes the container already has refreshed canvas content\n * \n * @param container - Pre-built clone container with refreshed canvas content\n * @param width - Output width\n * @param height - Output height\n * @returns Promise resolving to an HTMLImageElement\n */\nexport async function renderToImageDirect(\n container: HTMLElement,\n width: number,\n height: number,\n options?: {\n renderContext?: RenderContext;\n sourceMap?: WeakMap<HTMLCanvasElement, Element>;\n canvasScale?: number;\n },\n): Promise<HTMLImageElement> {\n defaultProfiler.incrementRenderCount();\n \n // Use common serialization pipeline (modifies in-place, restores after)\n const { dataUri, restore } = await serializeToSvgDataUri(container, width, height, {\n inlineImages: true,\n logEarlyRenders: true,\n renderContext: options?.renderContext,\n sourceMap: options?.sourceMap,\n canvasScale: options?.canvasScale ?? 1,\n });\n restore();\n \n // Load as image\n const image = await loadImageFromDataUri(dataUri);\n \n // Log timing breakdown periodically\n defaultProfiler.shouldLogByFrameCount(100);\n \n return image;\n}\n\n/**\n * Prepare a frame's data URI without waiting for image load.\n * Returns the data URI asynchronously (after parallel canvas encoding and serialization) for pipelined loading.\n * The DOM is restored before this function returns.\n */\nexport async function prepareFrameDataUri(\n container: HTMLElement,\n width: number,\n height: number,\n): Promise<string> {\n defaultProfiler.incrementRenderCount();\n \n // Use common serialization pipeline (modifies in-place, restores after)\n const { dataUri, restore } = await serializeToSvgDataUri(container, width, height);\n restore();\n \n return dataUri;\n}\n"],"mappings":";;;;;;;;;;;;AAkBA,SAAS,gBAAgB,SAA2B;CAClD,IAAIA,UAA0B;AAC9B,QAAO,SAAS;AACd,MAAI,mBAAmB,eAAe,QAAQ,MAAM,YAAY,OAC9D,QAAO;AAET,YAAU,QAAQ;;AAEpB,QAAO;;;;;AAMT,SAAgB,qBAAqB,SAA4C;CAC/E,MAAM,MAAM,IAAI,OAAO;CACvB,MAAM,iBAAiB,YAAY,KAAK;AAExC,QAAO,IAAI,SAA2B,SAAS,WAAW;AACxD,MAAI,eAAe;AACjB,mBAAgB,QAAQ,aAAa,YAAY,KAAK,GAAG,eAAe;AACxE,WAAQ,IAAI;;AAEd,MAAI,UAAU;AACd,MAAI,MAAM;GACV;;;;;;;;;;;;;;;AAgBJ,eAAsB,cACpB,WACA,OACA,QACA,SAC+C;AAI/C,KAHmB,wBAAwB,KAGxB,SACjB,QAAO,oBAAoB,WAAW,OAAO,QAAQ,QAAQ;CAM/D,MAAM,sBAAsB,MAAM,KAAK,UAAU,iBAAiB,SAAS,CAAC;CAC5E,MAAM,QAAQ,UAAU,UAAU,KAAK;CACvC,MAAM,oBAAoB,MAAM,KAAK,MAAM,iBAAiB,SAAS,CAAC;CAItE,MAAMC,iBAA2B,EAAE;CACnC,MAAMC,kBAAuC,EAAE;AAC/C,MAAK,IAAI,IAAI,GAAG,IAAI,oBAAoB,QAAQ,KAAK;EACnD,MAAM,SAAS,oBAAoB;AACnC,MAAI,CAAC,gBAAgB,OAAO,EAAE;AAC5B,kBAAe,KAAK,EAAE;AACtB,mBAAgB,KAAK,OAAO;;;CAMhC,MAAM,cAAc,SAAS,eAAe;CAC5C,MAAM,cAAc,YAAY,KAAK;CACrC,MAAM,iBAAiB,MAAM,yBAAyB,iBAAiB;EACrE,OAAO;EACP,eAAe,SAAS;EACxB,WAAW,SAAS;EACrB,CAAC;AAGF,MAAK,IAAI,IAAI,GAAG,IAAI,gBAAgB,QAAQ,KAAK;EAC/C,MAAM,YAAY,gBAAgB;EAElC,MAAM,YAAY,kBADI,eAAe;EAErC,MAAM,UAAU,eAAe,MAAM,MAAM,EAAE,WAAW,UAAU;AAElE,MAAI,CAAC,aAAa,CAAC,QAAS;AAE5B,MAAI;GACF,MAAM,MAAM,SAAS,cAAc,MAAM;AACzC,OAAI,MAAM,QAAQ;AAClB,OAAI,QAAQ,UAAU;AACtB,OAAI,SAAS,UAAU;GACvB,MAAM,QAAQ,UAAU,aAAa,QAAQ;AAC7C,OAAI,MAAO,KAAI,aAAa,SAAS,MAAM;AAC3C,aAAU,YAAY,aAAa,KAAK,UAAU;UAC5C;;AAIV,iBAAgB,QAAQ,gBAAgB,YAAY,KAAK,GAAG,YAAY;CAGxE,MAAM,cAAc,YAAY,KAAK;AACrC,OAAM,aAAa,MAAM;AACzB,iBAAgB,QAAQ,UAAU,YAAY,KAAK,GAAG,YAAY;CAGlE,MAAM,EAAE,YAAY,MAAM,sBAAsB,OAAO,OAAO,OAAO;AAGrE,QAAO,qBAAqB,QAAQ"}
|
|
@@ -41,25 +41,53 @@ async function serializeToSvgDataUri(container, width, height, options = {}) {
|
|
|
41
41
|
const canvasStart = performance.now();
|
|
42
42
|
const visibleCanvases = Array.from(container.querySelectorAll("canvas")).filter((canvas) => !isElementHidden(canvas));
|
|
43
43
|
const canvasSnapshots = [];
|
|
44
|
+
const qualityMultiplier = 1.5;
|
|
44
45
|
for (let i = 0; i < visibleCanvases.length; i++) {
|
|
45
46
|
const canvas = visibleCanvases[i];
|
|
46
47
|
if (canvas.width > 0 && canvas.height > 0) {
|
|
48
|
+
let optimalScale = canvasScale;
|
|
49
|
+
if (sourceMap) {
|
|
50
|
+
const sourceElement = sourceMap.get(canvas);
|
|
51
|
+
if (sourceElement) try {
|
|
52
|
+
const computedStyle = getComputedStyle(sourceElement);
|
|
53
|
+
const cssWidth = parseFloat(computedStyle.width) || canvas.width;
|
|
54
|
+
const cssHeight = parseFloat(computedStyle.height) || canvas.height;
|
|
55
|
+
const displayScaleX = cssWidth / canvas.width;
|
|
56
|
+
const displayScaleY = cssHeight / canvas.height;
|
|
57
|
+
const displayScale = Math.min(displayScaleX, displayScaleY);
|
|
58
|
+
optimalScale = Math.min(1, displayScale * canvasScale * qualityMultiplier);
|
|
59
|
+
console.log(`[serializeToSvg] Canvas ${canvas.width}x${canvas.height} -> CSS ${cssWidth.toFixed(0)}x${cssHeight.toFixed(0)}, displayScale=${displayScale.toFixed(3)}, videoScale=${canvasScale}, optimalScale=${optimalScale.toFixed(3)}`);
|
|
60
|
+
} catch (e) {
|
|
61
|
+
console.warn(`[serializeToSvg] Failed to get computed style for ${sourceElement.tagName}:`, e);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const targetWidth = Math.max(1, Math.floor(canvas.width * optimalScale));
|
|
65
|
+
const targetHeight = Math.max(1, Math.floor(canvas.height * optimalScale));
|
|
47
66
|
const copy = document.createElement("canvas");
|
|
48
|
-
copy.width =
|
|
49
|
-
copy.height =
|
|
67
|
+
copy.width = targetWidth;
|
|
68
|
+
copy.height = targetHeight;
|
|
50
69
|
if (canvas.dataset.preserveAlpha) copy.dataset.preserveAlpha = canvas.dataset.preserveAlpha;
|
|
51
70
|
const ctx = copy.getContext("2d");
|
|
52
|
-
if (ctx) ctx.drawImage(canvas, 0, 0);
|
|
71
|
+
if (ctx) ctx.drawImage(canvas, 0, 0, targetWidth, targetHeight);
|
|
53
72
|
canvasSnapshots.push({
|
|
54
73
|
original: canvas,
|
|
55
74
|
copy
|
|
56
75
|
});
|
|
57
76
|
}
|
|
58
77
|
}
|
|
59
|
-
const
|
|
60
|
-
|
|
78
|
+
const snapshotCanvases = canvasSnapshots.map((s) => s.copy);
|
|
79
|
+
let snapshotSourceMap;
|
|
80
|
+
if (sourceMap) {
|
|
81
|
+
snapshotSourceMap = /* @__PURE__ */ new WeakMap();
|
|
82
|
+
for (const { original, copy } of canvasSnapshots) {
|
|
83
|
+
const sourceElement = sourceMap.get(original);
|
|
84
|
+
if (sourceElement) snapshotSourceMap.set(copy, sourceElement);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const encodedWithOriginals = (await encodeCanvasesInParallel(snapshotCanvases, {
|
|
88
|
+
scale: 1,
|
|
61
89
|
renderContext,
|
|
62
|
-
sourceMap
|
|
90
|
+
sourceMap: snapshotSourceMap
|
|
63
91
|
})).map((result) => {
|
|
64
92
|
const snapshot = canvasSnapshots.find((s) => s.copy === result.canvas);
|
|
65
93
|
return {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"renderToImageForeignObject.js","names":["_xmlSerializer: XMLSerializer | null","_textEncoder: TextEncoder | null","current: Element | null","canvasRestoreInfo: CanvasRestoreInfo[]","canvasSnapshots: { original: HTMLCanvasElement; copy: HTMLCanvasElement }[]","base64: string"],"sources":["../../../src/preview/rendering/renderToImageForeignObject.ts"],"sourcesContent":["/**\n * SVG foreignObject rendering path with serialization.\n */\n\nimport type { SerializeToSvgOptions, SerializationResult, CanvasRestoreInfo } from \"./types.js\";\nimport { encodeBase64Fast } from \"./svgSerializer.js\";\nimport { inlineImages } from \"./inlineImages.js\";\nimport { encodeCanvasesInParallel } from \"../encoding/canvasEncoder.js\";\nimport { defaultProfiler } from \"../RenderProfiler.js\";\nimport { logger } from \"../logger.js\";\n\n// Reusable instances for better performance (avoid creating new instances every frame)\n// Note: wrapper element is NOT reused - each concurrent frame needs its own wrapper\nlet _xmlSerializer: XMLSerializer | null = null;\nlet _textEncoder: TextEncoder | null = null;\n\n/**\n * Check if an element or any of its ancestors has display:none.\n * Used to skip encoding hidden canvases.\n */\nfunction isElementHidden(element: Element): boolean {\n let current: Element | null = element;\n while (current) {\n if (current instanceof HTMLElement && current.style.display === \"none\") {\n return true;\n }\n current = current.parentElement;\n }\n return false;\n}\n\n// Pre-computed SVG constants\nconst SVG_PREFIX = '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"';\nconst SVG_HEIGHT_PREFIX = '\" height=\"';\nconst SVG_MIDDLE = '\"><foreignObject width=\"100%\" height=\"100%\">';\nconst SVG_SUFFIX = '</foreignObject></svg>';\nconst DATA_URI_PREFIX = 'data:image/svg+xml;base64,';\n\n// Shared style string to reduce allocations\nconst WRAPPER_STYLE_BASE = \"overflow:hidden;position:relative;\";\n\n/**\n * Common SVG foreignObject serialization pipeline.\n * Handles canvas encoding, serialization, and base64 encoding.\n * \n * @param container - The HTML element to serialize\n * @param width - Output width\n * @param height - Output height\n * @param options - Serialization options\n * @returns Serialization result with data URI and restore function\n */\nexport async function serializeToSvgDataUri(\n container: HTMLElement,\n width: number,\n height: number,\n options: SerializeToSvgOptions = {},\n): Promise<SerializationResult> {\n const { \n canvasScale = 1, \n inlineImages: shouldInlineImages = false, \n logEarlyRenders = false,\n renderContext,\n sourceMap,\n } = options;\n \n // Store info for restoration (only used if modifying in-place)\n const canvasRestoreInfo: CanvasRestoreInfo[] = [];\n \n // Phase 1: Encode canvases to data URLs (parallel)\n // Filter out hidden canvases - they have display:none and won't render anyway\n const canvasStart = performance.now();\n const allCanvases = Array.from(container.querySelectorAll(\"canvas\"));\n const visibleCanvases = allCanvases.filter(canvas => !isElementHidden(canvas));\n \n // CRITICAL FIX: Synchronously copy canvas pixels BEFORE any async work.\n // This prevents race conditions where concurrent render tasks overwrite\n // the shared clone canvases while encoding is in progress.\n // See: Hypothesis 1 - Clone Canvas Overwritten During Serialization\n const canvasSnapshots: { original: HTMLCanvasElement; copy: HTMLCanvasElement }[] = [];\n for (let i = 0; i < visibleCanvases.length; i++) {\n const canvas = visibleCanvases[i]!;\n if (canvas.width > 0 && canvas.height > 0) {\n const copy = document.createElement(\"canvas\");\n copy.width = canvas.width;\n copy.height = canvas.height;\n // Copy dataset attributes (e.g., preserveAlpha)\n if (canvas.dataset.preserveAlpha) {\n copy.dataset.preserveAlpha = canvas.dataset.preserveAlpha;\n }\n const ctx = copy.getContext(\"2d\");\n if (ctx) {\n // drawImage is SYNCHRONOUS - pixels are copied immediately\n ctx.drawImage(canvas, 0, 0);\n }\n canvasSnapshots.push({ original: canvas, copy });\n }\n }\n \n // Encode from the snapshot copies (safe from concurrent overwrites)\n const snapshotCanvases = canvasSnapshots.map(s => s.copy);\n const encodedResults = await encodeCanvasesInParallel(snapshotCanvases, { \n scale: canvasScale,\n renderContext,\n sourceMap,\n });\n \n // Map encoded results back to original canvases for DOM replacement\n const encodedWithOriginals = encodedResults.map(result => {\n const snapshot = canvasSnapshots.find(s => s.copy === result.canvas);\n return {\n ...result,\n canvas: snapshot?.original ?? result.canvas,\n };\n });\n \n // Replace canvases with images\n for (const { canvas, dataUrl } of encodedWithOriginals) {\n try {\n const img = document.createElement(\"img\");\n img.src = dataUrl;\n img.width = canvas.width;\n img.height = canvas.height;\n const style = canvas.getAttribute(\"style\");\n if (style) img.setAttribute(\"style\", style);\n \n const parent = canvas.parentNode;\n if (parent) {\n const nextSibling = canvas.nextSibling;\n parent.replaceChild(img, canvas);\n canvasRestoreInfo.push({ canvas, parent, nextSibling, img });\n }\n } catch {\n // Cross-origin canvas - leave as-is\n }\n }\n defaultProfiler.addTime(\"canvasEncode\", performance.now() - canvasStart);\n \n // Phase 2: Inline external images (if requested)\n if (shouldInlineImages) {\n const inlineStart = performance.now();\n await inlineImages(container);\n defaultProfiler.addTime(\"inline\", performance.now() - inlineStart);\n }\n \n // Phase 3: Serialize to XHTML\n const serializeStart = performance.now();\n \n // Create fresh wrapper element for THIS frame (local variable for closure safety)\n // Multiple concurrent frames in video export each get their own wrapper\n const wrapperElement = document.createElement(\"div\");\n wrapperElement.setAttribute(\"xmlns\", \"http://www.w3.org/1999/xhtml\");\n wrapperElement.setAttribute(\"style\", `width:${width}px;height:${height}px;${WRAPPER_STYLE_BASE}`);\n wrapperElement.appendChild(container);\n \n if (!_xmlSerializer) {\n _xmlSerializer = new XMLSerializer();\n }\n \n // NOTE: Hidden element handling is now done by the caller via removeHiddenNodesForSerialization().\n // The caller physically removes hidden nodes from the clone tree BEFORE calling this function,\n // so hidden elements are never serialized at all - not just hidden with display:none.\n //\n // Benefits of removing before serialization:\n // - Hidden canvases are not encoded (saves encoding time and memory)\n // - Hidden elements are not serialized (smaller SVG, faster serialization)\n // - Hidden images are not inlined (saves fetch and encoding)\n // - The serialized output is smaller and faster to base64 encode\n \n // Serialize to XHTML string\n const perfStart = performance.now();\n const serialized = _xmlSerializer.serializeToString(wrapperElement);\n const serializeTime = performance.now() - perfStart;\n \n // Sample 1% of frames to avoid spam\n if (Math.random() < 0.01) {\n const elementCount = wrapperElement.querySelectorAll('*').length;\n console.log(`[serialize] elements=${elementCount}, time=${serializeTime.toFixed(1)}ms, size=${(serialized.length / 1024).toFixed(1)}KB`);\n }\n\n defaultProfiler.addTime(\"serialize\", performance.now() - serializeStart);\n \n // Prepare restore function (removes container from wrapper, restores canvases)\n // Must be robust against concurrent frame rendering where DOM state may change\n const restore = (): void => {\n const restoreStart = performance.now();\n \n // Guard: only remove if container is still a child of wrapper\n if (container.parentNode === wrapperElement) {\n wrapperElement.removeChild(container);\n }\n \n for (const { canvas, parent, nextSibling, img } of canvasRestoreInfo) {\n // Guard: only restore if img is still in expected position\n if (img.parentNode === parent) {\n // Use replaceChild which is atomic and safer than insertBefore + removeChild\n parent.replaceChild(canvas, img);\n } else if (canvas.parentNode !== parent) {\n // Canvas was never restored and img was moved/removed - try to restore canvas\n if (nextSibling && nextSibling.parentNode === parent) {\n parent.insertBefore(canvas, nextSibling);\n } else {\n parent.appendChild(canvas);\n }\n }\n }\n defaultProfiler.addTime(\"restore\", performance.now() - restoreStart);\n };\n \n // DEBUG: Log serialized HTML size for early renders\n if (logEarlyRenders && defaultProfiler.isEarlyRender(2)) {\n logger.debug(`[serializeToSvgDataUri] FO serialized: ${serialized.length} chars`);\n }\n \n // Phase 4: Create SVG and encode to base64\n const base64Start = performance.now();\n \n // Build SVG string with minimal allocations (concatenation is faster for small strings)\n const svg = SVG_PREFIX + width + SVG_HEIGHT_PREFIX + height + SVG_MIDDLE + serialized + SVG_SUFFIX;\n \n if (!_textEncoder) {\n _textEncoder = new TextEncoder();\n }\n const utf8Bytes = _textEncoder.encode(svg);\n \n let base64: string;\n if (typeof (Uint8Array.prototype as any).toBase64 === \"function\") {\n base64 = (utf8Bytes as any).toBase64();\n } else {\n base64 = encodeBase64Fast(utf8Bytes);\n }\n const dataUri = DATA_URI_PREFIX + base64;\n defaultProfiler.addTime(\"base64\", performance.now() - base64Start);\n \n return { dataUri, restore };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"renderToImageForeignObject.js","names":["_xmlSerializer: XMLSerializer | null","_textEncoder: TextEncoder | null","current: Element | null","canvasRestoreInfo: CanvasRestoreInfo[]","canvasSnapshots: { original: HTMLCanvasElement; copy: HTMLCanvasElement }[]","snapshotSourceMap: WeakMap<HTMLCanvasElement, Element> | undefined","base64: string"],"sources":["../../../src/preview/rendering/renderToImageForeignObject.ts"],"sourcesContent":["/**\n * SVG foreignObject rendering path with serialization.\n */\n\nimport type { SerializeToSvgOptions, SerializationResult, CanvasRestoreInfo } from \"./types.js\";\nimport { encodeBase64Fast } from \"./svgSerializer.js\";\nimport { inlineImages } from \"./inlineImages.js\";\nimport { encodeCanvasesInParallel } from \"../encoding/canvasEncoder.js\";\nimport { defaultProfiler } from \"../RenderProfiler.js\";\nimport { logger } from \"../logger.js\";\n\n// Reusable instances for better performance (avoid creating new instances every frame)\n// Note: wrapper element is NOT reused - each concurrent frame needs its own wrapper\nlet _xmlSerializer: XMLSerializer | null = null;\nlet _textEncoder: TextEncoder | null = null;\n\n/**\n * Check if an element or any of its ancestors has display:none.\n * Used to skip encoding hidden canvases.\n */\nfunction isElementHidden(element: Element): boolean {\n let current: Element | null = element;\n while (current) {\n if (current instanceof HTMLElement && current.style.display === \"none\") {\n return true;\n }\n current = current.parentElement;\n }\n return false;\n}\n\n// Pre-computed SVG constants\nconst SVG_PREFIX = '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"';\nconst SVG_HEIGHT_PREFIX = '\" height=\"';\nconst SVG_MIDDLE = '\"><foreignObject width=\"100%\" height=\"100%\">';\nconst SVG_SUFFIX = '</foreignObject></svg>';\nconst DATA_URI_PREFIX = 'data:image/svg+xml;base64,';\n\n// Shared style string to reduce allocations\nconst WRAPPER_STYLE_BASE = \"overflow:hidden;position:relative;\";\n\n/**\n * Common SVG foreignObject serialization pipeline.\n * Handles canvas encoding, serialization, and base64 encoding.\n * \n * @param container - The HTML element to serialize\n * @param width - Output width\n * @param height - Output height\n * @param options - Serialization options\n * @returns Serialization result with data URI and restore function\n */\nexport async function serializeToSvgDataUri(\n container: HTMLElement,\n width: number,\n height: number,\n options: SerializeToSvgOptions = {},\n): Promise<SerializationResult> {\n const { \n canvasScale = 1, \n inlineImages: shouldInlineImages = false, \n logEarlyRenders = false,\n renderContext,\n sourceMap,\n } = options;\n \n // Store info for restoration (only used if modifying in-place)\n const canvasRestoreInfo: CanvasRestoreInfo[] = [];\n \n // Phase 1: Encode canvases to data URLs (parallel)\n // Filter out hidden canvases - they have display:none and won't render anyway\n const canvasStart = performance.now();\n const allCanvases = Array.from(container.querySelectorAll(\"canvas\"));\n const visibleCanvases = allCanvases.filter(canvas => !isElementHidden(canvas));\n \n // CRITICAL FIX: Synchronously copy canvas pixels BEFORE any async work.\n // This prevents race conditions where concurrent render tasks overwrite\n // the shared clone canvases while encoding is in progress.\n // \n // OPTIMIZATION: Calculate optimal encoding resolution based on:\n // 1. CSS display size (how big it actually appears)\n // 2. Video export scale (output resolution multiplier)\n // 3. Quality multiplier (for sharpness, default 1.5x)\n const canvasSnapshots: { original: HTMLCanvasElement; copy: HTMLCanvasElement }[] = [];\n const qualityMultiplier = 1.5; // Encode at 1.5x display size for quality\n \n for (let i = 0; i < visibleCanvases.length; i++) {\n const canvas = visibleCanvases[i]!;\n if (canvas.width > 0 && canvas.height > 0) {\n // Calculate optimal encoding scale\n let optimalScale = canvasScale; // Start with video export scale\n \n // If we have sourceMap, calculate based on CSS display size\n if (sourceMap) {\n const sourceElement = sourceMap.get(canvas);\n if (sourceElement) {\n try {\n const computedStyle = getComputedStyle(sourceElement);\n const cssWidth = parseFloat(computedStyle.width) || canvas.width;\n const cssHeight = parseFloat(computedStyle.height) || canvas.height;\n \n // Calculate how much smaller the display is vs natural size\n const displayScaleX = cssWidth / canvas.width;\n const displayScaleY = cssHeight / canvas.height;\n const displayScale = Math.min(displayScaleX, displayScaleY);\n \n // Combine display scale, video scale, and quality multiplier\n // Clamp to 1.0 max (never upscale beyond natural resolution)\n optimalScale = Math.min(1.0, displayScale * canvasScale * qualityMultiplier);\n \n console.log(`[serializeToSvg] Canvas ${canvas.width}x${canvas.height} -> CSS ${cssWidth.toFixed(0)}x${cssHeight.toFixed(0)}, displayScale=${displayScale.toFixed(3)}, videoScale=${canvasScale}, optimalScale=${optimalScale.toFixed(3)}`);\n } catch (e) {\n // Fallback to just video scale if we can't get computed style\n console.warn(`[serializeToSvg] Failed to get computed style for ${sourceElement.tagName}:`, e);\n }\n }\n }\n \n // Create snapshot at optimal resolution\n const targetWidth = Math.max(1, Math.floor(canvas.width * optimalScale));\n const targetHeight = Math.max(1, Math.floor(canvas.height * optimalScale));\n \n const copy = document.createElement(\"canvas\");\n copy.width = targetWidth;\n copy.height = targetHeight;\n \n // Copy dataset attributes (e.g., preserveAlpha)\n if (canvas.dataset.preserveAlpha) {\n copy.dataset.preserveAlpha = canvas.dataset.preserveAlpha;\n }\n \n const ctx = copy.getContext(\"2d\");\n if (ctx) {\n // drawImage with scaling is SYNCHRONOUS - pixels are copied and scaled immediately\n ctx.drawImage(canvas, 0, 0, targetWidth, targetHeight);\n }\n canvasSnapshots.push({ original: canvas, copy });\n }\n }\n \n // Encode from the snapshot copies (safe from concurrent overwrites)\n const snapshotCanvases = canvasSnapshots.map(s => s.copy);\n \n // Create a new sourceMap that maps snapshot canvases to their source elements\n // The original sourceMap maps original canvases -> source elements\n // We need snapshot canvases -> source elements for caching to work\n let snapshotSourceMap: WeakMap<HTMLCanvasElement, Element> | undefined;\n if (sourceMap) {\n snapshotSourceMap = new WeakMap();\n for (const { original, copy } of canvasSnapshots) {\n const sourceElement = sourceMap.get(original);\n if (sourceElement) {\n snapshotSourceMap.set(copy, sourceElement);\n }\n }\n }\n \n // Snapshots are already scaled to optimal resolution, so encode at 1.0 scale\n const encodedResults = await encodeCanvasesInParallel(snapshotCanvases, { \n scale: 1.0, // Already scaled during snapshot creation\n renderContext,\n sourceMap: snapshotSourceMap,\n });\n \n // Map encoded results back to original canvases for DOM replacement\n const encodedWithOriginals = encodedResults.map(result => {\n const snapshot = canvasSnapshots.find(s => s.copy === result.canvas);\n return {\n ...result,\n canvas: snapshot?.original ?? result.canvas,\n };\n });\n \n // Replace canvases with images\n for (const { canvas, dataUrl } of encodedWithOriginals) {\n try {\n const img = document.createElement(\"img\");\n img.src = dataUrl;\n img.width = canvas.width;\n img.height = canvas.height;\n const style = canvas.getAttribute(\"style\");\n if (style) img.setAttribute(\"style\", style);\n \n const parent = canvas.parentNode;\n if (parent) {\n const nextSibling = canvas.nextSibling;\n parent.replaceChild(img, canvas);\n canvasRestoreInfo.push({ canvas, parent, nextSibling, img });\n }\n } catch {\n // Cross-origin canvas - leave as-is\n }\n }\n defaultProfiler.addTime(\"canvasEncode\", performance.now() - canvasStart);\n \n // Phase 2: Inline external images (if requested)\n if (shouldInlineImages) {\n const inlineStart = performance.now();\n await inlineImages(container);\n defaultProfiler.addTime(\"inline\", performance.now() - inlineStart);\n }\n \n // Phase 3: Serialize to XHTML\n const serializeStart = performance.now();\n \n // Create fresh wrapper element for THIS frame (local variable for closure safety)\n // Multiple concurrent frames in video export each get their own wrapper\n const wrapperElement = document.createElement(\"div\");\n wrapperElement.setAttribute(\"xmlns\", \"http://www.w3.org/1999/xhtml\");\n wrapperElement.setAttribute(\"style\", `width:${width}px;height:${height}px;${WRAPPER_STYLE_BASE}`);\n wrapperElement.appendChild(container);\n \n if (!_xmlSerializer) {\n _xmlSerializer = new XMLSerializer();\n }\n \n // NOTE: Hidden element handling is now done by the caller via removeHiddenNodesForSerialization().\n // The caller physically removes hidden nodes from the clone tree BEFORE calling this function,\n // so hidden elements are never serialized at all - not just hidden with display:none.\n //\n // Benefits of removing before serialization:\n // - Hidden canvases are not encoded (saves encoding time and memory)\n // - Hidden elements are not serialized (smaller SVG, faster serialization)\n // - Hidden images are not inlined (saves fetch and encoding)\n // - The serialized output is smaller and faster to base64 encode\n \n // Serialize to XHTML string\n const perfStart = performance.now();\n const serialized = _xmlSerializer.serializeToString(wrapperElement);\n const serializeTime = performance.now() - perfStart;\n \n // Sample 1% of frames to avoid spam\n if (Math.random() < 0.01) {\n const elementCount = wrapperElement.querySelectorAll('*').length;\n console.log(`[serialize] elements=${elementCount}, time=${serializeTime.toFixed(1)}ms, size=${(serialized.length / 1024).toFixed(1)}KB`);\n }\n\n defaultProfiler.addTime(\"serialize\", performance.now() - serializeStart);\n \n // Prepare restore function (removes container from wrapper, restores canvases)\n // Must be robust against concurrent frame rendering where DOM state may change\n const restore = (): void => {\n const restoreStart = performance.now();\n \n // Guard: only remove if container is still a child of wrapper\n if (container.parentNode === wrapperElement) {\n wrapperElement.removeChild(container);\n }\n \n for (const { canvas, parent, nextSibling, img } of canvasRestoreInfo) {\n // Guard: only restore if img is still in expected position\n if (img.parentNode === parent) {\n // Use replaceChild which is atomic and safer than insertBefore + removeChild\n parent.replaceChild(canvas, img);\n } else if (canvas.parentNode !== parent) {\n // Canvas was never restored and img was moved/removed - try to restore canvas\n if (nextSibling && nextSibling.parentNode === parent) {\n parent.insertBefore(canvas, nextSibling);\n } else {\n parent.appendChild(canvas);\n }\n }\n }\n defaultProfiler.addTime(\"restore\", performance.now() - restoreStart);\n };\n \n // DEBUG: Log serialized HTML size for early renders\n if (logEarlyRenders && defaultProfiler.isEarlyRender(2)) {\n logger.debug(`[serializeToSvgDataUri] FO serialized: ${serialized.length} chars`);\n }\n \n // Phase 4: Create SVG and encode to base64\n const base64Start = performance.now();\n \n // Build SVG string with minimal allocations (concatenation is faster for small strings)\n const svg = SVG_PREFIX + width + SVG_HEIGHT_PREFIX + height + SVG_MIDDLE + serialized + SVG_SUFFIX;\n \n if (!_textEncoder) {\n _textEncoder = new TextEncoder();\n }\n const utf8Bytes = _textEncoder.encode(svg);\n \n let base64: string;\n if (typeof (Uint8Array.prototype as any).toBase64 === \"function\") {\n base64 = (utf8Bytes as any).toBase64();\n } else {\n base64 = encodeBase64Fast(utf8Bytes);\n }\n const dataUri = DATA_URI_PREFIX + base64;\n defaultProfiler.addTime(\"base64\", performance.now() - base64Start);\n \n return { dataUri, restore };\n}\n"],"mappings":"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"}
|