@hyperframes/producer 0.1.12 → 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.
@@ -102275,8 +102275,8 @@ ${right2.raw}`)
102275
102275
  if (!parentClosePattern.test(between)) {
102276
102276
  pushFinding({
102277
102277
  code: "video_nested_in_timed_element",
102278
- severity: "warning",
102279
- 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.`,
102278
+ severity: "error",
102279
+ 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.`,
102280
102280
  elementId: readAttr(tag.raw, "id") || void 0,
102281
102281
  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).",
102282
102282
  snippet: truncateSnippet(tag.raw)
@@ -102338,6 +102338,41 @@ ${right2.raw}`)
102338
102338
  });
102339
102339
  }
102340
102340
  }
102341
+ for (const tag of tags) {
102342
+ if (tag.name !== "video" && tag.name !== "audio") continue;
102343
+ const hasDataStart = readAttr(tag.raw, "data-start");
102344
+ const hasId = readAttr(tag.raw, "id");
102345
+ const hasSrc = readAttr(tag.raw, "src");
102346
+ if (hasDataStart && !hasId) {
102347
+ pushFinding({
102348
+ code: "media_missing_id",
102349
+ severity: "error",
102350
+ 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.`,
102351
+ fixHint: `Add a unique id attribute: <${tag.name} id="my-${tag.name}" ...>`,
102352
+ snippet: truncateSnippet(tag.raw)
102353
+ });
102354
+ }
102355
+ if (hasDataStart && hasId && !hasSrc) {
102356
+ pushFinding({
102357
+ code: "media_missing_src",
102358
+ severity: "error",
102359
+ message: `<${tag.name} id="${hasId}"> has data-start but no src attribute. The renderer cannot load this media.`,
102360
+ elementId: hasId,
102361
+ fixHint: `Add a src attribute to the <${tag.name}> element directly. If using <source> children, the renderer still requires src on the parent element.`,
102362
+ snippet: truncateSnippet(tag.raw)
102363
+ });
102364
+ }
102365
+ if (readAttr(tag.raw, "preload") === "none") {
102366
+ pushFinding({
102367
+ code: "media_preload_none",
102368
+ severity: "warning",
102369
+ 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.`,
102370
+ elementId: hasId || void 0,
102371
+ fixHint: `Remove preload="none" or change to preload="auto". The framework manages media loading.`,
102372
+ snippet: truncateSnippet(tag.raw)
102373
+ });
102374
+ }
102375
+ }
102341
102376
  for (const tag of tags) {
102342
102377
  if (tag.name === "audio" || tag.name === "script" || tag.name === "style") continue;
102343
102378
  if (!readAttr(tag.raw, "data-start")) continue;
@@ -102453,6 +102488,23 @@ ${right2.raw}`)
102453
102488
  }
102454
102489
  }
102455
102490
  }
102491
+ {
102492
+ const externalScriptRe = /<script\b[^>]*\bsrc=["'](https?:\/\/[^"']+)["'][^>]*>/gi;
102493
+ let match2;
102494
+ const seen2 = /* @__PURE__ */ new Set();
102495
+ while ((match2 = externalScriptRe.exec(source2)) !== null) {
102496
+ const src = match2[1] ?? "";
102497
+ if (seen2.has(src)) continue;
102498
+ seen2.add(src);
102499
+ pushFinding({
102500
+ code: "external_script_dependency",
102501
+ severity: "info",
102502
+ 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.`,
102503
+ 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.",
102504
+ snippet: truncateSnippet(match2[0] ?? "")
102505
+ });
102506
+ }
102507
+ }
102456
102508
  const errorCount = findings.filter((finding) => finding.severity === "error").length;
102457
102509
  const warningCount = findings.length - errorCount;
102458
102510
  return {
@@ -103894,6 +103946,42 @@ import { join as join7 } from "path";
103894
103946
 
103895
103947
  // ../engine/src/utils/ffprobe.ts
103896
103948
  import { spawn as spawn7 } from "child_process";
103949
+ function runFfprobe(args) {
103950
+ return new Promise((resolve12, reject) => {
103951
+ const proc = spawn7("ffprobe", args);
103952
+ let stdout = "";
103953
+ let stderr = "";
103954
+ proc.stdout.on("data", (data) => {
103955
+ stdout += data.toString();
103956
+ });
103957
+ proc.stderr.on("data", (data) => {
103958
+ stderr += data.toString();
103959
+ });
103960
+ proc.on("close", (code) => {
103961
+ if (code !== 0) {
103962
+ reject(new Error(`[FFmpeg] ffprobe exited with code ${code}: ${stderr}`));
103963
+ } else {
103964
+ resolve12(stdout);
103965
+ }
103966
+ });
103967
+ proc.on("error", (err) => {
103968
+ if (err.code === "ENOENT") {
103969
+ reject(new Error("[FFmpeg] ffprobe not found. Please install FFmpeg."));
103970
+ } else {
103971
+ reject(err);
103972
+ }
103973
+ });
103974
+ });
103975
+ }
103976
+ function parseProbeJson(stdout) {
103977
+ try {
103978
+ return JSON.parse(stdout);
103979
+ } catch (e) {
103980
+ throw new Error(
103981
+ `[FFmpeg] Failed to parse ffprobe output: ${e instanceof Error ? e.message : e}`
103982
+ );
103983
+ }
103984
+ }
103897
103985
  var videoMetadataCache = /* @__PURE__ */ new Map();
103898
103986
  var audioMetadataCache = /* @__PURE__ */ new Map();
103899
103987
  function parseFrameRate(frameRateStr) {
@@ -103908,11 +103996,9 @@ function parseFrameRate(frameRateStr) {
103908
103996
  }
103909
103997
  async function extractVideoMetadata(filePath) {
103910
103998
  const cached = videoMetadataCache.get(filePath);
103911
- if (cached) {
103912
- return cached;
103913
- }
103914
- const probePromise = new Promise((resolve12, reject) => {
103915
- const args = [
103999
+ if (cached) return cached;
104000
+ const probePromise = (async () => {
104001
+ const stdout = await runFfprobe([
103916
104002
  "-v",
103917
104003
  "quiet",
103918
104004
  "-print_format",
@@ -103920,56 +104006,24 @@ async function extractVideoMetadata(filePath) {
103920
104006
  "-show_format",
103921
104007
  "-show_streams",
103922
104008
  filePath
103923
- ];
103924
- const ffprobe = spawn7("ffprobe", args);
103925
- let stdout = "";
103926
- let stderr = "";
103927
- ffprobe.stdout.on("data", (data) => {
103928
- stdout += data.toString();
103929
- });
103930
- ffprobe.stderr.on("data", (data) => {
103931
- stderr += data.toString();
103932
- });
103933
- ffprobe.on("close", (code) => {
103934
- if (code !== 0) {
103935
- reject(new Error(`[FFmpeg] ffprobe exited with code ${code}: ${stderr}`));
103936
- return;
103937
- }
103938
- try {
103939
- const output2 = JSON.parse(stdout);
103940
- const videoStream = output2.streams.find((s) => s.codec_type === "video");
103941
- if (!videoStream) {
103942
- reject(new Error("[FFmpeg] No video stream found"));
103943
- return;
103944
- }
103945
- const hasAudio = output2.streams.some((s) => s.codec_type === "audio");
103946
- const fps = parseFrameRate(videoStream.avg_frame_rate) || parseFrameRate(videoStream.r_frame_rate);
103947
- const durationSeconds = output2.format.duration ? parseFloat(output2.format.duration) : 0;
103948
- const metadata = {
103949
- durationSeconds,
103950
- width: videoStream.width || 0,
103951
- height: videoStream.height || 0,
103952
- fps,
103953
- videoCodec: videoStream.codec_name || "unknown",
103954
- hasAudio
103955
- };
103956
- resolve12(metadata);
103957
- } catch (parseError) {
103958
- reject(
103959
- new Error(
103960
- `[FFmpeg] Failed to parse ffprobe output: ${parseError instanceof Error ? parseError.message : parseError}`
103961
- )
103962
- );
103963
- }
103964
- });
103965
- ffprobe.on("error", (err) => {
103966
- if (err.code === "ENOENT") {
103967
- reject(new Error("[FFmpeg] ffprobe not found. Please install FFmpeg."));
103968
- } else {
103969
- reject(err);
103970
- }
103971
- });
103972
- });
104009
+ ]);
104010
+ const output2 = parseProbeJson(stdout);
104011
+ const videoStream = output2.streams.find((s) => s.codec_type === "video");
104012
+ if (!videoStream) throw new Error("[FFmpeg] No video stream found");
104013
+ const rFps = parseFrameRate(videoStream.r_frame_rate);
104014
+ const avgFps = parseFrameRate(videoStream.avg_frame_rate);
104015
+ const fps = avgFps || rFps;
104016
+ const isVFR = rFps > 0 && avgFps > 0 && Math.abs(rFps - avgFps) / Math.max(rFps, avgFps) > 0.1;
104017
+ return {
104018
+ durationSeconds: output2.format.duration ? parseFloat(output2.format.duration) : 0,
104019
+ width: videoStream.width || 0,
104020
+ height: videoStream.height || 0,
104021
+ fps,
104022
+ videoCodec: videoStream.codec_name || "unknown",
104023
+ hasAudio: output2.streams.some((s) => s.codec_type === "audio"),
104024
+ isVFR
104025
+ };
104026
+ })();
103973
104027
  videoMetadataCache.set(filePath, probePromise);
103974
104028
  probePromise.catch(() => {
103975
104029
  if (videoMetadataCache.get(filePath) === probePromise) {
@@ -103980,11 +104034,9 @@ async function extractVideoMetadata(filePath) {
103980
104034
  }
103981
104035
  async function extractAudioMetadata(filePath) {
103982
104036
  const cached = audioMetadataCache.get(filePath);
103983
- if (cached) {
103984
- return cached;
103985
- }
103986
- const probePromise = new Promise((resolve12, reject) => {
103987
- const args = [
104037
+ if (cached) return cached;
104038
+ const probePromise = (async () => {
104039
+ const stdout = await runFfprobe([
103988
104040
  "-v",
103989
104041
  "quiet",
103990
104042
  "-print_format",
@@ -103992,53 +104044,19 @@ async function extractAudioMetadata(filePath) {
103992
104044
  "-show_format",
103993
104045
  "-show_streams",
103994
104046
  filePath
103995
- ];
103996
- const ffprobe = spawn7("ffprobe", args);
103997
- let stdout = "";
103998
- let stderr = "";
103999
- ffprobe.stdout.on("data", (data) => {
104000
- stdout += data.toString();
104001
- });
104002
- ffprobe.stderr.on("data", (data) => {
104003
- stderr += data.toString();
104004
- });
104005
- ffprobe.on("close", (code) => {
104006
- if (code !== 0) {
104007
- reject(new Error(`[FFmpeg] ffprobe exited with code ${code}: ${stderr}`));
104008
- return;
104009
- }
104010
- try {
104011
- const output2 = JSON.parse(stdout);
104012
- const audioStream = output2.streams.find((s) => s.codec_type === "audio");
104013
- if (!audioStream) {
104014
- reject(new Error("[FFmpeg] No audio stream found"));
104015
- return;
104016
- }
104017
- const durationSeconds = output2.format.duration ? parseFloat(output2.format.duration) : 0;
104018
- const metadata = {
104019
- durationSeconds,
104020
- sampleRate: audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100,
104021
- channels: audioStream.channels || 2,
104022
- audioCodec: audioStream.codec_name || "unknown",
104023
- bitrate: output2.format.bit_rate ? parseInt(output2.format.bit_rate) : void 0
104024
- };
104025
- resolve12(metadata);
104026
- } catch (parseError) {
104027
- reject(
104028
- new Error(
104029
- `[FFmpeg] Failed to parse ffprobe output: ${parseError instanceof Error ? parseError.message : parseError}`
104030
- )
104031
- );
104032
- }
104033
- });
104034
- ffprobe.on("error", (err) => {
104035
- if (err.code === "ENOENT") {
104036
- reject(new Error("[FFmpeg] ffprobe not found. Please install FFmpeg."));
104037
- } else {
104038
- reject(err);
104039
- }
104040
- });
104041
- });
104047
+ ]);
104048
+ const output2 = parseProbeJson(stdout);
104049
+ const audioStream = output2.streams.find((s) => s.codec_type === "audio");
104050
+ if (!audioStream) throw new Error("[FFmpeg] No audio stream found");
104051
+ const durationSeconds = output2.format.duration ? parseFloat(output2.format.duration) : 0;
104052
+ return {
104053
+ durationSeconds,
104054
+ sampleRate: audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100,
104055
+ channels: audioStream.channels || 2,
104056
+ audioCodec: audioStream.codec_name || "unknown",
104057
+ bitrate: output2.format.bit_rate ? parseInt(output2.format.bit_rate) : void 0
104058
+ };
104059
+ })();
104042
104060
  audioMetadataCache.set(filePath, probePromise);
104043
104061
  probePromise.catch(() => {
104044
104062
  if (audioMetadataCache.get(filePath) === probePromise) {
@@ -104047,6 +104065,57 @@ async function extractAudioMetadata(filePath) {
104047
104065
  });
104048
104066
  return probePromise;
104049
104067
  }
104068
+ var keyframeCache = /* @__PURE__ */ new Map();
104069
+ async function analyzeKeyframeIntervals(filePath) {
104070
+ const cached = keyframeCache.get(filePath);
104071
+ if (cached) return cached;
104072
+ const promise = analyzeKeyframeIntervalsUncached(filePath);
104073
+ keyframeCache.set(filePath, promise);
104074
+ promise.catch(() => {
104075
+ if (keyframeCache.get(filePath) === promise) {
104076
+ keyframeCache.delete(filePath);
104077
+ }
104078
+ });
104079
+ return promise;
104080
+ }
104081
+ async function analyzeKeyframeIntervalsUncached(filePath) {
104082
+ const stdout = await runFfprobe([
104083
+ "-v",
104084
+ "quiet",
104085
+ "-select_streams",
104086
+ "v:0",
104087
+ "-skip_frame",
104088
+ "nokey",
104089
+ "-show_entries",
104090
+ "frame=pts_time",
104091
+ "-of",
104092
+ "csv=p=0",
104093
+ filePath
104094
+ ]);
104095
+ const timestamps = stdout.split("\n").map((line) => parseFloat(line.trim())).filter((t) => Number.isFinite(t));
104096
+ if (timestamps.length < 2) {
104097
+ return {
104098
+ avgIntervalSeconds: 0,
104099
+ maxIntervalSeconds: 0,
104100
+ keyframeCount: timestamps.length,
104101
+ isProblematic: false
104102
+ };
104103
+ }
104104
+ let maxInterval = 0;
104105
+ let totalInterval = 0;
104106
+ for (let i = 1; i < timestamps.length; i++) {
104107
+ const interval = (timestamps[i] ?? 0) - (timestamps[i - 1] ?? 0);
104108
+ totalInterval += interval;
104109
+ if (interval > maxInterval) maxInterval = interval;
104110
+ }
104111
+ const avgInterval = totalInterval / (timestamps.length - 1);
104112
+ return {
104113
+ avgIntervalSeconds: Math.round(avgInterval * 100) / 100,
104114
+ maxIntervalSeconds: Math.round(maxInterval * 100) / 100,
104115
+ keyframeCount: timestamps.length,
104116
+ isProblematic: maxInterval > 2
104117
+ };
104118
+ }
104050
104119
 
104051
104120
  // ../engine/src/utils/urlDownloader.ts
104052
104121
  import { createWriteStream as createWriteStream2, existsSync as existsSync7, mkdirSync as mkdirSync4 } from "fs";
@@ -104118,20 +104187,38 @@ function isHttpUrl(path12) {
104118
104187
  function parseVideoElements(html) {
104119
104188
  const videos = [];
104120
104189
  const { document: document2 } = parseHTML(html);
104121
- const videoEls = document2.querySelectorAll("video[id][src]");
104190
+ const videoEls = Array.from(
104191
+ /* @__PURE__ */ new Set([
104192
+ ...Array.from(document2.querySelectorAll("video[id][src]")),
104193
+ ...Array.from(document2.querySelectorAll("video[src][data-start]"))
104194
+ ])
104195
+ );
104196
+ videoEls.forEach((el, i) => {
104197
+ if (!el.id) el.id = `hf-video-${i}`;
104198
+ });
104122
104199
  for (const el of videoEls) {
104123
104200
  const id = el.getAttribute("id");
104124
104201
  const src = el.getAttribute("src");
104125
104202
  if (!id || !src) continue;
104126
104203
  const startAttr = el.getAttribute("data-start");
104127
104204
  const endAttr = el.getAttribute("data-end");
104205
+ const durationAttr = el.getAttribute("data-duration");
104128
104206
  const mediaStartAttr = el.getAttribute("data-media-start");
104129
104207
  const hasAudioAttr = el.getAttribute("data-has-audio");
104208
+ const start = startAttr ? parseFloat(startAttr) : 0;
104209
+ let end = 0;
104210
+ if (endAttr) {
104211
+ end = parseFloat(endAttr);
104212
+ } else if (durationAttr) {
104213
+ end = start + parseFloat(durationAttr);
104214
+ } else {
104215
+ end = Infinity;
104216
+ }
104130
104217
  videos.push({
104131
104218
  id,
104132
104219
  src,
104133
- start: startAttr ? parseFloat(startAttr) : 0,
104134
- end: endAttr ? parseFloat(endAttr) : 0,
104220
+ start,
104221
+ end,
104135
104222
  mediaStart: mediaStartAttr ? parseFloat(mediaStartAttr) : 0,
104136
104223
  hasAudio: hasAudioAttr === "true"
104137
104224
  });
@@ -104241,7 +104328,7 @@ async function extractAllVideoFrames(videos, baseDir, options, signal, config2)
104241
104328
  return { error: { videoId: video.id, error: `Video file not found: ${videoPath}` } };
104242
104329
  }
104243
104330
  let videoDuration = video.end - video.start;
104244
- if (videoDuration <= 0) {
104331
+ if (!Number.isFinite(videoDuration) || videoDuration <= 0) {
104245
104332
  const metadata = await extractVideoMetadata(videoPath);
104246
104333
  const sourceDuration = metadata.durationSeconds - video.mediaStart;
104247
104334
  videoDuration = sourceDuration > 0 ? sourceDuration : metadata.durationSeconds;
@@ -105611,6 +105698,7 @@ async function compileHtmlFile(html, baseDir, downloadDir) {
105611
105698
  if (clampList.length > 0) {
105612
105699
  compiledHtml = clampDurations(compiledHtml, clampList);
105613
105700
  }
105701
+ compiledHtml = compiledHtml.replace(/(<video\b[^>]*)\s+crossorigin(?:=["'][^"']*["'])?/gi, "$1");
105614
105702
  return { html: compiledHtml, unresolvedCompositions };
105615
105703
  }
105616
105704
  async function parseSubCompositions(html, projectDir, downloadDir, parentOffset = 0, parentEnd = Infinity, visited = /* @__PURE__ */ new Set()) {
@@ -105963,13 +106051,51 @@ async function compileForRender(projectDir, htmlPath, downloadDir) {
105963
106051
  } = await parseSubCompositions(compiledHtml, projectDir, downloadDir);
105964
106052
  const fullHtml = ensureFullDocument(compiledHtml);
105965
106053
  const inlinedHtml = inlineSubCompositions(fullHtml, subCompositions, projectDir);
106054
+ const sanitizedHtml = inlinedHtml.replace(
106055
+ /(<(?:video|audio)\b[^>]*?)\s+preload\s*=\s*["']none["']/gi,
106056
+ "$1"
106057
+ );
105966
106058
  const html = injectDeterministicFontFaces(
105967
- coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(inlinedHtml))
106059
+ coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(sanitizedHtml))
105968
106060
  );
105969
106061
  const mainVideos = parseVideoElements(html);
105970
106062
  const mainAudios = parseAudioElements(html);
105971
106063
  const videos = dedupeElementsById([...subVideos, ...mainVideos]);
105972
106064
  const audios = dedupeElementsById([...subAudios, ...mainAudios]);
106065
+ for (const video of videos) {
106066
+ if (isHttpUrl(video.src)) continue;
106067
+ const videoPath = resolve7(projectDir, video.src);
106068
+ const reencode = `ffmpeg -i "${video.src}" -c:v libx264 -r 30 -g 30 -keyint_min 30 -movflags +faststart -c:a copy output.mp4`;
106069
+ Promise.all([analyzeKeyframeIntervals(videoPath), extractVideoMetadata(videoPath)]).then(([analysis, metadata]) => {
106070
+ if (analysis.isProblematic) {
106071
+ console.warn(
106072
+ `[Compiler] WARNING: Video "${video.id}" has sparse keyframes (max interval: ${analysis.maxIntervalSeconds}s). This causes seek failures and frame freezing. Re-encode with: ${reencode}`
106073
+ );
106074
+ }
106075
+ if (metadata.isVFR) {
106076
+ console.warn(
106077
+ `[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}`
106078
+ );
106079
+ }
106080
+ }).catch(() => {
106081
+ });
106082
+ }
106083
+ const autoIdVideos = videos.filter((v) => v.id.startsWith("hf-video-"));
106084
+ let htmlWithIds = html;
106085
+ if (autoIdVideos.length > 0) {
106086
+ const { document: idDoc } = parseHTML(html);
106087
+ let changed = false;
106088
+ for (const v of autoIdVideos) {
106089
+ const el = idDoc.querySelector(`video[src="${v.src}"]:not([id])`);
106090
+ if (el) {
106091
+ el.id = v.id;
106092
+ changed = true;
106093
+ }
106094
+ }
106095
+ if (changed) {
106096
+ htmlWithIds = idDoc.documentElement?.outerHTML ?? html;
106097
+ }
106098
+ }
105973
106099
  const { document: document2 } = parseHTML(html);
105974
106100
  const rootEl = document2.querySelector("[data-composition-id]");
105975
106101
  const width = rootEl ? parseInt(rootEl.getAttribute("data-width") || "1080", 10) : 1080;
@@ -105978,7 +106104,7 @@ async function compileForRender(projectDir, htmlPath, downloadDir) {
105978
106104
  rootEl.getAttribute("data-duration") || rootEl.getAttribute("data-composition-duration") || "0"
105979
106105
  ) : 0;
105980
106106
  return {
105981
- html,
106107
+ html: htmlWithIds,
105982
106108
  subCompositions,
105983
106109
  videos,
105984
106110
  audios,
@@ -106884,6 +107010,13 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
106884
107010
  }
106885
107011
  const errorMessage = error instanceof Error ? error.message : String(error);
106886
107012
  const errorStack = error instanceof Error ? error.stack : void 0;
107013
+ const isTimeoutError = errorMessage.includes("Waiting failed") || errorMessage.includes("timeout exceeded") || errorMessage.includes("Navigation timeout");
107014
+ const wasParallel = job.config.workers !== 1;
107015
+ if (isTimeoutError && wasParallel) {
107016
+ log.warn(
107017
+ `Parallel capture timed out with ${job.config.workers ?? "auto"} workers. Video-heavy compositions often need sequential capture. Retry with --workers 1`
107018
+ );
107019
+ }
106887
107020
  job.error = errorMessage;
106888
107021
  updateJobStatus(job, "failed", `Failed: ${errorMessage}`, job.progress, onProgress);
106889
107022
  const elapsed = Date.now() - pipelineStart;