@hyperframes/producer 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -99486,8 +99486,8 @@ ${right2.raw}`)
99486
99486
  if (!parentClosePattern.test(between)) {
99487
99487
  pushFinding({
99488
99488
  code: "video_nested_in_timed_element",
99489
- severity: "warning",
99490
- message: `<video> with data-start appears to be nested inside <${parent.name}${parent.id ? ` id="${parent.id}"` : ""}> which also has data-start. This can break media sync.`,
99489
+ severity: "error",
99490
+ message: `<video> with data-start is nested inside <${parent.name}${parent.id ? ` id="${parent.id}"` : ""}> which also has data-start. The framework cannot manage playback of nested media \u2014 video will be FROZEN in renders.`,
99491
99491
  elementId: readAttr(tag.raw, "id") || void 0,
99492
99492
  fixHint: "Move the <video> to be a direct child of the stage, or remove data-start from the wrapper div (use it as a non-timed visual container).",
99493
99493
  snippet: truncateSnippet(tag.raw)
@@ -99549,6 +99549,41 @@ ${right2.raw}`)
99549
99549
  });
99550
99550
  }
99551
99551
  }
99552
+ for (const tag of tags) {
99553
+ if (tag.name !== "video" && tag.name !== "audio") continue;
99554
+ const hasDataStart = readAttr(tag.raw, "data-start");
99555
+ const hasId = readAttr(tag.raw, "id");
99556
+ const hasSrc = readAttr(tag.raw, "src");
99557
+ if (hasDataStart && !hasId) {
99558
+ pushFinding({
99559
+ code: "media_missing_id",
99560
+ severity: "error",
99561
+ message: `<${tag.name}> has data-start but no id attribute. The renderer requires id to discover media elements \u2014 this ${tag.name === "audio" ? "audio will be SILENT" : "video will be FROZEN"} in renders.`,
99562
+ fixHint: `Add a unique id attribute: <${tag.name} id="my-${tag.name}" ...>`,
99563
+ snippet: truncateSnippet(tag.raw)
99564
+ });
99565
+ }
99566
+ if (hasDataStart && hasId && !hasSrc) {
99567
+ pushFinding({
99568
+ code: "media_missing_src",
99569
+ severity: "error",
99570
+ message: `<${tag.name} id="${hasId}"> has data-start but no src attribute. The renderer cannot load this media.`,
99571
+ elementId: hasId,
99572
+ fixHint: `Add a src attribute to the <${tag.name}> element directly. If using <source> children, the renderer still requires src on the parent element.`,
99573
+ snippet: truncateSnippet(tag.raw)
99574
+ });
99575
+ }
99576
+ if (readAttr(tag.raw, "preload") === "none") {
99577
+ pushFinding({
99578
+ code: "media_preload_none",
99579
+ severity: "warning",
99580
+ message: `<${tag.name}${hasId ? ` id="${hasId}"` : ""}> has preload="none" which prevents the renderer from loading this media. The compiler strips it for renders, but preview may also have issues.`,
99581
+ elementId: hasId || void 0,
99582
+ fixHint: `Remove preload="none" or change to preload="auto". The framework manages media loading.`,
99583
+ snippet: truncateSnippet(tag.raw)
99584
+ });
99585
+ }
99586
+ }
99552
99587
  for (const tag of tags) {
99553
99588
  if (tag.name === "audio" || tag.name === "script" || tag.name === "style") continue;
99554
99589
  if (!readAttr(tag.raw, "data-start")) continue;
@@ -99664,6 +99699,23 @@ ${right2.raw}`)
99664
99699
  }
99665
99700
  }
99666
99701
  }
99702
+ {
99703
+ const externalScriptRe = /<script\b[^>]*\bsrc=["'](https?:\/\/[^"']+)["'][^>]*>/gi;
99704
+ let match2;
99705
+ const seen2 = /* @__PURE__ */ new Set();
99706
+ while ((match2 = externalScriptRe.exec(source2)) !== null) {
99707
+ const src = match2[1] ?? "";
99708
+ if (seen2.has(src)) continue;
99709
+ seen2.add(src);
99710
+ pushFinding({
99711
+ code: "external_script_dependency",
99712
+ severity: "info",
99713
+ message: `This composition loads an external script from \`${src}\`. The HyperFrames bundler automatically hoists CDN scripts from sub-compositions into the parent document. In unbundled runtime mode, \`loadExternalCompositions\` re-injects them. If you're using a custom pipeline that bypasses both, you'll need to include this script manually.`,
99714
+ fixHint: "No action needed when using `hyperframes dev` or `hyperframes render`. If using a custom pipeline, add this script tag to your root composition or HTML page.",
99715
+ snippet: truncateSnippet(match2[0] ?? "")
99716
+ });
99717
+ }
99718
+ }
99667
99719
  const errorCount = findings.filter((finding) => finding.severity === "error").length;
99668
99720
  const warningCount = findings.length - errorCount;
99669
99721
  return {
@@ -101105,6 +101157,42 @@ import { join as join7 } from "path";
101105
101157
 
101106
101158
  // ../engine/src/utils/ffprobe.ts
101107
101159
  import { spawn as spawn7 } from "child_process";
101160
+ function runFfprobe(args) {
101161
+ return new Promise((resolve12, reject) => {
101162
+ const proc = spawn7("ffprobe", args);
101163
+ let stdout = "";
101164
+ let stderr = "";
101165
+ proc.stdout.on("data", (data) => {
101166
+ stdout += data.toString();
101167
+ });
101168
+ proc.stderr.on("data", (data) => {
101169
+ stderr += data.toString();
101170
+ });
101171
+ proc.on("close", (code) => {
101172
+ if (code !== 0) {
101173
+ reject(new Error(`[FFmpeg] ffprobe exited with code ${code}: ${stderr}`));
101174
+ } else {
101175
+ resolve12(stdout);
101176
+ }
101177
+ });
101178
+ proc.on("error", (err) => {
101179
+ if (err.code === "ENOENT") {
101180
+ reject(new Error("[FFmpeg] ffprobe not found. Please install FFmpeg."));
101181
+ } else {
101182
+ reject(err);
101183
+ }
101184
+ });
101185
+ });
101186
+ }
101187
+ function parseProbeJson(stdout) {
101188
+ try {
101189
+ return JSON.parse(stdout);
101190
+ } catch (e) {
101191
+ throw new Error(
101192
+ `[FFmpeg] Failed to parse ffprobe output: ${e instanceof Error ? e.message : e}`
101193
+ );
101194
+ }
101195
+ }
101108
101196
  var videoMetadataCache = /* @__PURE__ */ new Map();
101109
101197
  var audioMetadataCache = /* @__PURE__ */ new Map();
101110
101198
  function parseFrameRate(frameRateStr) {
@@ -101119,11 +101207,9 @@ function parseFrameRate(frameRateStr) {
101119
101207
  }
101120
101208
  async function extractVideoMetadata(filePath) {
101121
101209
  const cached = videoMetadataCache.get(filePath);
101122
- if (cached) {
101123
- return cached;
101124
- }
101125
- const probePromise = new Promise((resolve12, reject) => {
101126
- const args = [
101210
+ if (cached) return cached;
101211
+ const probePromise = (async () => {
101212
+ const stdout = await runFfprobe([
101127
101213
  "-v",
101128
101214
  "quiet",
101129
101215
  "-print_format",
@@ -101131,56 +101217,24 @@ async function extractVideoMetadata(filePath) {
101131
101217
  "-show_format",
101132
101218
  "-show_streams",
101133
101219
  filePath
101134
- ];
101135
- const ffprobe = spawn7("ffprobe", args);
101136
- let stdout = "";
101137
- let stderr = "";
101138
- ffprobe.stdout.on("data", (data) => {
101139
- stdout += data.toString();
101140
- });
101141
- ffprobe.stderr.on("data", (data) => {
101142
- stderr += data.toString();
101143
- });
101144
- ffprobe.on("close", (code) => {
101145
- if (code !== 0) {
101146
- reject(new Error(`[FFmpeg] ffprobe exited with code ${code}: ${stderr}`));
101147
- return;
101148
- }
101149
- try {
101150
- const output2 = JSON.parse(stdout);
101151
- const videoStream = output2.streams.find((s) => s.codec_type === "video");
101152
- if (!videoStream) {
101153
- reject(new Error("[FFmpeg] No video stream found"));
101154
- return;
101155
- }
101156
- const hasAudio = output2.streams.some((s) => s.codec_type === "audio");
101157
- const fps = parseFrameRate(videoStream.avg_frame_rate) || parseFrameRate(videoStream.r_frame_rate);
101158
- const durationSeconds = output2.format.duration ? parseFloat(output2.format.duration) : 0;
101159
- const metadata = {
101160
- durationSeconds,
101161
- width: videoStream.width || 0,
101162
- height: videoStream.height || 0,
101163
- fps,
101164
- videoCodec: videoStream.codec_name || "unknown",
101165
- hasAudio
101166
- };
101167
- resolve12(metadata);
101168
- } catch (parseError) {
101169
- reject(
101170
- new Error(
101171
- `[FFmpeg] Failed to parse ffprobe output: ${parseError instanceof Error ? parseError.message : parseError}`
101172
- )
101173
- );
101174
- }
101175
- });
101176
- ffprobe.on("error", (err) => {
101177
- if (err.code === "ENOENT") {
101178
- reject(new Error("[FFmpeg] ffprobe not found. Please install FFmpeg."));
101179
- } else {
101180
- reject(err);
101181
- }
101182
- });
101183
- });
101220
+ ]);
101221
+ const output2 = parseProbeJson(stdout);
101222
+ const videoStream = output2.streams.find((s) => s.codec_type === "video");
101223
+ if (!videoStream) throw new Error("[FFmpeg] No video stream found");
101224
+ const rFps = parseFrameRate(videoStream.r_frame_rate);
101225
+ const avgFps = parseFrameRate(videoStream.avg_frame_rate);
101226
+ const fps = avgFps || rFps;
101227
+ const isVFR = rFps > 0 && avgFps > 0 && Math.abs(rFps - avgFps) / Math.max(rFps, avgFps) > 0.1;
101228
+ return {
101229
+ durationSeconds: output2.format.duration ? parseFloat(output2.format.duration) : 0,
101230
+ width: videoStream.width || 0,
101231
+ height: videoStream.height || 0,
101232
+ fps,
101233
+ videoCodec: videoStream.codec_name || "unknown",
101234
+ hasAudio: output2.streams.some((s) => s.codec_type === "audio"),
101235
+ isVFR
101236
+ };
101237
+ })();
101184
101238
  videoMetadataCache.set(filePath, probePromise);
101185
101239
  probePromise.catch(() => {
101186
101240
  if (videoMetadataCache.get(filePath) === probePromise) {
@@ -101191,11 +101245,9 @@ async function extractVideoMetadata(filePath) {
101191
101245
  }
101192
101246
  async function extractAudioMetadata(filePath) {
101193
101247
  const cached = audioMetadataCache.get(filePath);
101194
- if (cached) {
101195
- return cached;
101196
- }
101197
- const probePromise = new Promise((resolve12, reject) => {
101198
- const args = [
101248
+ if (cached) return cached;
101249
+ const probePromise = (async () => {
101250
+ const stdout = await runFfprobe([
101199
101251
  "-v",
101200
101252
  "quiet",
101201
101253
  "-print_format",
@@ -101203,53 +101255,19 @@ async function extractAudioMetadata(filePath) {
101203
101255
  "-show_format",
101204
101256
  "-show_streams",
101205
101257
  filePath
101206
- ];
101207
- const ffprobe = spawn7("ffprobe", args);
101208
- let stdout = "";
101209
- let stderr = "";
101210
- ffprobe.stdout.on("data", (data) => {
101211
- stdout += data.toString();
101212
- });
101213
- ffprobe.stderr.on("data", (data) => {
101214
- stderr += data.toString();
101215
- });
101216
- ffprobe.on("close", (code) => {
101217
- if (code !== 0) {
101218
- reject(new Error(`[FFmpeg] ffprobe exited with code ${code}: ${stderr}`));
101219
- return;
101220
- }
101221
- try {
101222
- const output2 = JSON.parse(stdout);
101223
- const audioStream = output2.streams.find((s) => s.codec_type === "audio");
101224
- if (!audioStream) {
101225
- reject(new Error("[FFmpeg] No audio stream found"));
101226
- return;
101227
- }
101228
- const durationSeconds = output2.format.duration ? parseFloat(output2.format.duration) : 0;
101229
- const metadata = {
101230
- durationSeconds,
101231
- sampleRate: audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100,
101232
- channels: audioStream.channels || 2,
101233
- audioCodec: audioStream.codec_name || "unknown",
101234
- bitrate: output2.format.bit_rate ? parseInt(output2.format.bit_rate) : void 0
101235
- };
101236
- resolve12(metadata);
101237
- } catch (parseError) {
101238
- reject(
101239
- new Error(
101240
- `[FFmpeg] Failed to parse ffprobe output: ${parseError instanceof Error ? parseError.message : parseError}`
101241
- )
101242
- );
101243
- }
101244
- });
101245
- ffprobe.on("error", (err) => {
101246
- if (err.code === "ENOENT") {
101247
- reject(new Error("[FFmpeg] ffprobe not found. Please install FFmpeg."));
101248
- } else {
101249
- reject(err);
101250
- }
101251
- });
101252
- });
101258
+ ]);
101259
+ const output2 = parseProbeJson(stdout);
101260
+ const audioStream = output2.streams.find((s) => s.codec_type === "audio");
101261
+ if (!audioStream) throw new Error("[FFmpeg] No audio stream found");
101262
+ const durationSeconds = output2.format.duration ? parseFloat(output2.format.duration) : 0;
101263
+ return {
101264
+ durationSeconds,
101265
+ sampleRate: audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100,
101266
+ channels: audioStream.channels || 2,
101267
+ audioCodec: audioStream.codec_name || "unknown",
101268
+ bitrate: output2.format.bit_rate ? parseInt(output2.format.bit_rate) : void 0
101269
+ };
101270
+ })();
101253
101271
  audioMetadataCache.set(filePath, probePromise);
101254
101272
  probePromise.catch(() => {
101255
101273
  if (audioMetadataCache.get(filePath) === probePromise) {
@@ -101258,6 +101276,57 @@ async function extractAudioMetadata(filePath) {
101258
101276
  });
101259
101277
  return probePromise;
101260
101278
  }
101279
+ var keyframeCache = /* @__PURE__ */ new Map();
101280
+ async function analyzeKeyframeIntervals(filePath) {
101281
+ const cached = keyframeCache.get(filePath);
101282
+ if (cached) return cached;
101283
+ const promise = analyzeKeyframeIntervalsUncached(filePath);
101284
+ keyframeCache.set(filePath, promise);
101285
+ promise.catch(() => {
101286
+ if (keyframeCache.get(filePath) === promise) {
101287
+ keyframeCache.delete(filePath);
101288
+ }
101289
+ });
101290
+ return promise;
101291
+ }
101292
+ async function analyzeKeyframeIntervalsUncached(filePath) {
101293
+ const stdout = await runFfprobe([
101294
+ "-v",
101295
+ "quiet",
101296
+ "-select_streams",
101297
+ "v:0",
101298
+ "-skip_frame",
101299
+ "nokey",
101300
+ "-show_entries",
101301
+ "frame=pts_time",
101302
+ "-of",
101303
+ "csv=p=0",
101304
+ filePath
101305
+ ]);
101306
+ const timestamps = stdout.split("\n").map((line) => parseFloat(line.trim())).filter((t) => Number.isFinite(t));
101307
+ if (timestamps.length < 2) {
101308
+ return {
101309
+ avgIntervalSeconds: 0,
101310
+ maxIntervalSeconds: 0,
101311
+ keyframeCount: timestamps.length,
101312
+ isProblematic: false
101313
+ };
101314
+ }
101315
+ let maxInterval = 0;
101316
+ let totalInterval = 0;
101317
+ for (let i = 1; i < timestamps.length; i++) {
101318
+ const interval = (timestamps[i] ?? 0) - (timestamps[i - 1] ?? 0);
101319
+ totalInterval += interval;
101320
+ if (interval > maxInterval) maxInterval = interval;
101321
+ }
101322
+ const avgInterval = totalInterval / (timestamps.length - 1);
101323
+ return {
101324
+ avgIntervalSeconds: Math.round(avgInterval * 100) / 100,
101325
+ maxIntervalSeconds: Math.round(maxInterval * 100) / 100,
101326
+ keyframeCount: timestamps.length,
101327
+ isProblematic: maxInterval > 2
101328
+ };
101329
+ }
101261
101330
 
101262
101331
  // ../engine/src/utils/urlDownloader.ts
101263
101332
  import { createWriteStream as createWriteStream2, existsSync as existsSync7, mkdirSync as mkdirSync4 } from "fs";
@@ -101329,20 +101398,38 @@ function isHttpUrl(path12) {
101329
101398
  function parseVideoElements(html) {
101330
101399
  const videos = [];
101331
101400
  const { document: document2 } = parseHTML(html);
101332
- const videoEls = document2.querySelectorAll("video[id][src]");
101401
+ const videoEls = Array.from(
101402
+ /* @__PURE__ */ new Set([
101403
+ ...Array.from(document2.querySelectorAll("video[id][src]")),
101404
+ ...Array.from(document2.querySelectorAll("video[src][data-start]"))
101405
+ ])
101406
+ );
101407
+ videoEls.forEach((el, i) => {
101408
+ if (!el.id) el.id = `hf-video-${i}`;
101409
+ });
101333
101410
  for (const el of videoEls) {
101334
101411
  const id = el.getAttribute("id");
101335
101412
  const src = el.getAttribute("src");
101336
101413
  if (!id || !src) continue;
101337
101414
  const startAttr = el.getAttribute("data-start");
101338
101415
  const endAttr = el.getAttribute("data-end");
101416
+ const durationAttr = el.getAttribute("data-duration");
101339
101417
  const mediaStartAttr = el.getAttribute("data-media-start");
101340
101418
  const hasAudioAttr = el.getAttribute("data-has-audio");
101419
+ const start = startAttr ? parseFloat(startAttr) : 0;
101420
+ let end = 0;
101421
+ if (endAttr) {
101422
+ end = parseFloat(endAttr);
101423
+ } else if (durationAttr) {
101424
+ end = start + parseFloat(durationAttr);
101425
+ } else {
101426
+ end = Infinity;
101427
+ }
101341
101428
  videos.push({
101342
101429
  id,
101343
101430
  src,
101344
- start: startAttr ? parseFloat(startAttr) : 0,
101345
- end: endAttr ? parseFloat(endAttr) : 0,
101431
+ start,
101432
+ end,
101346
101433
  mediaStart: mediaStartAttr ? parseFloat(mediaStartAttr) : 0,
101347
101434
  hasAudio: hasAudioAttr === "true"
101348
101435
  });
@@ -101452,7 +101539,7 @@ async function extractAllVideoFrames(videos, baseDir, options, signal, config2)
101452
101539
  return { error: { videoId: video.id, error: `Video file not found: ${videoPath}` } };
101453
101540
  }
101454
101541
  let videoDuration = video.end - video.start;
101455
- if (videoDuration <= 0) {
101542
+ if (!Number.isFinite(videoDuration) || videoDuration <= 0) {
101456
101543
  const metadata = await extractVideoMetadata(videoPath);
101457
101544
  const sourceDuration = metadata.durationSeconds - video.mediaStart;
101458
101545
  videoDuration = sourceDuration > 0 ? sourceDuration : metadata.durationSeconds;
@@ -105446,6 +105533,7 @@ async function compileHtmlFile(html, baseDir, downloadDir) {
105446
105533
  if (clampList.length > 0) {
105447
105534
  compiledHtml = clampDurations(compiledHtml, clampList);
105448
105535
  }
105536
+ compiledHtml = compiledHtml.replace(/(<video\b[^>]*)\s+crossorigin(?:=["'][^"']*["'])?/gi, "$1");
105449
105537
  return { html: compiledHtml, unresolvedCompositions };
105450
105538
  }
105451
105539
  async function parseSubCompositions(html, projectDir, downloadDir, parentOffset = 0, parentEnd = Infinity, visited = /* @__PURE__ */ new Set()) {
@@ -105798,13 +105886,51 @@ async function compileForRender(projectDir, htmlPath, downloadDir) {
105798
105886
  } = await parseSubCompositions(compiledHtml, projectDir, downloadDir);
105799
105887
  const fullHtml = ensureFullDocument(compiledHtml);
105800
105888
  const inlinedHtml = inlineSubCompositions(fullHtml, subCompositions, projectDir);
105889
+ const sanitizedHtml = inlinedHtml.replace(
105890
+ /(<(?:video|audio)\b[^>]*?)\s+preload\s*=\s*["']none["']/gi,
105891
+ "$1"
105892
+ );
105801
105893
  const html = injectDeterministicFontFaces(
105802
- coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(inlinedHtml))
105894
+ coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(sanitizedHtml))
105803
105895
  );
105804
105896
  const mainVideos = parseVideoElements(html);
105805
105897
  const mainAudios = parseAudioElements(html);
105806
105898
  const videos = dedupeElementsById([...subVideos, ...mainVideos]);
105807
105899
  const audios = dedupeElementsById([...subAudios, ...mainAudios]);
105900
+ for (const video of videos) {
105901
+ if (isHttpUrl(video.src)) continue;
105902
+ const videoPath = resolve7(projectDir, video.src);
105903
+ const reencode = `ffmpeg -i "${video.src}" -c:v libx264 -r 30 -g 30 -keyint_min 30 -movflags +faststart -c:a copy output.mp4`;
105904
+ Promise.all([analyzeKeyframeIntervals(videoPath), extractVideoMetadata(videoPath)]).then(([analysis, metadata]) => {
105905
+ if (analysis.isProblematic) {
105906
+ console.warn(
105907
+ `[Compiler] WARNING: Video "${video.id}" has sparse keyframes (max interval: ${analysis.maxIntervalSeconds}s). This causes seek failures and frame freezing. Re-encode with: ${reencode}`
105908
+ );
105909
+ }
105910
+ if (metadata.isVFR) {
105911
+ console.warn(
105912
+ `[Compiler] WARNING: Video "${video.id}" is variable frame rate (VFR). Screen recordings and phone videos are often VFR, which causes stuttering and frame skipping in renders. Re-encode with: ${reencode}`
105913
+ );
105914
+ }
105915
+ }).catch(() => {
105916
+ });
105917
+ }
105918
+ const autoIdVideos = videos.filter((v) => v.id.startsWith("hf-video-"));
105919
+ let htmlWithIds = html;
105920
+ if (autoIdVideos.length > 0) {
105921
+ const { document: idDoc } = parseHTML(html);
105922
+ let changed = false;
105923
+ for (const v of autoIdVideos) {
105924
+ const el = idDoc.querySelector(`video[src="${v.src}"]:not([id])`);
105925
+ if (el) {
105926
+ el.id = v.id;
105927
+ changed = true;
105928
+ }
105929
+ }
105930
+ if (changed) {
105931
+ htmlWithIds = idDoc.documentElement?.outerHTML ?? html;
105932
+ }
105933
+ }
105808
105934
  const { document: document2 } = parseHTML(html);
105809
105935
  const rootEl = document2.querySelector("[data-composition-id]");
105810
105936
  const width = rootEl ? parseInt(rootEl.getAttribute("data-width") || "1080", 10) : 1080;
@@ -105813,7 +105939,7 @@ async function compileForRender(projectDir, htmlPath, downloadDir) {
105813
105939
  rootEl.getAttribute("data-duration") || rootEl.getAttribute("data-composition-duration") || "0"
105814
105940
  ) : 0;
105815
105941
  return {
105816
- html,
105942
+ html: htmlWithIds,
105817
105943
  subCompositions,
105818
105944
  videos,
105819
105945
  audios,
@@ -106719,6 +106845,13 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
106719
106845
  }
106720
106846
  const errorMessage = error instanceof Error ? error.message : String(error);
106721
106847
  const errorStack = error instanceof Error ? error.stack : void 0;
106848
+ const isTimeoutError = errorMessage.includes("Waiting failed") || errorMessage.includes("timeout exceeded") || errorMessage.includes("Navigation timeout");
106849
+ const wasParallel = job.config.workers !== 1;
106850
+ if (isTimeoutError && wasParallel) {
106851
+ log.warn(
106852
+ `Parallel capture timed out with ${job.config.workers ?? "auto"} workers. Video-heavy compositions often need sequential capture. Retry with --workers 1`
106853
+ );
106854
+ }
106722
106855
  job.error = errorMessage;
106723
106856
  updateJobStatus(job, "failed", `Failed: ${errorMessage}`, job.progress, onProgress);
106724
106857
  const elapsed = Date.now() - pipelineStart;