hyperframes 0.5.0-alpha.6 → 0.5.0-alpha.7

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.5.0-alpha.6" : "0.0.0-dev";
57
+ VERSION = true ? "0.5.0-alpha.7" : "0.0.0-dev";
58
58
  }
59
59
  });
60
60
 
@@ -9490,9 +9490,12 @@ function readCache(path2) {
9490
9490
  }
9491
9491
  }
9492
9492
  function writeCache(path2, data) {
9493
- mkdirSync(dirname3(path2), { recursive: true });
9494
- const entry = { fetchedAt: Date.now(), data };
9495
- writeFileSync(path2, JSON.stringify(entry), "utf-8");
9493
+ try {
9494
+ mkdirSync(dirname3(path2), { recursive: true });
9495
+ const entry = { fetchedAt: Date.now(), data };
9496
+ writeFileSync(path2, JSON.stringify(entry), "utf-8");
9497
+ } catch {
9498
+ }
9496
9499
  }
9497
9500
  async function fetchJson(url) {
9498
9501
  const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
@@ -29126,6 +29129,7 @@ function calculateOptimalWorkers(totalFrames, requested, config) {
29126
29129
  const effectiveCoresPerWorker = config?.coresPerWorker ?? DEFAULT_CONFIG2.coresPerWorker;
29127
29130
  const effectiveMinParallelFrames = config?.minParallelFrames ?? DEFAULT_CONFIG2.minParallelFrames;
29128
29131
  const effectiveLargeRenderThreshold = config?.largeRenderThreshold ?? DEFAULT_CONFIG2.largeRenderThreshold;
29132
+ const captureCostMultiplier = Math.max(1, config?.captureCostMultiplier ?? 1);
29129
29133
  if (requested !== void 0) {
29130
29134
  return Math.max(MIN_WORKERS, Math.min(effectiveMaxWorkers, requested));
29131
29135
  }
@@ -29138,8 +29142,14 @@ function calculateOptimalWorkers(totalFrames, requested, config) {
29138
29142
  const optimal = Math.min(cpuBasedWorkers, memoryBasedWorkers, frameBasedWorkers);
29139
29143
  const minWorkersForJob = totalFrames >= effectiveMinParallelFrames ? 2 : MIN_WORKERS;
29140
29144
  let finalWorkers = Math.max(minWorkersForJob, Math.min(effectiveMaxWorkers, optimal));
29141
- if (totalFrames >= effectiveLargeRenderThreshold) {
29142
- const cpuScaledMax = Math.max(2, Math.floor(cpuCount / effectiveCoresPerWorker));
29145
+ const weightedFrames = totalFrames * captureCostMultiplier;
29146
+ const contentionThreshold = Math.max(
29147
+ effectiveMinParallelFrames,
29148
+ Math.floor(effectiveLargeRenderThreshold / 3)
29149
+ );
29150
+ if (totalFrames >= effectiveLargeRenderThreshold || weightedFrames >= contentionThreshold) {
29151
+ const weightedCoresPerWorker = effectiveCoresPerWorker * captureCostMultiplier;
29152
+ const cpuScaledMax = Math.max(MIN_WORKERS, Math.floor(cpuCount / weightedCoresPerWorker));
29143
29153
  if (finalWorkers > cpuScaledMax) {
29144
29154
  finalWorkers = cpuScaledMax;
29145
29155
  }
@@ -32899,6 +32909,17 @@ function detectRenderModeHints(html) {
32899
32909
  reasons
32900
32910
  };
32901
32911
  }
32912
+ function detectShaderTransitionUsage(html) {
32913
+ let scriptMatch;
32914
+ const scriptPattern = new RegExp(INLINE_SCRIPT_PATTERN.source, INLINE_SCRIPT_PATTERN.flags);
32915
+ while ((scriptMatch = scriptPattern.exec(html)) !== null) {
32916
+ const attrs = scriptMatch[1] || "";
32917
+ if (/\bsrc\s*=/i.test(attrs)) continue;
32918
+ const content = stripJsComments(stripCompilerMountBootstrap(scriptMatch[2] || ""));
32919
+ if (SHADER_TRANSITION_USAGE_PATTERN.test(content)) return true;
32920
+ }
32921
+ return false;
32922
+ }
32902
32923
  async function resolveMediaDuration(src, mediaStart, baseDir, downloadDir, tagName19) {
32903
32924
  let filePath = src;
32904
32925
  if (isHttpUrl(src)) {
@@ -33435,6 +33456,7 @@ async function compileForRender(projectDir, htmlPath, downloadDir) {
33435
33456
  "$1"
33436
33457
  );
33437
33458
  const renderModeHints = detectRenderModeHints(sanitizedHtml);
33459
+ const hasShaderTransitions = detectShaderTransitionUsage(sanitizedHtml);
33438
33460
  const coalescedHtml = await injectDeterministicFontFaces(
33439
33461
  coalesceHeadStylesAndBodyScripts2(promoteCssImportsToLinkTags(sanitizedHtml))
33440
33462
  );
@@ -33482,7 +33504,8 @@ async function compileForRender(projectDir, htmlPath, downloadDir) {
33482
33504
  width,
33483
33505
  height,
33484
33506
  staticDuration,
33485
- renderModeHints
33507
+ renderModeHints,
33508
+ hasShaderTransitions
33486
33509
  };
33487
33510
  }
33488
33511
  async function discoverMediaFromBrowser(page) {
@@ -33584,10 +33607,11 @@ async function recompileWithResolutions(compiled, resolutions, projectDir, downl
33584
33607
  audios,
33585
33608
  images,
33586
33609
  unresolvedCompositions: remaining,
33587
- renderModeHints: compiled.renderModeHints
33610
+ renderModeHints: compiled.renderModeHints,
33611
+ hasShaderTransitions: compiled.hasShaderTransitions
33588
33612
  };
33589
33613
  }
33590
- var INLINE_SCRIPT_PATTERN, COMPILER_MOUNT_BLOCK_START, COMPILER_MOUNT_BLOCK_END;
33614
+ var INLINE_SCRIPT_PATTERN, COMPILER_MOUNT_BLOCK_START, COMPILER_MOUNT_BLOCK_END, SHADER_TRANSITION_USAGE_PATTERN;
33591
33615
  var init_htmlCompiler2 = __esm({
33592
33616
  "../producer/src/services/htmlCompiler.ts"() {
33593
33617
  "use strict";
@@ -33602,6 +33626,7 @@ var init_htmlCompiler2 = __esm({
33602
33626
  INLINE_SCRIPT_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
33603
33627
  COMPILER_MOUNT_BLOCK_START = "/* __HF_COMPILER_MOUNT_START__ */";
33604
33628
  COMPILER_MOUNT_BLOCK_END = "/* __HF_COMPILER_MOUNT_END__ */";
33629
+ SHADER_TRANSITION_USAGE_PATTERN = /\b(?:(?:window|globalThis)\s*\.\s*)?HyperShader\s*\.\s*init\s*\(|\b__hf\s*\.\s*transitions\s*=/;
33605
33630
  }
33606
33631
  });
33607
33632
 
@@ -33905,7 +33930,8 @@ function writeCompiledArtifacts(compiled, workDir, includeSummary) {
33905
33930
  mediaStart: a.mediaStart
33906
33931
  })),
33907
33932
  subCompositions: Array.from(compiled.subCompositions.keys()),
33908
- renderModeHints: compiled.renderModeHints
33933
+ renderModeHints: compiled.renderModeHints,
33934
+ hasShaderTransitions: compiled.hasShaderTransitions
33909
33935
  };
33910
33936
  writeFileSync14(join36(compileDir, "summary.json"), JSON.stringify(summary, null, 2), "utf-8");
33911
33937
  }
@@ -33918,6 +33944,287 @@ function applyRenderModeHints(cfg, compiled, log2 = defaultLogger) {
33918
33944
  reasons: compiled.renderModeHints.reasons.map((reason) => reason.message)
33919
33945
  });
33920
33946
  }
33947
+ function resolveRenderWorkerCount(totalFrames, requestedWorkers, cfg, compiled, composition, log2 = defaultLogger, measuredCaptureCost) {
33948
+ const captureCost = combineCaptureCostEstimates(
33949
+ estimateCaptureCostMultiplier(compiled, composition),
33950
+ measuredCaptureCost
33951
+ );
33952
+ const workerCount = calculateOptimalWorkers(totalFrames, requestedWorkers, {
33953
+ ...cfg,
33954
+ captureCostMultiplier: captureCost.multiplier
33955
+ });
33956
+ if (requestedWorkers !== void 0 || captureCost.multiplier <= 1) {
33957
+ return workerCount;
33958
+ }
33959
+ const baselineWorkers = calculateOptimalWorkers(totalFrames, void 0, cfg);
33960
+ if (workerCount < baselineWorkers) {
33961
+ log2.warn(
33962
+ "[Render] Reduced auto worker count for high-cost capture workload to avoid Chrome compositor starvation.",
33963
+ {
33964
+ from: baselineWorkers,
33965
+ to: workerCount,
33966
+ costMultiplier: captureCost.multiplier,
33967
+ reasons: captureCost.reasons
33968
+ }
33969
+ );
33970
+ }
33971
+ return workerCount;
33972
+ }
33973
+ function estimateCaptureCostMultiplier(compiled, composition) {
33974
+ let multiplier = 1;
33975
+ const reasons = [];
33976
+ if (compiled.hasShaderTransitions) {
33977
+ multiplier += 2;
33978
+ reasons.push("shader-transitions");
33979
+ }
33980
+ const reasonCodes = new Set(compiled.renderModeHints.reasons.map((reason) => reason.code));
33981
+ if (reasonCodes.has("requestAnimationFrame")) {
33982
+ multiplier += 1;
33983
+ reasons.push("requestAnimationFrame");
33984
+ }
33985
+ if (reasonCodes.has("iframe")) {
33986
+ multiplier += 0.5;
33987
+ reasons.push("iframe");
33988
+ }
33989
+ if (composition.videos.length > 0) {
33990
+ multiplier += Math.min(2, composition.videos.length * 0.75);
33991
+ reasons.push(`${composition.videos.length} video${composition.videos.length === 1 ? "" : "s"}`);
33992
+ }
33993
+ if (composition.audios.length > 0) {
33994
+ multiplier += Math.min(1, composition.audios.length * 0.75);
33995
+ reasons.push(`${composition.audios.length} audio${composition.audios.length === 1 ? "" : "s"}`);
33996
+ }
33997
+ return {
33998
+ multiplier: Math.round(multiplier * 100) / 100,
33999
+ reasons
34000
+ };
34001
+ }
34002
+ function combineCaptureCostEstimates(staticCost, measuredCost) {
34003
+ if (!measuredCost || measuredCost.multiplier <= 1) return staticCost;
34004
+ if (staticCost.multiplier >= measuredCost.multiplier) {
34005
+ return {
34006
+ multiplier: staticCost.multiplier,
34007
+ reasons: [...staticCost.reasons, ...measuredCost.reasons],
34008
+ p95Ms: measuredCost.p95Ms
34009
+ };
34010
+ }
34011
+ return {
34012
+ multiplier: measuredCost.multiplier,
34013
+ reasons: [...measuredCost.reasons, ...staticCost.reasons],
34014
+ p95Ms: measuredCost.p95Ms
34015
+ };
34016
+ }
34017
+ function createCaptureCalibrationConfig(cfg) {
34018
+ return {
34019
+ ...cfg,
34020
+ protocolTimeout: Math.min(cfg.protocolTimeout, CAPTURE_CALIBRATION_PROTOCOL_TIMEOUT_MS)
34021
+ };
34022
+ }
34023
+ function estimateMeasuredCaptureCostMultiplier(samples) {
34024
+ if (samples.length === 0) {
34025
+ return { multiplier: 1, reasons: [] };
34026
+ }
34027
+ const sorted = [...samples].sort((a, b) => a.captureTimeMs - b.captureTimeMs);
34028
+ const p95Index = Math.max(0, Math.ceil(sorted.length * 0.95) - 1);
34029
+ const p95Sample = sorted[p95Index] ?? sorted[sorted.length - 1];
34030
+ if (!p95Sample) {
34031
+ return { multiplier: 1, reasons: [] };
34032
+ }
34033
+ const p95Ms = Math.round(p95Sample.captureTimeMs);
34034
+ const multiplier = Math.min(
34035
+ MAX_MEASURED_CAPTURE_COST_MULTIPLIER,
34036
+ Math.max(1, Math.round(p95Ms / CAPTURE_CALIBRATION_TARGET_MS * 100) / 100)
34037
+ );
34038
+ return {
34039
+ multiplier,
34040
+ reasons: multiplier > 1 ? [`calibration-p95=${p95Ms}ms`] : [],
34041
+ p95Ms
34042
+ };
34043
+ }
34044
+ function selectCaptureCalibrationFrames(totalFrames) {
34045
+ if (totalFrames <= 0) return [];
34046
+ const lastFrame = totalFrames - 1;
34047
+ const candidates = [
34048
+ 0,
34049
+ Math.floor(totalFrames * 0.25),
34050
+ Math.floor(totalFrames * 0.5),
34051
+ Math.floor(totalFrames * 0.75),
34052
+ lastFrame
34053
+ ];
34054
+ return Array.from(
34055
+ new Set(candidates.map((frame) => Math.max(0, Math.min(lastFrame, frame))))
34056
+ ).sort((a, b) => a - b);
34057
+ }
34058
+ function findMissingFrameRanges(totalFrames, framesDir, frameExt) {
34059
+ const ranges = [];
34060
+ let rangeStart = null;
34061
+ for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
34062
+ const framePath = join36(framesDir, `frame_${String(frameIndex).padStart(6, "0")}.${frameExt}`);
34063
+ const missing = !existsSync34(framePath);
34064
+ if (missing && rangeStart === null) {
34065
+ rangeStart = frameIndex;
34066
+ } else if (!missing && rangeStart !== null) {
34067
+ ranges.push({ startFrame: rangeStart, endFrame: frameIndex });
34068
+ rangeStart = null;
34069
+ }
34070
+ }
34071
+ if (rangeStart !== null) {
34072
+ ranges.push({ startFrame: rangeStart, endFrame: totalFrames });
34073
+ }
34074
+ return ranges;
34075
+ }
34076
+ function buildMissingFrameRetryBatches(ranges, maxWorkers, workDir, attempt) {
34077
+ const workersPerBatch = Math.max(1, Math.floor(maxWorkers));
34078
+ const batches = [];
34079
+ for (let i2 = 0; i2 < ranges.length; i2 += workersPerBatch) {
34080
+ const batchIndex = batches.length;
34081
+ const batch = ranges.slice(i2, i2 + workersPerBatch).map((range, workerId) => ({
34082
+ workerId,
34083
+ startFrame: range.startFrame,
34084
+ endFrame: range.endFrame,
34085
+ outputDir: join36(workDir, `retry-${attempt}-batch-${batchIndex}-worker-${workerId}`)
34086
+ }));
34087
+ batches.push(batch);
34088
+ }
34089
+ return batches;
34090
+ }
34091
+ function getNextRetryWorkerCount(currentWorkers) {
34092
+ return Math.max(1, Math.floor(currentWorkers / 2));
34093
+ }
34094
+ function isRecoverableParallelCaptureError(error) {
34095
+ const message = error instanceof Error ? error.message : String(error);
34096
+ 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(
34097
+ message
34098
+ );
34099
+ }
34100
+ function shouldFallbackToScreenshotAfterCalibrationError(error) {
34101
+ const message = error instanceof Error ? error.message : String(error);
34102
+ return /HeadlessExperimental\.beginFrame timed out|beginFrame probe timeout|Another frame is pending|Frame still pending|Protocol error.*HeadlessExperimental\.beginFrame/i.test(
34103
+ message
34104
+ );
34105
+ }
34106
+ function countCapturedFrames(totalFrames, framesDir, frameExt) {
34107
+ let captured = 0;
34108
+ for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) {
34109
+ const framePath = join36(framesDir, `frame_${String(frameIndex).padStart(6, "0")}.${frameExt}`);
34110
+ if (existsSync34(framePath)) captured++;
34111
+ }
34112
+ return captured;
34113
+ }
34114
+ function countFrameRanges(ranges) {
34115
+ return ranges.reduce((sum, range) => sum + (range.endFrame - range.startFrame), 0);
34116
+ }
34117
+ async function measureCaptureCostFromSession(session, totalFrames, fps) {
34118
+ const sampledFrames = selectCaptureCalibrationFrames(totalFrames);
34119
+ const samples = [];
34120
+ for (const frameIndex of sampledFrames) {
34121
+ const time = frameIndex / fps;
34122
+ const startedAt = Date.now();
34123
+ const result = await captureFrameToBuffer(session, frameIndex, time);
34124
+ samples.push({
34125
+ frameIndex,
34126
+ captureTimeMs: result.captureTimeMs || Date.now() - startedAt
34127
+ });
34128
+ }
34129
+ return {
34130
+ estimate: estimateMeasuredCaptureCostMultiplier(samples),
34131
+ samples
34132
+ };
34133
+ }
34134
+ async function executeDiskCaptureWithAdaptiveRetry(options) {
34135
+ const attempts = [];
34136
+ let currentWorkers = options.initialWorkerCount;
34137
+ let missingRanges = null;
34138
+ let attempt = 0;
34139
+ while (true) {
34140
+ const frameCount = missingRanges ? countFrameRanges(missingRanges) : options.totalFrames;
34141
+ attempts.push({
34142
+ attempt,
34143
+ workers: currentWorkers,
34144
+ frameCount,
34145
+ reason: attempt === 0 ? "initial" : "retry"
34146
+ });
34147
+ const attemptWorkDir = join36(options.workDir, `capture-attempt-${attempt}`);
34148
+ const batches = missingRanges ? buildMissingFrameRetryBatches(missingRanges, currentWorkers, attemptWorkDir, attempt) : [distributeFrames(options.totalFrames, currentWorkers, attemptWorkDir)];
34149
+ try {
34150
+ for (const tasks of batches) {
34151
+ const capturedBeforeBatch = countCapturedFrames(
34152
+ options.totalFrames,
34153
+ options.framesDir,
34154
+ options.frameExt
34155
+ );
34156
+ try {
34157
+ await executeParallelCapture(
34158
+ options.serverUrl,
34159
+ attemptWorkDir,
34160
+ tasks,
34161
+ options.captureOptions,
34162
+ options.createBeforeCaptureHook,
34163
+ options.abortSignal,
34164
+ options.onProgress ? (progress) => {
34165
+ options.onProgress?.({
34166
+ ...progress,
34167
+ totalFrames: options.totalFrames,
34168
+ capturedFrames: Math.min(
34169
+ options.totalFrames,
34170
+ capturedBeforeBatch + progress.capturedFrames
34171
+ )
34172
+ });
34173
+ } : void 0,
34174
+ void 0,
34175
+ options.cfg
34176
+ );
34177
+ } finally {
34178
+ await mergeWorkerFrames(attemptWorkDir, tasks, options.framesDir);
34179
+ }
34180
+ }
34181
+ const remaining = findMissingFrameRanges(
34182
+ options.totalFrames,
34183
+ options.framesDir,
34184
+ options.frameExt
34185
+ );
34186
+ if (remaining.length === 0) {
34187
+ return attempts;
34188
+ }
34189
+ if (!options.allowRetry || currentWorkers <= 1) {
34190
+ throw new Error(
34191
+ `[Render] Capture completed but ${countFrameRanges(remaining)} frame(s) are missing`
34192
+ );
34193
+ }
34194
+ const nextWorkers = getNextRetryWorkerCount(currentWorkers);
34195
+ options.log.warn("[Render] Retrying missing captured frames with fewer workers.", {
34196
+ fromWorkers: currentWorkers,
34197
+ toWorkers: nextWorkers,
34198
+ missingFrames: countFrameRanges(remaining)
34199
+ });
34200
+ currentWorkers = nextWorkers;
34201
+ missingRanges = remaining;
34202
+ attempt++;
34203
+ } catch (error) {
34204
+ const remaining = findMissingFrameRanges(
34205
+ options.totalFrames,
34206
+ options.framesDir,
34207
+ options.frameExt
34208
+ );
34209
+ if (remaining.length === 0) {
34210
+ return attempts;
34211
+ }
34212
+ if (!options.allowRetry || currentWorkers <= 1 || !isRecoverableParallelCaptureError(error)) {
34213
+ throw error;
34214
+ }
34215
+ const nextWorkers = getNextRetryWorkerCount(currentWorkers);
34216
+ options.log.warn("[Render] Parallel capture timed out; retrying missing frames.", {
34217
+ fromWorkers: currentWorkers,
34218
+ toWorkers: nextWorkers,
34219
+ missingFrames: countFrameRanges(remaining),
34220
+ error: error instanceof Error ? error.message : String(error)
34221
+ });
34222
+ currentWorkers = nextWorkers;
34223
+ missingRanges = remaining;
34224
+ attempt++;
34225
+ }
34226
+ }
34227
+ }
33921
34228
  function blitHdrVideoLayer(canvas, el, time, fps, hdrFrameDirs, hdrStartTimes, width, height, log2, sourceTransfer, targetTransfer) {
33922
34229
  const frameDir = hdrFrameDirs.get(el.id);
33923
34230
  const startTime = hdrStartTimes.get(el.id);
@@ -34544,7 +34851,7 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
34544
34851
  let extractionResult = null;
34545
34852
  const nativeHdrVideoIds = /* @__PURE__ */ new Set();
34546
34853
  const videoTransfers = /* @__PURE__ */ new Map();
34547
- if (job.config.hdr && composition.videos.length > 0) {
34854
+ if (job.config.hdrMode !== "force-sdr" && composition.videos.length > 0) {
34548
34855
  await Promise.all(
34549
34856
  composition.videos.map(async (v) => {
34550
34857
  let videoPath = v.src;
@@ -34565,7 +34872,7 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
34565
34872
  const imageTransfers = /* @__PURE__ */ new Map();
34566
34873
  const hdrImageSrcPaths = /* @__PURE__ */ new Map();
34567
34874
  const imageColorSpaces = [];
34568
- if (job.config.hdr && composition.images.length > 0) {
34875
+ if (job.config.hdrMode !== "force-sdr" && composition.images.length > 0) {
34569
34876
  const probed = await Promise.all(
34570
34877
  composition.images.map(async (img) => {
34571
34878
  let imgPath = img.src;
@@ -34622,28 +34929,53 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
34622
34929
  perfStages.videoExtractMs = Date.now() - stage2Start;
34623
34930
  }
34624
34931
  let effectiveHdr;
34625
- if (job.config.hdr) {
34932
+ let forcedHdrWithoutSources = false;
34933
+ {
34934
+ const hdrMode = job.config.hdrMode ?? "auto";
34626
34935
  const videoColorSpaces = (extractionResult?.extracted ?? []).map(
34627
34936
  (ext) => ext.metadata.colorSpace
34628
34937
  );
34629
34938
  const allColorSpaces = [...videoColorSpaces, ...imageColorSpaces];
34630
- if (allColorSpaces.length > 0) {
34631
- const info = analyzeCompositionHdr(allColorSpaces);
34632
- if (info.hasHdr && info.dominantTransfer) {
34939
+ const info = allColorSpaces.length > 0 ? analyzeCompositionHdr(allColorSpaces) : null;
34940
+ if (hdrMode === "force-sdr") {
34941
+ effectiveHdr = void 0;
34942
+ } else if (hdrMode === "force-hdr") {
34943
+ if (info?.hasHdr && info.dominantTransfer) {
34944
+ effectiveHdr = { transfer: info.dominantTransfer };
34945
+ } else {
34946
+ effectiveHdr = { transfer: "hlg" };
34947
+ forcedHdrWithoutSources = true;
34948
+ }
34949
+ } else {
34950
+ if (info?.hasHdr && info.dominantTransfer) {
34633
34951
  effectiveHdr = { transfer: info.dominantTransfer };
34634
34952
  }
34635
34953
  }
34636
34954
  }
34637
34955
  if (effectiveHdr && outputFormat !== "mp4") {
34956
+ const hdrSourceReason = forcedHdrWithoutSources ? "HDR was forced without detected HDR sources" : "HDR source detected";
34638
34957
  log2.warn(
34639
- `[Render] HDR source detected but format is "${outputFormat}" \u2014 falling back to SDR. HDR + alpha is not supported. Use --format mp4 for HDR10 output.`
34958
+ `[Render] ${hdrSourceReason}, but format is "${outputFormat}" \u2014 falling back to SDR. HDR + alpha is not supported. Use --format mp4 for HDR10 output.`
34640
34959
  );
34641
34960
  effectiveHdr = void 0;
34642
34961
  }
34643
- if (effectiveHdr) {
34644
- log2.info(
34645
- `[Render] HDR source detected \u2014 output: ${effectiveHdr.transfer.toUpperCase()} (BT.2020, 10-bit H.265)`
34646
- );
34962
+ {
34963
+ const hdrMode = job.config.hdrMode ?? "auto";
34964
+ if (forcedHdrWithoutSources) {
34965
+ log2.warn(
34966
+ "[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."
34967
+ );
34968
+ }
34969
+ if (effectiveHdr) {
34970
+ 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)";
34971
+ log2.info(
34972
+ `[Render] HDR ${reason} \u2014 output: ${effectiveHdr.transfer.toUpperCase()} (BT.2020, 10-bit H.265)`
34973
+ );
34974
+ } else if (hdrMode === "force-sdr") {
34975
+ log2.info("[Render] SDR forced by --sdr flag");
34976
+ } else {
34977
+ log2.info("[Render] No HDR sources detected \u2014 rendering SDR");
34978
+ }
34647
34979
  }
34648
34980
  const stage3Start = Date.now();
34649
34981
  updateJobStatus(job, "preprocessing", "Processing audio tracks", 20, onProgress);
@@ -34690,7 +35022,101 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
34690
35022
  ...captureOptions,
34691
35023
  skipReadinessVideoIds: Array.from(nativeHdrVideoIds)
34692
35024
  });
34693
- const workerCount = calculateOptimalWorkers(totalFrames, job.config.workers, cfg);
35025
+ let captureCalibration;
35026
+ let switchedToScreenshotAfterCalibration = false;
35027
+ if (job.config.workers === void 0 && totalFrames >= 60) {
35028
+ const calibrationDir = join36(workDir, "capture-calibration");
35029
+ const calibrationCfg = createCaptureCalibrationConfig(cfg);
35030
+ const videoInjector = createVideoFrameInjector(frameLookup);
35031
+ let calibrationSession = null;
35032
+ try {
35033
+ calibrationSession = await createCaptureSession(
35034
+ fileServer.url,
35035
+ calibrationDir,
35036
+ buildHdrCaptureOptions(),
35037
+ videoInjector,
35038
+ calibrationCfg
35039
+ );
35040
+ if (!calibrationSession.isInitialized) {
35041
+ await initializeSession(calibrationSession);
35042
+ }
35043
+ assertNotAborted();
35044
+ captureCalibration = await measureCaptureCostFromSession(
35045
+ calibrationSession,
35046
+ totalFrames,
35047
+ job.config.fps
35048
+ );
35049
+ if (captureCalibration.estimate.multiplier > 1) {
35050
+ log2.warn("[Render] Measured slow frame capture during auto-worker calibration.", {
35051
+ multiplier: captureCalibration.estimate.multiplier,
35052
+ p95Ms: captureCalibration.estimate.p95Ms,
35053
+ sampledFrames: captureCalibration.samples.map((sample) => sample.frameIndex)
35054
+ });
35055
+ } else {
35056
+ log2.debug("[Render] Auto-worker calibration kept baseline capture cost.", {
35057
+ p95Ms: captureCalibration.estimate.p95Ms,
35058
+ sampledFrames: captureCalibration.samples.map((sample) => sample.frameIndex)
35059
+ });
35060
+ }
35061
+ } catch (error) {
35062
+ const shouldFallbackToScreenshot = !cfg.forceScreenshot && shouldFallbackToScreenshotAfterCalibrationError(error);
35063
+ if (shouldFallbackToScreenshot) {
35064
+ cfg.forceScreenshot = true;
35065
+ switchedToScreenshotAfterCalibration = true;
35066
+ if (probeSession) {
35067
+ lastBrowserConsole = probeSession.browserConsoleBuffer;
35068
+ await closeCaptureSession(probeSession).catch(() => {
35069
+ });
35070
+ probeSession = null;
35071
+ }
35072
+ }
35073
+ captureCalibration = {
35074
+ estimate: {
35075
+ multiplier: MAX_MEASURED_CAPTURE_COST_MULTIPLIER,
35076
+ reasons: shouldFallbackToScreenshot ? ["calibration-beginframe-timeout", "screenshot-fallback"] : ["calibration-failed"]
35077
+ },
35078
+ samples: []
35079
+ };
35080
+ if (shouldFallbackToScreenshot) {
35081
+ log2.warn(
35082
+ "[Render] BeginFrame auto-worker calibration timed out; falling back to screenshot capture mode.",
35083
+ {
35084
+ protocolTimeout: calibrationCfg.protocolTimeout,
35085
+ error: error instanceof Error ? error.message : String(error)
35086
+ }
35087
+ );
35088
+ } else {
35089
+ log2.warn("[Render] Auto-worker calibration failed; using conservative worker budget.", {
35090
+ protocolTimeout: calibrationCfg.protocolTimeout,
35091
+ error: error instanceof Error ? error.message : String(error)
35092
+ });
35093
+ }
35094
+ } finally {
35095
+ if (calibrationSession) {
35096
+ lastBrowserConsole = calibrationSession.browserConsoleBuffer;
35097
+ await closeCaptureSession(calibrationSession).catch(() => {
35098
+ });
35099
+ }
35100
+ }
35101
+ }
35102
+ let workerCount = resolveRenderWorkerCount(
35103
+ totalFrames,
35104
+ job.config.workers,
35105
+ cfg,
35106
+ compiled,
35107
+ composition,
35108
+ log2,
35109
+ captureCalibration?.estimate
35110
+ );
35111
+ if (switchedToScreenshotAfterCalibration && workerCount > 1) {
35112
+ workerCount = 1;
35113
+ }
35114
+ if (workerCount > 1 && probeSession) {
35115
+ lastBrowserConsole = probeSession.browserConsoleBuffer;
35116
+ await closeCaptureSession(probeSession);
35117
+ probeSession = null;
35118
+ }
35119
+ const captureAttempts = [];
34694
35120
  const FORMAT_EXT2 = {
34695
35121
  mp4: ".mp4",
34696
35122
  webm: ".webm",
@@ -35282,15 +35708,18 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
35282
35708
  perfStages.encodeMs = encodeResult.durationMs;
35283
35709
  } else {
35284
35710
  if (workerCount > 1) {
35285
- const tasks = distributeFrames(job.totalFrames, workerCount, workDir);
35286
- await executeParallelCapture(
35287
- fileServer.url,
35711
+ const attempts = await executeDiskCaptureWithAdaptiveRetry({
35712
+ serverUrl: fileServer.url,
35288
35713
  workDir,
35289
- tasks,
35290
- buildHdrCaptureOptions(),
35291
- () => createVideoFrameInjector(frameLookup),
35714
+ framesDir,
35715
+ totalFrames: job.totalFrames,
35716
+ initialWorkerCount: workerCount,
35717
+ allowRetry: job.config.workers === void 0,
35718
+ frameExt: needsAlpha ? "png" : "jpg",
35719
+ captureOptions: buildHdrCaptureOptions(),
35720
+ createBeforeCaptureHook: () => createVideoFrameInjector(frameLookup),
35292
35721
  abortSignal,
35293
- (progress) => {
35722
+ onProgress: (progress) => {
35294
35723
  job.framesRendered = progress.capturedFrames;
35295
35724
  const frameProgress = progress.capturedFrames / progress.totalFrames;
35296
35725
  const progressPct = 25 + frameProgress * 45;
@@ -35298,16 +35727,20 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
35298
35727
  updateJobStatus(
35299
35728
  job,
35300
35729
  "rendering",
35301
- `Capturing frame ${progress.capturedFrames}/${progress.totalFrames} (${workerCount} workers)`,
35730
+ `Capturing frame ${progress.capturedFrames}/${progress.totalFrames} (${progress.activeWorkers} workers)`,
35302
35731
  Math.round(progressPct),
35303
35732
  onProgress
35304
35733
  );
35305
35734
  }
35306
35735
  },
35307
- void 0,
35308
- cfg
35309
- );
35310
- await mergeWorkerFrames(workDir, tasks, framesDir);
35736
+ cfg,
35737
+ log: log2
35738
+ });
35739
+ captureAttempts.push(...attempts);
35740
+ const lastAttempt = attempts[attempts.length - 1];
35741
+ if (lastAttempt) {
35742
+ workerCount = lastAttempt.workers;
35743
+ }
35311
35744
  if (probeSession) {
35312
35745
  lastBrowserConsole = probeSession.browserConsoleBuffer;
35313
35746
  await closeCaptureSession(probeSession);
@@ -35477,6 +35910,13 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
35477
35910
  stages: perfStages,
35478
35911
  videoExtractBreakdown: extractionResult?.phaseBreakdown,
35479
35912
  tmpPeakBytes,
35913
+ captureCalibration: captureCalibration ? {
35914
+ sampledFrames: captureCalibration.samples.map((sample) => sample.frameIndex),
35915
+ p95Ms: captureCalibration.estimate.p95Ms,
35916
+ multiplier: captureCalibration.estimate.multiplier,
35917
+ reasons: captureCalibration.estimate.reasons
35918
+ } : void 0,
35919
+ captureAttempts: captureAttempts.length > 0 ? captureAttempts : void 0,
35480
35920
  hdrDiagnostics: hdrDiagnostics.videoExtractionFailures > 0 || hdrDiagnostics.imageDecodeFailures > 0 ? { ...hdrDiagnostics } : void 0,
35481
35921
  captureAvgMs: totalFrames > 0 ? Math.round((perfStages.captureMs ?? 0) / totalFrames) : void 0,
35482
35922
  peakRssMb: Math.round(peakRssBytes / (1024 * 1024)),
@@ -35592,7 +36032,7 @@ async function executeRenderJob(job, projectDir, outputPath, onProgress, abortSi
35592
36032
  clearInterval(memSamplerInterval);
35593
36033
  }
35594
36034
  }
35595
- var RenderCancelledError, BROWSER_MEDIA_EPSILON;
36035
+ var RenderCancelledError, BROWSER_MEDIA_EPSILON, CAPTURE_CALIBRATION_TARGET_MS, MAX_MEASURED_CAPTURE_COST_MULTIPLIER, CAPTURE_CALIBRATION_PROTOCOL_TIMEOUT_MS;
35596
36036
  var init_renderOrchestrator = __esm({
35597
36037
  "../producer/src/services/renderOrchestrator.ts"() {
35598
36038
  "use strict";
@@ -35613,6 +36053,9 @@ var init_renderOrchestrator = __esm({
35613
36053
  }
35614
36054
  };
35615
36055
  BROWSER_MEDIA_EPSILON = 1e-4;
36056
+ CAPTURE_CALIBRATION_TARGET_MS = 600;
36057
+ MAX_MEASURED_CAPTURE_COST_MULTIPLIER = 8;
36058
+ CAPTURE_CALIBRATION_PROTOCOL_TIMEOUT_MS = 3e4;
35616
36059
  }
35617
36060
  });
35618
36061
 
@@ -38645,13 +39088,13 @@ function buildDockerRunArgs(input) {
38645
39088
  options.quality,
38646
39089
  "--format",
38647
39090
  options.format,
38648
- "--workers",
38649
- String(options.workers),
39091
+ ...options.workers != null ? ["--workers", String(options.workers)] : [],
38650
39092
  ...options.crf != null ? ["--crf", String(options.crf)] : [],
38651
39093
  ...options.videoBitrate ? ["--video-bitrate", options.videoBitrate] : [],
38652
39094
  ...options.quiet ? ["--quiet"] : [],
38653
39095
  ...options.gpu ? ["--gpu"] : [],
38654
- ...options.hdr ? ["--hdr"] : []
39096
+ ...options.hdrMode === "force-hdr" ? ["--hdr"] : [],
39097
+ ...options.hdrMode === "force-sdr" ? ["--sdr"] : []
38655
39098
  ];
38656
39099
  }
38657
39100
  var init_dockerRunArgs = __esm({
@@ -38707,9 +39150,6 @@ import { mkdirSync as mkdirSync24, readFileSync as readFileSync29, statSync as s
38707
39150
  import { cpus as cpus3, freemem as freemem3, tmpdir as tmpdir4 } from "os";
38708
39151
  import { resolve as resolve28, dirname as dirname16, join as join44, basename as basename9 } from "path";
38709
39152
  import { execFileSync as execFileSync6, spawn as spawn11 } from "child_process";
38710
- function defaultWorkerCount() {
38711
- return Math.max(1, Math.min(Math.floor(CPU_CORE_COUNT * 3 / 4), 8));
38712
- }
38713
39153
  function dockerImageTag(version) {
38714
39154
  return `${DOCKER_IMAGE_PREFIX}:${version}`;
38715
39155
  }
@@ -38801,7 +39241,7 @@ async function renderDocker(projectDir, outputPath, options) {
38801
39241
  format: options.format,
38802
39242
  workers: options.workers,
38803
39243
  gpu: options.gpu,
38804
- hdr: options.hdr,
39244
+ hdrMode: options.hdrMode,
38805
39245
  crf: options.crf,
38806
39246
  videoBitrate: options.videoBitrate,
38807
39247
  quiet: options.quiet
@@ -38850,7 +39290,7 @@ async function renderLocal(projectDir, outputPath, options) {
38850
39290
  format: options.format,
38851
39291
  workers: options.workers,
38852
39292
  useGpu: options.gpu,
38853
- hdr: options.hdr,
39293
+ hdrMode: options.hdrMode,
38854
39294
  crf: options.crf,
38855
39295
  videoBitrate: options.videoBitrate
38856
39296
  });
@@ -38897,7 +39337,7 @@ function trackRenderMetrics(job, elapsedMs, options, docker) {
38897
39337
  durationMs: elapsedMs,
38898
39338
  fps: options.fps,
38899
39339
  quality: options.quality,
38900
- workers: options.workers,
39340
+ workers: options.workers ?? perf?.workers,
38901
39341
  docker,
38902
39342
  gpu: options.gpu,
38903
39343
  compositionDurationMs,
@@ -38963,7 +39403,7 @@ var init_render2 = __esm({
38963
39403
  ["High quality at 60fps", "hyperframes render --fps 60 --quality high --output hd.mp4"],
38964
39404
  ["Deterministic render via Docker", "hyperframes render --docker --output deterministic.mp4"],
38965
39405
  ["Parallel rendering with 6 workers", "hyperframes render --workers 6 --output fast.mp4"],
38966
- ["HDR output (H.265 10-bit)", "hyperframes render --hdr --output hdr-output.mp4"]
39406
+ ["HDR output (auto-detected)", "hyperframes render --output hdr-output.mp4"]
38967
39407
  ];
38968
39408
  VALID_FPS = /* @__PURE__ */ new Set([24, 30, 60]);
38969
39409
  VALID_QUALITY = /* @__PURE__ */ new Set(["draft", "standard", "high"]);
@@ -39015,7 +39455,12 @@ var init_render2 = __esm({
39015
39455
  },
39016
39456
  hdr: {
39017
39457
  type: "boolean",
39018
- description: "Enable HDR: probe sources for PQ/HLG, output H.265 10-bit BT.2020",
39458
+ description: "Force HDR output even if no HDR sources are detected",
39459
+ default: false
39460
+ },
39461
+ sdr: {
39462
+ type: "boolean",
39463
+ description: "Force SDR output even if HDR sources are detected",
39019
39464
  default: false
39020
39465
  },
39021
39466
  crf: {
@@ -39121,9 +39566,8 @@ var init_render2 = __esm({
39121
39566
  );
39122
39567
  process.exit(1);
39123
39568
  }
39124
- const workerCount = workers ?? defaultWorkerCount();
39125
39569
  if (!quiet) {
39126
- const workerLabel = args.workers != null ? `${workerCount} workers` : `${workerCount} workers (auto \u2014 ${CPU_CORE_COUNT} cores detected)`;
39570
+ const workerLabel = workers != null ? `${workers} workers` : `auto workers (${CPU_CORE_COUNT} cores detected)`;
39127
39571
  console.log("");
39128
39572
  console.log(
39129
39573
  c.accent("\u25C6") + " Rendering " + c.accent(project.name) + c.dim(" \u2192 " + outputPath)
@@ -39191,14 +39635,18 @@ var init_render2 = __esm({
39191
39635
  console.log("");
39192
39636
  }
39193
39637
  }
39638
+ if (args.hdr && args.sdr) {
39639
+ console.error("Error: --hdr and --sdr are mutually exclusive.");
39640
+ process.exit(1);
39641
+ }
39194
39642
  if (useDocker) {
39195
39643
  await renderDocker(project.dir, outputPath, {
39196
39644
  fps,
39197
39645
  quality,
39198
39646
  format,
39199
- workers: workerCount,
39647
+ workers,
39200
39648
  gpu: useGpu,
39201
- hdr: args.hdr ?? false,
39649
+ hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto",
39202
39650
  crf,
39203
39651
  videoBitrate,
39204
39652
  quiet
@@ -39208,9 +39656,9 @@ var init_render2 = __esm({
39208
39656
  fps,
39209
39657
  quality,
39210
39658
  format,
39211
- workers: workerCount,
39659
+ workers,
39212
39660
  gpu: useGpu,
39213
- hdr: args.hdr ?? false,
39661
+ hdrMode: args.sdr ? "force-sdr" : args.hdr ? "force-hdr" : "auto",
39214
39662
  crf,
39215
39663
  videoBitrate,
39216
39664
  quiet,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hyperframes",
3
- "version": "0.5.0-alpha.6",
3
+ "version": "0.5.0-alpha.7",
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"