hyperframes 0.4.32 → 0.4.33

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.
Files changed (2) hide show
  1. package/dist/cli.js +500 -52
  2. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -54,7 +54,7 @@ var VERSION;
54
54
  var init_version = __esm({
55
55
  "src/version.ts"() {
56
56
  "use strict";
57
- VERSION = true ? "0.4.32" : "0.0.0-dev";
57
+ VERSION = true ? "0.4.33" : "0.0.0-dev";
58
58
  }
59
59
  });
60
60
 
@@ -9478,9 +9478,12 @@ function readCache(path2) {
9478
9478
  }
9479
9479
  }
9480
9480
  function writeCache(path2, data) {
9481
- mkdirSync(dirname3(path2), { recursive: true });
9482
- const entry = { fetchedAt: Date.now(), data };
9483
- writeFileSync(path2, JSON.stringify(entry), "utf-8");
9481
+ try {
9482
+ mkdirSync(dirname3(path2), { recursive: true });
9483
+ const entry = { fetchedAt: Date.now(), data };
9484
+ writeFileSync(path2, JSON.stringify(entry), "utf-8");
9485
+ } catch {
9486
+ }
9484
9487
  }
9485
9488
  async function fetchJson(url) {
9486
9489
  const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
@@ -28885,6 +28888,7 @@ function calculateOptimalWorkers(totalFrames, requested, config) {
28885
28888
  const effectiveCoresPerWorker = config?.coresPerWorker ?? DEFAULT_CONFIG2.coresPerWorker;
28886
28889
  const effectiveMinParallelFrames = config?.minParallelFrames ?? DEFAULT_CONFIG2.minParallelFrames;
28887
28890
  const effectiveLargeRenderThreshold = config?.largeRenderThreshold ?? DEFAULT_CONFIG2.largeRenderThreshold;
28891
+ const captureCostMultiplier = Math.max(1, config?.captureCostMultiplier ?? 1);
28888
28892
  if (requested !== void 0) {
28889
28893
  return Math.max(MIN_WORKERS, Math.min(effectiveMaxWorkers, requested));
28890
28894
  }
@@ -28897,8 +28901,14 @@ function calculateOptimalWorkers(totalFrames, requested, config) {
28897
28901
  const optimal = Math.min(cpuBasedWorkers, memoryBasedWorkers, frameBasedWorkers);
28898
28902
  const minWorkersForJob = totalFrames >= effectiveMinParallelFrames ? 2 : MIN_WORKERS;
28899
28903
  let finalWorkers = Math.max(minWorkersForJob, Math.min(effectiveMaxWorkers, optimal));
28900
- if (totalFrames >= effectiveLargeRenderThreshold) {
28901
- const cpuScaledMax = Math.max(2, Math.floor(cpuCount / effectiveCoresPerWorker));
28904
+ const weightedFrames = totalFrames * captureCostMultiplier;
28905
+ const contentionThreshold = Math.max(
28906
+ effectiveMinParallelFrames,
28907
+ Math.floor(effectiveLargeRenderThreshold / 3)
28908
+ );
28909
+ if (totalFrames >= effectiveLargeRenderThreshold || weightedFrames >= contentionThreshold) {
28910
+ const weightedCoresPerWorker = effectiveCoresPerWorker * captureCostMultiplier;
28911
+ const cpuScaledMax = Math.max(MIN_WORKERS, Math.floor(cpuCount / weightedCoresPerWorker));
28902
28912
  if (finalWorkers > cpuScaledMax) {
28903
28913
  finalWorkers = cpuScaledMax;
28904
28914
  }
@@ -32649,6 +32659,17 @@ function detectRenderModeHints(html) {
32649
32659
  reasons
32650
32660
  };
32651
32661
  }
32662
+ function detectShaderTransitionUsage(html) {
32663
+ let scriptMatch;
32664
+ const scriptPattern = new RegExp(INLINE_SCRIPT_PATTERN.source, INLINE_SCRIPT_PATTERN.flags);
32665
+ while ((scriptMatch = scriptPattern.exec(html)) !== null) {
32666
+ const attrs = scriptMatch[1] || "";
32667
+ if (/\bsrc\s*=/i.test(attrs)) continue;
32668
+ const content = stripJsComments(stripCompilerMountBootstrap(scriptMatch[2] || ""));
32669
+ if (SHADER_TRANSITION_USAGE_PATTERN.test(content)) return true;
32670
+ }
32671
+ return false;
32672
+ }
32652
32673
  async function resolveMediaDuration(src, mediaStart, baseDir, downloadDir, tagName19) {
32653
32674
  let filePath = src;
32654
32675
  if (isHttpUrl(src)) {
@@ -33185,6 +33206,7 @@ async function compileForRender(projectDir, htmlPath, downloadDir) {
33185
33206
  "$1"
33186
33207
  );
33187
33208
  const renderModeHints = detectRenderModeHints(sanitizedHtml);
33209
+ const hasShaderTransitions = detectShaderTransitionUsage(sanitizedHtml);
33188
33210
  const coalescedHtml = await injectDeterministicFontFaces(
33189
33211
  coalesceHeadStylesAndBodyScripts2(promoteCssImportsToLinkTags(sanitizedHtml))
33190
33212
  );
@@ -33232,7 +33254,8 @@ async function compileForRender(projectDir, htmlPath, downloadDir) {
33232
33254
  width,
33233
33255
  height,
33234
33256
  staticDuration,
33235
- renderModeHints
33257
+ renderModeHints,
33258
+ hasShaderTransitions
33236
33259
  };
33237
33260
  }
33238
33261
  async function discoverMediaFromBrowser(page) {
@@ -33334,10 +33357,11 @@ async function recompileWithResolutions(compiled, resolutions, projectDir, downl
33334
33357
  audios,
33335
33358
  images,
33336
33359
  unresolvedCompositions: remaining,
33337
- renderModeHints: compiled.renderModeHints
33360
+ renderModeHints: compiled.renderModeHints,
33361
+ hasShaderTransitions: compiled.hasShaderTransitions
33338
33362
  };
33339
33363
  }
33340
- var INLINE_SCRIPT_PATTERN, COMPILER_MOUNT_BLOCK_START, COMPILER_MOUNT_BLOCK_END;
33364
+ var INLINE_SCRIPT_PATTERN, COMPILER_MOUNT_BLOCK_START, COMPILER_MOUNT_BLOCK_END, SHADER_TRANSITION_USAGE_PATTERN;
33341
33365
  var init_htmlCompiler2 = __esm({
33342
33366
  "../producer/src/services/htmlCompiler.ts"() {
33343
33367
  "use strict";
@@ -33352,6 +33376,7 @@ var init_htmlCompiler2 = __esm({
33352
33376
  INLINE_SCRIPT_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
33353
33377
  COMPILER_MOUNT_BLOCK_START = "/* __HF_COMPILER_MOUNT_START__ */";
33354
33378
  COMPILER_MOUNT_BLOCK_END = "/* __HF_COMPILER_MOUNT_END__ */";
33379
+ SHADER_TRANSITION_USAGE_PATTERN = /\b(?:(?:window|globalThis)\s*\.\s*)?HyperShader\s*\.\s*init\s*\(|\b__hf\s*\.\s*transitions\s*=/;
33355
33380
  }
33356
33381
  });
33357
33382
 
@@ -33655,7 +33680,8 @@ function writeCompiledArtifacts(compiled, workDir, includeSummary) {
33655
33680
  mediaStart: a.mediaStart
33656
33681
  })),
33657
33682
  subCompositions: Array.from(compiled.subCompositions.keys()),
33658
- renderModeHints: compiled.renderModeHints
33683
+ renderModeHints: compiled.renderModeHints,
33684
+ hasShaderTransitions: compiled.hasShaderTransitions
33659
33685
  };
33660
33686
  writeFileSync14(join35(compileDir, "summary.json"), JSON.stringify(summary, null, 2), "utf-8");
33661
33687
  }
@@ -33668,6 +33694,287 @@ function applyRenderModeHints(cfg, compiled, log2 = defaultLogger) {
33668
33694
  reasons: compiled.renderModeHints.reasons.map((reason) => reason.message)
33669
33695
  });
33670
33696
  }
33697
+ function resolveRenderWorkerCount(totalFrames, requestedWorkers, cfg, compiled, composition, log2 = defaultLogger, measuredCaptureCost) {
33698
+ const captureCost = combineCaptureCostEstimates(
33699
+ estimateCaptureCostMultiplier(compiled, composition),
33700
+ measuredCaptureCost
33701
+ );
33702
+ const workerCount = calculateOptimalWorkers(totalFrames, requestedWorkers, {
33703
+ ...cfg,
33704
+ captureCostMultiplier: captureCost.multiplier
33705
+ });
33706
+ if (requestedWorkers !== void 0 || captureCost.multiplier <= 1) {
33707
+ return workerCount;
33708
+ }
33709
+ const baselineWorkers = calculateOptimalWorkers(totalFrames, void 0, cfg);
33710
+ if (workerCount < baselineWorkers) {
33711
+ log2.warn(
33712
+ "[Render] Reduced auto worker count for high-cost capture workload to avoid Chrome compositor starvation.",
33713
+ {
33714
+ from: baselineWorkers,
33715
+ to: workerCount,
33716
+ costMultiplier: captureCost.multiplier,
33717
+ reasons: captureCost.reasons
33718
+ }
33719
+ );
33720
+ }
33721
+ return workerCount;
33722
+ }
33723
+ function estimateCaptureCostMultiplier(compiled, composition) {
33724
+ let multiplier = 1;
33725
+ const reasons = [];
33726
+ if (compiled.hasShaderTransitions) {
33727
+ multiplier += 2;
33728
+ reasons.push("shader-transitions");
33729
+ }
33730
+ const reasonCodes = new Set(compiled.renderModeHints.reasons.map((reason) => reason.code));
33731
+ if (reasonCodes.has("requestAnimationFrame")) {
33732
+ multiplier += 1;
33733
+ reasons.push("requestAnimationFrame");
33734
+ }
33735
+ if (reasonCodes.has("iframe")) {
33736
+ multiplier += 0.5;
33737
+ reasons.push("iframe");
33738
+ }
33739
+ if (composition.videos.length > 0) {
33740
+ multiplier += Math.min(2, composition.videos.length * 0.75);
33741
+ reasons.push(`${composition.videos.length} video${composition.videos.length === 1 ? "" : "s"}`);
33742
+ }
33743
+ if (composition.audios.length > 0) {
33744
+ multiplier += Math.min(1, composition.audios.length * 0.75);
33745
+ reasons.push(`${composition.audios.length} audio${composition.audios.length === 1 ? "" : "s"}`);
33746
+ }
33747
+ return {
33748
+ multiplier: Math.round(multiplier * 100) / 100,
33749
+ reasons
33750
+ };
33751
+ }
33752
+ function combineCaptureCostEstimates(staticCost, measuredCost) {
33753
+ if (!measuredCost || measuredCost.multiplier <= 1) return staticCost;
33754
+ if (staticCost.multiplier >= measuredCost.multiplier) {
33755
+ return {
33756
+ multiplier: staticCost.multiplier,
33757
+ reasons: [...staticCost.reasons, ...measuredCost.reasons],
33758
+ p95Ms: measuredCost.p95Ms
33759
+ };
33760
+ }
33761
+ return {
33762
+ multiplier: measuredCost.multiplier,
33763
+ reasons: [...measuredCost.reasons, ...staticCost.reasons],
33764
+ p95Ms: measuredCost.p95Ms
33765
+ };
33766
+ }
33767
+ function createCaptureCalibrationConfig(cfg) {
33768
+ return {
33769
+ ...cfg,
33770
+ protocolTimeout: Math.min(cfg.protocolTimeout, CAPTURE_CALIBRATION_PROTOCOL_TIMEOUT_MS)
33771
+ };
33772
+ }
33773
+ function estimateMeasuredCaptureCostMultiplier(samples) {
33774
+ if (samples.length === 0) {
33775
+ return { multiplier: 1, reasons: [] };
33776
+ }
33777
+ const sorted = [...samples].sort((a, b) => a.captureTimeMs - b.captureTimeMs);
33778
+ const p95Index = Math.max(0, Math.ceil(sorted.length * 0.95) - 1);
33779
+ const p95Sample = sorted[p95Index] ?? sorted[sorted.length - 1];
33780
+ if (!p95Sample) {
33781
+ return { multiplier: 1, reasons: [] };
33782
+ }
33783
+ const p95Ms = Math.round(p95Sample.captureTimeMs);
33784
+ const multiplier = Math.min(
33785
+ MAX_MEASURED_CAPTURE_COST_MULTIPLIER,
33786
+ Math.max(1, Math.round(p95Ms / CAPTURE_CALIBRATION_TARGET_MS * 100) / 100)
33787
+ );
33788
+ return {
33789
+ multiplier,
33790
+ reasons: multiplier > 1 ? [`calibration-p95=${p95Ms}ms`] : [],
33791
+ p95Ms
33792
+ };
33793
+ }
33794
+ function selectCaptureCalibrationFrames(totalFrames) {
33795
+ if (totalFrames <= 0) return [];
33796
+ const lastFrame = totalFrames - 1;
33797
+ const candidates = [
33798
+ 0,
33799
+ Math.floor(totalFrames * 0.25),
33800
+ Math.floor(totalFrames * 0.5),
33801
+ Math.floor(totalFrames * 0.75),
33802
+ lastFrame
33803
+ ];
33804
+ return Array.from(
33805
+ new Set(candidates.map((frame) => Math.max(0, Math.min(lastFrame, frame))))
33806
+ ).sort((a, b) => a - b);
33807
+ }
33808
+ function findMissingFrameRanges(totalFrames, framesDir, frameExt) {
33809
+ const ranges = [];
33810
+ let rangeStart = null;
33811
+ for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
33812
+ const framePath = join35(framesDir, `frame_${String(frameIndex).padStart(6, "0")}.${frameExt}`);
33813
+ const missing = !existsSync33(framePath);
33814
+ if (missing && rangeStart === null) {
33815
+ rangeStart = frameIndex;
33816
+ } else if (!missing && rangeStart !== null) {
33817
+ ranges.push({ startFrame: rangeStart, endFrame: frameIndex });
33818
+ rangeStart = null;
33819
+ }
33820
+ }
33821
+ if (rangeStart !== null) {
33822
+ ranges.push({ startFrame: rangeStart, endFrame: totalFrames });
33823
+ }
33824
+ return ranges;
33825
+ }
33826
+ function buildMissingFrameRetryBatches(ranges, maxWorkers, workDir, attempt) {
33827
+ const workersPerBatch = Math.max(1, Math.floor(maxWorkers));
33828
+ const batches = [];
33829
+ for (let i2 = 0; i2 < ranges.length; i2 += workersPerBatch) {
33830
+ const batchIndex = batches.length;
33831
+ const batch = ranges.slice(i2, i2 + workersPerBatch).map((range, workerId) => ({
33832
+ workerId,
33833
+ startFrame: range.startFrame,
33834
+ endFrame: range.endFrame,
33835
+ outputDir: join35(workDir, `retry-${attempt}-batch-${batchIndex}-worker-${workerId}`)
33836
+ }));
33837
+ batches.push(batch);
33838
+ }
33839
+ return batches;
33840
+ }
33841
+ function getNextRetryWorkerCount(currentWorkers) {
33842
+ return Math.max(1, Math.floor(currentWorkers / 2));
33843
+ }
33844
+ function isRecoverableParallelCaptureError(error) {
33845
+ const message = error instanceof Error ? error.message : String(error);
33846
+ return message.includes("[Parallel] Capture failed") && /Runtime\.callFunctionOn timed out|HeadlessExperimental\.beginFrame timed out|Waiting failed|timeout exceeded|timed out|Navigation timeout|Protocol error|Target closed/i.test(
33847
+ message
33848
+ );
33849
+ }
33850
+ function shouldFallbackToScreenshotAfterCalibrationError(error) {
33851
+ const message = error instanceof Error ? error.message : String(error);
33852
+ return /HeadlessExperimental\.beginFrame timed out|beginFrame probe timeout|Another frame is pending|Frame still pending|Protocol error.*HeadlessExperimental\.beginFrame/i.test(
33853
+ message
33854
+ );
33855
+ }
33856
+ function countCapturedFrames(totalFrames, framesDir, frameExt) {
33857
+ let captured = 0;
33858
+ for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
33859
+ const framePath = join35(framesDir, `frame_${String(frameIndex).padStart(6, "0")}.${frameExt}`);
33860
+ if (existsSync33(framePath)) captured++;
33861
+ }
33862
+ return captured;
33863
+ }
33864
+ function countFrameRanges(ranges) {
33865
+ return ranges.reduce((sum, range) => sum + (range.endFrame - range.startFrame), 0);
33866
+ }
33867
+ async function measureCaptureCostFromSession(session, totalFrames, fps) {
33868
+ const sampledFrames = selectCaptureCalibrationFrames(totalFrames);
33869
+ const samples = [];
33870
+ for (const frameIndex of sampledFrames) {
33871
+ const time = frameIndex / fps;
33872
+ const startedAt = Date.now();
33873
+ const result = await captureFrameToBuffer(session, frameIndex, time);
33874
+ samples.push({
33875
+ frameIndex,
33876
+ captureTimeMs: result.captureTimeMs || Date.now() - startedAt
33877
+ });
33878
+ }
33879
+ return {
33880
+ estimate: estimateMeasuredCaptureCostMultiplier(samples),
33881
+ samples
33882
+ };
33883
+ }
33884
+ async function executeDiskCaptureWithAdaptiveRetry(options) {
33885
+ const attempts = [];
33886
+ let currentWorkers = options.initialWorkerCount;
33887
+ let missingRanges = null;
33888
+ let attempt = 0;
33889
+ while (true) {
33890
+ const frameCount = missingRanges ? countFrameRanges(missingRanges) : options.totalFrames;
33891
+ attempts.push({
33892
+ attempt,
33893
+ workers: currentWorkers,
33894
+ frameCount,
33895
+ reason: attempt === 0 ? "initial" : "retry"
33896
+ });
33897
+ const attemptWorkDir = join35(options.workDir, `capture-attempt-${attempt}`);
33898
+ const batches = missingRanges ? buildMissingFrameRetryBatches(missingRanges, currentWorkers, attemptWorkDir, attempt) : [distributeFrames(options.totalFrames, currentWorkers, attemptWorkDir)];
33899
+ try {
33900
+ for (const tasks of batches) {
33901
+ const capturedBeforeBatch = countCapturedFrames(
33902
+ options.totalFrames,
33903
+ options.framesDir,
33904
+ options.frameExt
33905
+ );
33906
+ try {
33907
+ await executeParallelCapture(
33908
+ options.serverUrl,
33909
+ attemptWorkDir,
33910
+ tasks,
33911
+ options.captureOptions,
33912
+ options.createBeforeCaptureHook,
33913
+ options.abortSignal,
33914
+ options.onProgress ? (progress) => {
33915
+ options.onProgress?.({
33916
+ ...progress,
33917
+ totalFrames: options.totalFrames,
33918
+ capturedFrames: Math.min(
33919
+ options.totalFrames,
33920
+ capturedBeforeBatch + progress.capturedFrames
33921
+ )
33922
+ });
33923
+ } : void 0,
33924
+ void 0,
33925
+ options.cfg
33926
+ );
33927
+ } finally {
33928
+ await mergeWorkerFrames(attemptWorkDir, tasks, options.framesDir);
33929
+ }
33930
+ }
33931
+ const remaining = findMissingFrameRanges(
33932
+ options.totalFrames,
33933
+ options.framesDir,
33934
+ options.frameExt
33935
+ );
33936
+ if (remaining.length === 0) {
33937
+ return attempts;
33938
+ }
33939
+ if (!options.allowRetry || currentWorkers <= 1) {
33940
+ throw new Error(
33941
+ `[Render] Capture completed but ${countFrameRanges(remaining)} frame(s) are missing`
33942
+ );
33943
+ }
33944
+ const nextWorkers = getNextRetryWorkerCount(currentWorkers);
33945
+ options.log.warn("[Render] Retrying missing captured frames with fewer workers.", {
33946
+ fromWorkers: currentWorkers,
33947
+ toWorkers: nextWorkers,
33948
+ missingFrames: countFrameRanges(remaining)
33949
+ });
33950
+ currentWorkers = nextWorkers;
33951
+ missingRanges = remaining;
33952
+ attempt++;
33953
+ } catch (error) {
33954
+ const remaining = findMissingFrameRanges(
33955
+ options.totalFrames,
33956
+ options.framesDir,
33957
+ options.frameExt
33958
+ );
33959
+ if (remaining.length === 0) {
33960
+ return attempts;
33961
+ }
33962
+ if (!options.allowRetry || currentWorkers <= 1 || !isRecoverableParallelCaptureError(error)) {
33963
+ throw error;
33964
+ }
33965
+ const nextWorkers = getNextRetryWorkerCount(currentWorkers);
33966
+ options.log.warn("[Render] Parallel capture timed out; retrying missing frames.", {
33967
+ fromWorkers: currentWorkers,
33968
+ toWorkers: nextWorkers,
33969
+ missingFrames: countFrameRanges(remaining),
33970
+ error: error instanceof Error ? error.message : String(error)
33971
+ });
33972
+ currentWorkers = nextWorkers;
33973
+ missingRanges = remaining;
33974
+ attempt++;
33975
+ }
33976
+ }
33977
+ }
33671
33978
  function blitHdrVideoLayer(canvas, el, time, fps, hdrFrameDirs, hdrStartTimes, width, height, log2, sourceTransfer, targetTransfer) {
33672
33979
  const frameDir = hdrFrameDirs.get(el.id);
33673
33980
  const startTime = hdrStartTimes.get(el.id);
@@ -34294,7 +34601,7 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
34294
34601
  let extractionResult = null;
34295
34602
  const nativeHdrVideoIds = /* @__PURE__ */ new Set();
34296
34603
  const videoTransfers = /* @__PURE__ */ new Map();
34297
- if (job.config.hdr && composition.videos.length > 0) {
34604
+ if (job.config.hdrMode !== "force-sdr" && composition.videos.length > 0) {
34298
34605
  await Promise.all(
34299
34606
  composition.videos.map(async (v) => {
34300
34607
  let videoPath = v.src;
@@ -34315,7 +34622,7 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
34315
34622
  const imageTransfers = /* @__PURE__ */ new Map();
34316
34623
  const hdrImageSrcPaths = /* @__PURE__ */ new Map();
34317
34624
  const imageColorSpaces = [];
34318
- if (job.config.hdr && composition.images.length > 0) {
34625
+ if (job.config.hdrMode !== "force-sdr" && composition.images.length > 0) {
34319
34626
  const probed = await Promise.all(
34320
34627
  composition.images.map(async (img) => {
34321
34628
  let imgPath = img.src;
@@ -34372,28 +34679,53 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
34372
34679
  perfStages.videoExtractMs = Date.now() - stage2Start;
34373
34680
  }
34374
34681
  let effectiveHdr;
34375
- if (job.config.hdr) {
34682
+ let forcedHdrWithoutSources = false;
34683
+ {
34684
+ const hdrMode = job.config.hdrMode ?? "auto";
34376
34685
  const videoColorSpaces = (extractionResult?.extracted ?? []).map(
34377
34686
  (ext) => ext.metadata.colorSpace
34378
34687
  );
34379
34688
  const allColorSpaces = [...videoColorSpaces, ...imageColorSpaces];
34380
- if (allColorSpaces.length > 0) {
34381
- const info = analyzeCompositionHdr(allColorSpaces);
34382
- if (info.hasHdr && info.dominantTransfer) {
34689
+ const info = allColorSpaces.length > 0 ? analyzeCompositionHdr(allColorSpaces) : null;
34690
+ if (hdrMode === "force-sdr") {
34691
+ effectiveHdr = void 0;
34692
+ } else if (hdrMode === "force-hdr") {
34693
+ if (info?.hasHdr && info.dominantTransfer) {
34694
+ effectiveHdr = { transfer: info.dominantTransfer };
34695
+ } else {
34696
+ effectiveHdr = { transfer: "hlg" };
34697
+ forcedHdrWithoutSources = true;
34698
+ }
34699
+ } else {
34700
+ if (info?.hasHdr && info.dominantTransfer) {
34383
34701
  effectiveHdr = { transfer: info.dominantTransfer };
34384
34702
  }
34385
34703
  }
34386
34704
  }
34387
34705
  if (effectiveHdr && outputFormat !== "mp4") {
34706
+ const hdrSourceReason = forcedHdrWithoutSources ? "HDR was forced without detected HDR sources" : "HDR source detected";
34388
34707
  log2.warn(
34389
- `[Render] HDR source detected but format is "${outputFormat}" \u2014 falling back to SDR. HDR + alpha is not supported. Use --format mp4 for HDR10 output.`
34708
+ `[Render] ${hdrSourceReason}, but format is "${outputFormat}" \u2014 falling back to SDR. HDR + alpha is not supported. Use --format mp4 for HDR10 output.`
34390
34709
  );
34391
34710
  effectiveHdr = void 0;
34392
34711
  }
34393
- if (effectiveHdr) {
34394
- log2.info(
34395
- `[Render] HDR source detected \u2014 output: ${effectiveHdr.transfer.toUpperCase()} (BT.2020, 10-bit H.265)`
34396
- );
34712
+ {
34713
+ const hdrMode = job.config.hdrMode ?? "auto";
34714
+ if (forcedHdrWithoutSources) {
34715
+ log2.warn(
34716
+ "[Render] HDR forced by --hdr flag, but no HDR sources were detected \u2014 defaulting to HLG. SDR-only compositions may look perceptually wrong on HDR displays."
34717
+ );
34718
+ }
34719
+ if (effectiveHdr) {
34720
+ const reason = hdrMode === "force-hdr" ? forcedHdrWithoutSources ? "forced by --hdr flag (no HDR sources detected \u2014 defaulting to HLG)" : "forced by --hdr flag" : "auto-detected from source(s)";
34721
+ log2.info(
34722
+ `[Render] HDR ${reason} \u2014 output: ${effectiveHdr.transfer.toUpperCase()} (BT.2020, 10-bit H.265)`
34723
+ );
34724
+ } else if (hdrMode === "force-sdr") {
34725
+ log2.info("[Render] SDR forced by --sdr flag");
34726
+ } else {
34727
+ log2.info("[Render] No HDR sources detected \u2014 rendering SDR");
34728
+ }
34397
34729
  }
34398
34730
  const stage3Start = Date.now();
34399
34731
  updateJobStatus(job, "preprocessing", "Processing audio tracks", 20, onProgress);
@@ -34440,7 +34772,101 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
34440
34772
  ...captureOptions,
34441
34773
  skipReadinessVideoIds: Array.from(nativeHdrVideoIds)
34442
34774
  });
34443
- const workerCount = calculateOptimalWorkers(totalFrames, job.config.workers, cfg);
34775
+ let captureCalibration;
34776
+ let switchedToScreenshotAfterCalibration = false;
34777
+ if (job.config.workers === void 0 && totalFrames >= 60) {
34778
+ const calibrationDir = join35(workDir, "capture-calibration");
34779
+ const calibrationCfg = createCaptureCalibrationConfig(cfg);
34780
+ const videoInjector = createVideoFrameInjector(frameLookup);
34781
+ let calibrationSession = null;
34782
+ try {
34783
+ calibrationSession = await createCaptureSession(
34784
+ fileServer.url,
34785
+ calibrationDir,
34786
+ buildHdrCaptureOptions(),
34787
+ videoInjector,
34788
+ calibrationCfg
34789
+ );
34790
+ if (!calibrationSession.isInitialized) {
34791
+ await initializeSession(calibrationSession);
34792
+ }
34793
+ assertNotAborted();
34794
+ captureCalibration = await measureCaptureCostFromSession(
34795
+ calibrationSession,
34796
+ totalFrames,
34797
+ job.config.fps
34798
+ );
34799
+ if (captureCalibration.estimate.multiplier > 1) {
34800
+ log2.warn("[Render] Measured slow frame capture during auto-worker calibration.", {
34801
+ multiplier: captureCalibration.estimate.multiplier,
34802
+ p95Ms: captureCalibration.estimate.p95Ms,
34803
+ sampledFrames: captureCalibration.samples.map((sample) => sample.frameIndex)
34804
+ });
34805
+ } else {
34806
+ log2.debug("[Render] Auto-worker calibration kept baseline capture cost.", {
34807
+ p95Ms: captureCalibration.estimate.p95Ms,
34808
+ sampledFrames: captureCalibration.samples.map((sample) => sample.frameIndex)
34809
+ });
34810
+ }
34811
+ } catch (error) {
34812
+ const shouldFallbackToScreenshot = !cfg.forceScreenshot && shouldFallbackToScreenshotAfterCalibrationError(error);
34813
+ if (shouldFallbackToScreenshot) {
34814
+ cfg.forceScreenshot = true;
34815
+ switchedToScreenshotAfterCalibration = true;
34816
+ if (probeSession) {
34817
+ lastBrowserConsole = probeSession.browserConsoleBuffer;
34818
+ await closeCaptureSession(probeSession).catch(() => {
34819
+ });
34820
+ probeSession = null;
34821
+ }
34822
+ }
34823
+ captureCalibration = {
34824
+ estimate: {
34825
+ multiplier: MAX_MEASURED_CAPTURE_COST_MULTIPLIER,
34826
+ reasons: shouldFallbackToScreenshot ? ["calibration-beginframe-timeout", "screenshot-fallback"] : ["calibration-failed"]
34827
+ },
34828
+ samples: []
34829
+ };
34830
+ if (shouldFallbackToScreenshot) {
34831
+ log2.warn(
34832
+ "[Render] BeginFrame auto-worker calibration timed out; falling back to screenshot capture mode.",
34833
+ {
34834
+ protocolTimeout: calibrationCfg.protocolTimeout,
34835
+ error: error instanceof Error ? error.message : String(error)
34836
+ }
34837
+ );
34838
+ } else {
34839
+ log2.warn("[Render] Auto-worker calibration failed; using conservative worker budget.", {
34840
+ protocolTimeout: calibrationCfg.protocolTimeout,
34841
+ error: error instanceof Error ? error.message : String(error)
34842
+ });
34843
+ }
34844
+ } finally {
34845
+ if (calibrationSession) {
34846
+ lastBrowserConsole = calibrationSession.browserConsoleBuffer;
34847
+ await closeCaptureSession(calibrationSession).catch(() => {
34848
+ });
34849
+ }
34850
+ }
34851
+ }
34852
+ let workerCount = resolveRenderWorkerCount(
34853
+ totalFrames,
34854
+ job.config.workers,
34855
+ cfg,
34856
+ compiled,
34857
+ composition,
34858
+ log2,
34859
+ captureCalibration?.estimate
34860
+ );
34861
+ if (switchedToScreenshotAfterCalibration && workerCount > 1) {
34862
+ workerCount = 1;
34863
+ }
34864
+ if (workerCount > 1 && probeSession) {
34865
+ lastBrowserConsole = probeSession.browserConsoleBuffer;
34866
+ await closeCaptureSession(probeSession);
34867
+ probeSession = null;
34868
+ }
34869
+ const captureAttempts = [];
34444
34870
  const FORMAT_EXT2 = {
34445
34871
  mp4: ".mp4",
34446
34872
  webm: ".webm",
@@ -35032,15 +35458,18 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
35032
35458
  perfStages.encodeMs = encodeResult.durationMs;
35033
35459
  } else {
35034
35460
  if (workerCount > 1) {
35035
- const tasks = distributeFrames(job.totalFrames, workerCount, workDir);
35036
- await executeParallelCapture(
35037
- fileServer.url,
35461
+ const attempts = await executeDiskCaptureWithAdaptiveRetry({
35462
+ serverUrl: fileServer.url,
35038
35463
  workDir,
35039
- tasks,
35040
- buildHdrCaptureOptions(),
35041
- () => createVideoFrameInjector(frameLookup),
35464
+ framesDir,
35465
+ totalFrames: job.totalFrames,
35466
+ initialWorkerCount: workerCount,
35467
+ allowRetry: job.config.workers === void 0,
35468
+ frameExt: needsAlpha ? "png" : "jpg",
35469
+ captureOptions: buildHdrCaptureOptions(),
35470
+ createBeforeCaptureHook: () => createVideoFrameInjector(frameLookup),
35042
35471
  abortSignal,
35043
- (progress) => {
35472
+ onProgress: (progress) => {
35044
35473
  job.framesRendered = progress.capturedFrames;
35045
35474
  const frameProgress = progress.capturedFrames / progress.totalFrames;
35046
35475
  const progressPct = 25 + frameProgress * 45;
@@ -35048,16 +35477,20 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
35048
35477
  updateJobStatus(
35049
35478
  job,
35050
35479
  "rendering",
35051
- `Capturing frame ${progress.capturedFrames}/${progress.totalFrames} (${workerCount} workers)`,
35480
+ `Capturing frame ${progress.capturedFrames}/${progress.totalFrames} (${progress.activeWorkers} workers)`,
35052
35481
  Math.round(progressPct),
35053
35482
  onProgress
35054
35483
  );
35055
35484
  }
35056
35485
  },
35057
- void 0,
35058
- cfg
35059
- );
35060
- await mergeWorkerFrames(workDir, tasks, framesDir);
35486
+ cfg,
35487
+ log: log2
35488
+ });
35489
+ captureAttempts.push(...attempts);
35490
+ const lastAttempt = attempts[attempts.length - 1];
35491
+ if (lastAttempt) {
35492
+ workerCount = lastAttempt.workers;
35493
+ }
35061
35494
  if (probeSession) {
35062
35495
  lastBrowserConsole = probeSession.browserConsoleBuffer;
35063
35496
  await closeCaptureSession(probeSession);
@@ -35227,6 +35660,13 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
35227
35660
  stages: perfStages,
35228
35661
  videoExtractBreakdown: extractionResult?.phaseBreakdown,
35229
35662
  tmpPeakBytes,
35663
+ captureCalibration: captureCalibration ? {
35664
+ sampledFrames: captureCalibration.samples.map((sample) => sample.frameIndex),
35665
+ p95Ms: captureCalibration.estimate.p95Ms,
35666
+ multiplier: captureCalibration.estimate.multiplier,
35667
+ reasons: captureCalibration.estimate.reasons
35668
+ } : void 0,
35669
+ captureAttempts: captureAttempts.length > 0 ? captureAttempts : void 0,
35230
35670
  hdrDiagnostics: hdrDiagnostics.videoExtractionFailures > 0 || hdrDiagnostics.imageDecodeFailures > 0 ? { ...hdrDiagnostics } : void 0,
35231
35671
  captureAvgMs: totalFrames > 0 ? Math.round((perfStages.captureMs ?? 0) / totalFrames) : void 0,
35232
35672
  peakRssMb: Math.round(peakRssBytes / (1024 * 1024)),
@@ -35342,7 +35782,7 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
35342
35782
  clearInterval(memSamplerInterval);
35343
35783
  }
35344
35784
  }
35345
- var RenderCancelledError, BROWSER_MEDIA_EPSILON;
35785
+ var RenderCancelledError, BROWSER_MEDIA_EPSILON, CAPTURE_CALIBRATION_TARGET_MS, MAX_MEASURED_CAPTURE_COST_MULTIPLIER, CAPTURE_CALIBRATION_PROTOCOL_TIMEOUT_MS;
35346
35786
  var init_renderOrchestrator = __esm({
35347
35787
  "../producer/src/services/renderOrchestrator.ts"() {
35348
35788
  "use strict";
@@ -35363,6 +35803,9 @@ var init_renderOrchestrator = __esm({
35363
35803
  }
35364
35804
  };
35365
35805
  BROWSER_MEDIA_EPSILON = 1e-4;
35806
+ CAPTURE_CALIBRATION_TARGET_MS = 600;
35807
+ MAX_MEASURED_CAPTURE_COST_MULTIPLIER = 8;
35808
+ CAPTURE_CALIBRATION_PROTOCOL_TIMEOUT_MS = 3e4;
35366
35809
  }
35367
35810
  });
35368
35811
 
@@ -38384,13 +38827,13 @@ function buildDockerRunArgs(input) {
38384
38827
  options.quality,
38385
38828
  "--format",
38386
38829
  options.format,
38387
- "--workers",
38388
- String(options.workers),
38830
+ ...options.workers != null ? ["--workers", String(options.workers)] : [],
38389
38831
  ...options.crf != null ? ["--crf", String(options.crf)] : [],
38390
38832
  ...options.videoBitrate ? ["--video-bitrate", options.videoBitrate] : [],
38391
38833
  ...options.quiet ? ["--quiet"] : [],
38392
38834
  ...options.gpu ? ["--gpu"] : [],
38393
- ...options.hdr ? ["--hdr"] : []
38835
+ ...options.hdrMode === "force-hdr" ? ["--hdr"] : [],
38836
+ ...options.hdrMode === "force-sdr" ? ["--sdr"] : []
38394
38837
  ];
38395
38838
  }
38396
38839
  var init_dockerRunArgs = __esm({
@@ -38446,9 +38889,6 @@ import { mkdirSync as mkdirSync24, readFileSync as readFileSync29, statSync as s
38446
38889
  import { cpus as cpus3, freemem as freemem3, tmpdir as tmpdir4 } from "os";
38447
38890
  import { resolve as resolve28, dirname as dirname16, join as join43, basename as basename9 } from "path";
38448
38891
  import { execFileSync as execFileSync5, spawn as spawn11 } from "child_process";
38449
- function defaultWorkerCount() {
38450
- return Math.max(1, Math.min(Math.floor(CPU_CORE_COUNT * 3 / 4), 8));
38451
- }
38452
38892
  function dockerImageTag(version) {
38453
38893
  return `${DOCKER_IMAGE_PREFIX}:${version}`;
38454
38894
  }
@@ -38540,7 +38980,7 @@ async function renderDocker(projectDir, outputPath, options) {
38540
38980
  format: options.format,
38541
38981
  workers: options.workers,
38542
38982
  gpu: options.gpu,
38543
- hdr: options.hdr,
38983
+ hdrMode: options.hdrMode,
38544
38984
  crf: options.crf,
38545
38985
  videoBitrate: options.videoBitrate,
38546
38986
  quiet: options.quiet
@@ -38589,7 +39029,7 @@ async function renderLocal(projectDir, outputPath, options) {
38589
39029
  format: options.format,
38590
39030
  workers: options.workers,
38591
39031
  useGpu: options.gpu,
38592
- hdr: options.hdr,
39032
+ hdrMode: options.hdrMode,
38593
39033
  crf: options.crf,
38594
39034
  videoBitrate: options.videoBitrate
38595
39035
  });
@@ -38636,7 +39076,7 @@ function trackRenderMetrics(job, elapsedMs, options, docker) {
38636
39076
  durationMs: elapsedMs,
38637
39077
  fps: options.fps,
38638
39078
  quality: options.quality,
38639
- workers: options.workers,
39079
+ workers: options.workers ?? perf?.workers,
38640
39080
  docker,
38641
39081
  gpu: options.gpu,
38642
39082
  compositionDurationMs,
@@ -38702,7 +39142,7 @@ var init_render2 = __esm({
38702
39142
  ["High quality at 60fps", "hyperframes render --fps 60 --quality high --output hd.mp4"],
38703
39143
  ["Deterministic render via Docker", "hyperframes render --docker --output deterministic.mp4"],
38704
39144
  ["Parallel rendering with 6 workers", "hyperframes render --workers 6 --output fast.mp4"],
38705
- ["HDR output (H.265 10-bit)", "hyperframes render --hdr --output hdr-output.mp4"]
39145
+ ["HDR output (auto-detected)", "hyperframes render --output hdr-output.mp4"]
38706
39146
  ];
38707
39147
  VALID_FPS = /* @__PURE__ */ new Set([24, 30, 60]);
38708
39148
  VALID_QUALITY = /* @__PURE__ */ new Set(["draft", "standard", "high"]);
@@ -38754,7 +39194,12 @@ var init_render2 = __esm({
38754
39194
  },
38755
39195
  hdr: {
38756
39196
  type: "boolean",
38757
- description: "Enable HDR: probe sources for PQ/HLG, output H.265 10-bit BT.2020",
39197
+ description: "Force HDR output even if no HDR sources are detected",
39198
+ default: false
39199
+ },
39200
+ sdr: {
39201
+ type: "boolean",
39202
+ description: "Force SDR output even if HDR sources are detected",
38758
39203
  default: false
38759
39204
  },
38760
39205
  crf: {
@@ -38860,9 +39305,8 @@ var init_render2 = __esm({
38860
39305
  );
38861
39306
  process.exit(1);
38862
39307
  }
38863
- const workerCount = workers ?? defaultWorkerCount();
38864
39308
  if (!quiet) {
38865
- const workerLabel = args.workers != null ? `${workerCount} workers` : `${workerCount} workers (auto \u2014 ${CPU_CORE_COUNT} cores detected)`;
39309
+ const workerLabel = workers != null ? `${workers} workers` : `auto workers (${CPU_CORE_COUNT} cores detected)`;
38866
39310
  console.log("");
38867
39311
  console.log(
38868
39312
  c.accent("\u25C6") + " Rendering " + c.accent(project.name) + c.dim(" \u2192 " + outputPath)
@@ -38930,14 +39374,18 @@ var init_render2 = __esm({
38930
39374
  console.log("");
38931
39375
  }
38932
39376
  }
39377
+ if (args.hdr && args.sdr) {
39378
+ console.error("Error: --hdr and --sdr are mutually exclusive.");
39379
+ process.exit(1);
39380
+ }
38933
39381
  if (useDocker) {
38934
39382
  await renderDocker(project.dir, outputPath, {
38935
39383
  fps,
38936
39384
  quality,
38937
39385
  format,
38938
- workers: workerCount,
39386
+ workers,
38939
39387
  gpu: useGpu,
38940
- hdr: args.hdr ?? false,
39388
+ hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto",
38941
39389
  crf,
38942
39390
  videoBitrate,
38943
39391
  quiet
@@ -38947,9 +39395,9 @@ var init_render2 = __esm({
38947
39395
  fps,
38948
39396
  quality,
38949
39397
  format,
38950
- workers: workerCount,
39398
+ workers,
38951
39399
  gpu: useGpu,
38952
- hdr: args.hdr ?? false,
39400
+ hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto",
38953
39401
  crf,
38954
39402
  videoBitrate,
38955
39403
  quiet,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hyperframes",
3
- "version": "0.4.32",
3
+ "version": "0.4.33",
4
4
  "description": "HyperFrames CLI — create, preview, and render HTML video compositions",
5
5
  "repository": {
6
6
  "type": "git",
@@ -55,7 +55,7 @@
55
55
  "vitest": "^3.2.4"
56
56
  },
57
57
  "optionalDependencies": {
58
- "@google/genai": "^1.50.0"
58
+ "@google/genai": "^1.50.1"
59
59
  },
60
60
  "engines": {
61
61
  "node": ">=22"