@hyperframes/studio-server 0.8.208 → 0.8.209

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.
@@ -1,8 +1,11 @@
1
1
  import {
2
2
  PROXY_VARIANT_CONFIG,
3
+ ffmpegHasHdrFilters
4
+ } from "./chunk-FUTDRYQK.js";
5
+ import {
3
6
  probeFirstFrameColour,
4
7
  probeMediaMetadata
5
- } from "./chunk-JKPLDWAB.js";
8
+ } from "./chunk-X73QTST2.js";
6
9
  import {
7
10
  mkdirWithinProject,
8
11
  realProjectRoot,
@@ -479,29 +482,8 @@ function markCacheEntryUsed(cachePath) {
479
482
  }
480
483
  }
481
484
  var failedTranscodes = /* @__PURE__ */ new Map();
482
- var hdrFilterCheck;
483
- function ensureHdrFilters(ffmpegPath) {
484
- if (hdrFilterCheck?.ffmpegPath === ffmpegPath) return hdrFilterCheck.promise;
485
- const promise = new Promise((resolveCheck, rejectCheck) => {
486
- const proc = spawn(ffmpegPath, ["-hide_banner", "-filters"], {
487
- stdio: ["ignore", "pipe", "pipe"],
488
- windowsHide: true
489
- });
490
- let stdout = "";
491
- proc.stdout?.on("data", (chunk) => {
492
- stdout += chunk.toString();
493
- });
494
- proc.on("error", () => rejectCheck(new FfmpegMissingFilterError()));
495
- proc.on("close", (code) => {
496
- if (code !== 0 || !/\bzscale\b/.test(stdout) || !/\btonemap\b/.test(stdout)) {
497
- rejectCheck(new FfmpegMissingFilterError());
498
- } else {
499
- resolveCheck();
500
- }
501
- });
502
- });
503
- hdrFilterCheck = { ffmpegPath, promise };
504
- return promise;
485
+ async function ensureHdrFilters(ffmpegPath) {
486
+ if (!await ffmpegHasHdrFilters(ffmpegPath)) throw new FfmpegMissingFilterError();
505
487
  }
506
488
  function rememberFailure(cachePath, error) {
507
489
  const ttlMs = error instanceof FfmpegUnavailableError || error instanceof FfmpegMissingFilterError ? ENVIRONMENT_FAILURE_TTL_MS : FAILURE_CACHE_TTL_MS;
@@ -761,6 +743,10 @@ async function runFfmpeg(sourcePath, outputPath, chunkPrefix, variant, box, sign
761
743
  const videoFilter = await buildVideoFilter(ffmpegPath, sourcePath, metadata, keepsAlpha, box);
762
744
  const alphaDecoder = keepsAlpha ? alphaDecoderFor(metadata.color.codecName) : null;
763
745
  const decoderArgs = alphaDecoder ? ["-c:v", alphaDecoder] : [];
746
+ const streamArgs = [
747
+ ...["-map", "0:V:0"],
748
+ ...metadata.audioStream === void 0 ? [] : ["-map", `0:a:${metadata.audioStream}`]
749
+ ];
764
750
  const totalSeconds = metadata.durationSeconds;
765
751
  const gate = keepsAlpha ? ALPHA_PIECE_GATE : UNGATED;
766
752
  const vp8Threads = (pieces) => [
@@ -778,6 +764,7 @@ async function runFfmpeg(sourcePath, outputPath, chunkPrefix, variant, box, sign
778
764
  ...decoderArgs,
779
765
  "-i",
780
766
  sourcePath,
767
+ ...streamArgs,
781
768
  ...range ? ["-t", String(range.lengthSeconds)] : [],
782
769
  "-vf",
783
770
  videoFilter,
@@ -204,14 +204,15 @@ function claim(file) {
204
204
  const draft = `${file}-${process.pid}-${randomUUID2()}.tmp`;
205
205
  writeFileSync(draft, String(process.pid));
206
206
  try {
207
- linkSync(draft, file);
208
- recordStart(file);
209
- return true;
210
- } catch (error) {
211
- const code = error.code ?? "";
212
- if (code === "EEXIST") return false;
213
- if (!NO_HARD_LINKS.has(code)) throw error;
214
- return createExclusively(file);
207
+ try {
208
+ linkSync(draft, file);
209
+ } catch (error) {
210
+ const code = error.code ?? "";
211
+ if (code === "EEXIST") return false;
212
+ if (!NO_HARD_LINKS.has(code)) throw error;
213
+ return createExclusively(file);
214
+ }
215
+ return recordOwnership(file);
215
216
  } finally {
216
217
  rmSync(draft, { force: true });
217
218
  }
@@ -219,12 +220,20 @@ function claim(file) {
219
220
  function createExclusively(file) {
220
221
  try {
221
222
  writeFileSync(file, String(process.pid), { flag: "wx" });
222
- recordStart(file);
223
- return true;
224
223
  } catch (error) {
225
224
  if (error.code !== "EEXIST") throw error;
226
225
  return false;
227
226
  }
227
+ return recordOwnership(file);
228
+ }
229
+ function recordOwnership(file) {
230
+ try {
231
+ recordStart(file);
232
+ return true;
233
+ } catch (error) {
234
+ releaseOwn(file);
235
+ throw error;
236
+ }
228
237
  }
229
238
  function releaseOwn(file) {
230
239
  if (ownerOf(file) !== process.pid) return;
@@ -524,11 +533,12 @@ function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set(
524
533
 
525
534
  // src/history/blobStore.ts
526
535
  import { createHash as createHash3, randomUUID as randomUUID3 } from "crypto";
527
- import { constants, createReadStream, createWriteStream, renameSync } from "fs";
528
- import { copyFile, mkdir, readdir, readFile, rename, rm, stat } from "fs/promises";
536
+ import { constants, createReadStream, createWriteStream } from "fs";
537
+ import { copyFile, mkdir, readdir, readFile, rm, stat } from "fs/promises";
529
538
  import { dirname, join as join2 } from "path";
530
539
  import { pipeline } from "stream/promises";
531
540
  import { atomicTempPath } from "@hyperframes/core/atomic-file";
541
+ import { renameWithRetry, renameWithRetryAsync } from "@hyperframes/core/rename-retry";
532
542
  var BLOB_HASH = /^[0-9a-f]{64}$/;
533
543
  function blobPath(dir, hash) {
534
544
  if (!BLOB_HASH.test(hash)) throw new Error("That is not a history blob.");
@@ -554,7 +564,7 @@ async function cloneOrCopy(from, to, beforeReplace) {
554
564
  try {
555
565
  await copyFile(from, temp, constants.COPYFILE_FICLONE);
556
566
  beforeReplace?.();
557
- renameSync(temp, to);
567
+ renameWithRetry(temp, to);
558
568
  } catch (error) {
559
569
  await rm(temp, { force: true });
560
570
  throw error;
@@ -610,7 +620,7 @@ async function openBlobStore(dir) {
610
620
  }
611
621
  await mkdir(dirname(blobPath(dir, hash)), { recursive: true });
612
622
  const size = (await stat(temp)).size;
613
- await rename(temp, blobPath(dir, hash));
623
+ await renameWithRetryAsync(temp, blobPath(dir, hash));
614
624
  sizes.set(hash, size);
615
625
  total += size;
616
626
  return hash;
@@ -634,7 +644,7 @@ async function openBlobStore(dir) {
634
644
  }
635
645
  const to = blobPath(asideDir, hash);
636
646
  await mkdir(dirname(to), { recursive: true });
637
- const moved = remove && await rename(absPath, to).then(
647
+ const moved = remove && await renameWithRetryAsync(absPath, to).then(
638
648
  () => true,
639
649
  () => false
640
650
  );
@@ -644,7 +654,7 @@ async function openBlobStore(dir) {
644
654
  await rm(temp, { force: true });
645
655
  throw error;
646
656
  });
647
- await rename(temp, to);
657
+ await renameWithRetryAsync(temp, to);
648
658
  }
649
659
  asideSizes.set(hash, (await stat(to)).size);
650
660
  holds.set(hash, /* @__PURE__ */ new Set([path]));
@@ -699,7 +709,7 @@ function uncopiedOnlyHashes(log, sizeOf) {
699
709
 
700
710
  // src/history/pruneHistories.ts
701
711
  import { randomUUID as randomUUID5 } from "crypto";
702
- import { readdirSync as readdirSync2, readFileSync as readFileSync5, renameSync as renameSync2, statSync as statSync3, writeFileSync as writeFileSync3 } from "fs";
712
+ import { readdirSync as readdirSync2, readFileSync as readFileSync5, renameSync, statSync as statSync3, writeFileSync as writeFileSync3 } from "fs";
703
713
  import { lstat, readdir as readdir2, rm as rm2 } from "fs/promises";
704
714
  import { tmpdir } from "os";
705
715
  import { dirname as dirname2, join as join4 } from "path";
@@ -863,7 +873,7 @@ function recheckAndMoveAway(historyRoot, id, { now, dryRun, tempDir }) {
863
873
  if (projectDir === null) return null;
864
874
  if (dryRun) return { projectDir, trash: null };
865
875
  const trash = join4(historyRoot, `${PRUNED_PREFIX}${id}-${randomUUID5()}`);
866
- renameSync2(home, trash);
876
+ renameSync(home, trash);
867
877
  return { projectDir, trash };
868
878
  }
869
879
  function abandonedProject(home, id, now, tempDir) {
@@ -922,7 +932,7 @@ function pruneGoneProjectHistoriesDaily(historyRoot, now, { onError, budgetMs }
922
932
 
923
933
  // src/history/videoState.ts
924
934
  import { createHash as createHash5, randomUUID as randomUUID6 } from "crypto";
925
- import { createReadStream as createReadStream2, existsSync as existsSync2, readFileSync as readFileSync8, renameSync as renameSync3, statSync as statSync4 } from "fs";
935
+ import { createReadStream as createReadStream2, existsSync as existsSync2, readFileSync as readFileSync8, renameSync as renameSync2, statSync as statSync4 } from "fs";
926
936
  import { isAbsolute as isAbsolute4, join as join7, resolve as resolve6 } from "path";
927
937
  import { replaceFileAtomically as replaceFileAtomically4 } from "@hyperframes/core/atomic-file";
928
938
 
@@ -1323,7 +1333,7 @@ async function updateState(dir, update) {
1323
1333
  state = readState(dir);
1324
1334
  } catch (error) {
1325
1335
  if (!(error instanceof SyntaxError)) throw error;
1326
- renameSync3(statePath(dir), `${statePath(dir)}.invalid-${randomUUID6()}`);
1336
+ renameSync2(statePath(dir), `${statePath(dir)}.invalid-${randomUUID6()}`);
1327
1337
  state = emptyState();
1328
1338
  }
1329
1339
  update(state);
@@ -1,14 +1,14 @@
1
1
  import {
2
2
  PROXY_PARAMS_VERSION,
3
3
  resolveProxy
4
- } from "./chunk-MNJZQCBL.js";
4
+ } from "./chunk-2B5SR4RJ.js";
5
5
  import {
6
6
  createMediaCodecProbeCache,
7
7
  proxyVariantFor,
8
8
  recordProxyPrewarm,
9
9
  scanProjectMedia,
10
10
  shouldPrewarmProxy
11
- } from "./chunk-JKPLDWAB.js";
11
+ } from "./chunk-FUTDRYQK.js";
12
12
 
13
13
  // src/helpers/mediaProxyPreview.ts
14
14
  import { resolve } from "path";
@@ -1,3 +1,6 @@
1
+ import {
2
+ probeAudioTrack
3
+ } from "./chunk-X73QTST2.js";
1
4
  import {
2
5
  resolveWithinProject
3
6
  } from "./chunk-QF7MFJJU.js";
@@ -318,16 +321,29 @@ function updateAudioVolume(html, id, volume) {
318
321
  }
319
322
  return html.slice(0, insertion) + ` data-volume="${value}"` + html.slice(insertion);
320
323
  }
321
- function loudnessMeasureArgs(file, tag) {
324
+ function loudnessMeasureArgs(file, tag, stream) {
322
325
  const args = ["-hide_banner", "-nostats"];
323
326
  if (tag.mediaStart > 0) args.push("-ss", String(tag.mediaStart));
324
327
  const sourceSpan = tag.duration === null ? null : tag.duration * (tag.playbackRate ?? 1);
325
328
  if (sourceSpan !== null && sourceSpan > 0) args.push("-t", String(sourceSpan));
326
- args.push("-i", file, "-map", "0:a:0", "-vn", "-af", "ebur128=peak=true", "-f", "null", "-");
329
+ args.push(
330
+ "-i",
331
+ file,
332
+ "-map",
333
+ `0:a:${stream}`,
334
+ "-vn",
335
+ "-af",
336
+ "ebur128=peak=true",
337
+ "-f",
338
+ "null",
339
+ "-"
340
+ );
327
341
  return args;
328
342
  }
329
343
  async function measureAudio(ffmpegPath, file, tag) {
330
- const result = await execFileAsync(ffmpegPath, loudnessMeasureArgs(file, tag), {
344
+ const track = await probeAudioTrack(file);
345
+ if (!track) throw new Error("no audio stream to measure");
346
+ const result = await execFileAsync(ffmpegPath, loudnessMeasureArgs(file, tag, track.stream), {
331
347
  encoding: "utf8",
332
348
  maxBuffer: 16 * 1024 * 1024,
333
349
  timeout: FFMPEG_TIMEOUT_MS,
@@ -1,3 +1,6 @@
1
+ import {
2
+ probeMediaMetadata
3
+ } from "./chunk-X73QTST2.js";
1
4
  import {
2
5
  realpath
3
6
  } from "./chunk-QF7MFJJU.js";
@@ -6,6 +9,34 @@ import {
6
9
  import { scanHtmlOpeningTags, decodeAuthoredAttribute } from "@hyperframes/parsers";
7
10
  import { statSync } from "fs";
8
11
  import { sep } from "path";
12
+ import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
13
+
14
+ // src/helpers/hdrFilters.ts
15
+ import { spawn } from "child_process";
16
+ var checks = /* @__PURE__ */ new Map();
17
+ function ffmpegHasHdrFilters(ffmpegPath) {
18
+ const known = checks.get(ffmpegPath);
19
+ if (known) return known;
20
+ const check = new Promise((resolveCheck) => {
21
+ const proc = spawn(ffmpegPath, ["-hide_banner", "-filters"], {
22
+ stdio: ["ignore", "pipe", "pipe"],
23
+ windowsHide: true
24
+ });
25
+ let stdout = "";
26
+ proc.stdout?.on("data", (chunk) => {
27
+ stdout += chunk.toString();
28
+ });
29
+ proc.on("error", () => resolveCheck(false));
30
+ proc.on(
31
+ "close",
32
+ (code) => resolveCheck(code === 0 && /\bzscale\b/.test(stdout) && /\btonemap\b/.test(stdout))
33
+ );
34
+ });
35
+ checks.set(ffmpegPath, check);
36
+ return check;
37
+ }
38
+
39
+ // src/helpers/mediaCodecMap.ts
9
40
  import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths";
10
41
  import {
11
42
  cleanAssetUrl,
@@ -16,176 +47,6 @@ import {
16
47
  import {
17
48
  isHeavyForPreview
18
49
  } from "@hyperframes/core";
19
-
20
- // src/helpers/mediaMetadata.ts
21
- import { execFile } from "child_process";
22
- import { extname } from "path";
23
- import {
24
- firstFrameColourArgs,
25
- parseFirstFrameColour
26
- } from "@hyperframes/core";
27
- import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
28
- import { videoStreamHasAlpha } from "@hyperframes/parsers/media-alpha";
29
- var execFileRunner = (command, args, options) => new Promise((resolvePromise) => {
30
- execFile(
31
- command,
32
- args,
33
- { timeout: options?.timeout, maxBuffer: options?.maxBuffer, windowsHide: true },
34
- (error, stdout, stderr) => {
35
- if (error && error.code === "ENOENT") {
36
- resolvePromise({ status: null, stdout: "", stderr: "", error });
37
- return;
38
- }
39
- if (error) {
40
- const status = typeof error.code === "number" ? error.code : 1;
41
- resolvePromise({ status, stdout: stdout ?? "", stderr: stderr ?? "" });
42
- return;
43
- }
44
- resolvePromise({ status: 0, stdout: stdout ?? "", stderr: stderr ?? "" });
45
- }
46
- );
47
- });
48
- var VIDEO_EXT = /* @__PURE__ */ new Set([
49
- ".mp4",
50
- ".mov",
51
- ".webm",
52
- ".mkv",
53
- ".avi",
54
- ".m4v",
55
- ".mxf",
56
- ".mts",
57
- ".m2ts",
58
- ".ts"
59
- ]);
60
- var IMAGE_EXT = /* @__PURE__ */ new Set([".jpg", ".jpeg", ".png", ".webp", ".avif"]);
61
- var AUDIO_EXT = /* @__PURE__ */ new Set([".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"]);
62
- function lower(value) {
63
- return value?.toLowerCase() ?? "";
64
- }
65
- function inferKindFromPath(path) {
66
- const ext = extname(path).toLowerCase();
67
- if (VIDEO_EXT.has(ext)) return "video";
68
- if (IMAGE_EXT.has(ext)) return "image";
69
- if (AUDIO_EXT.has(ext)) return "audio";
70
- return "unknown";
71
- }
72
- function colorLabel(input) {
73
- if (input.isHdr) {
74
- if (input.hdrTransfer === "pq") return "HDR PQ";
75
- if (input.hdrTransfer === "hlg") return "HDR HLG";
76
- return "HDR";
77
- }
78
- if (input.colorPrimaries.includes("bt709") || input.colorSpace.includes("bt709") || input.colorTransfer.includes("bt709")) {
79
- return "SDR Rec.709";
80
- }
81
- return "SDR/unknown";
82
- }
83
- function classifyMediaColor(stream) {
84
- const colorPrimaries = lower(stream?.color_primaries);
85
- const colorSpace = lower(stream?.color_space);
86
- const colorTransfer = lower(stream?.color_transfer);
87
- const isHdr = colorPrimaries.includes("bt2020") || colorSpace.includes("bt2020") || colorTransfer === "smpte2084" || colorTransfer === "arib-std-b67";
88
- const hdrTransfer = isHdr ? colorTransfer === "smpte2084" ? "pq" : colorTransfer === "arib-std-b67" ? "hlg" : "unknown" : null;
89
- return {
90
- dynamicRange: stream ? isHdr ? "hdr" : "sdr" : "unknown",
91
- hdrTransfer,
92
- label: stream ? colorLabel({ isHdr, hdrTransfer, colorPrimaries, colorSpace, colorTransfer }) : "Unknown",
93
- isHdr,
94
- codecName: stream?.codec_name,
95
- profile: stream?.profile,
96
- pixelFormat: stream?.pix_fmt,
97
- colorSpace: stream?.color_space,
98
- colorTransfer: stream?.color_transfer,
99
- colorPrimaries: stream?.color_primaries,
100
- bitsPerRawSample: stream?.bits_per_raw_sample
101
- };
102
- }
103
- async function probeFirstFrameColour(filePath, runner = execFileRunner) {
104
- const ffmpegPath = findFfBinary("ffmpeg", { configuredMustExist: true }) ?? (runner === execFileRunner ? void 0 : "ffmpeg");
105
- if (!ffmpegPath) return {};
106
- const result = await runner(ffmpegPath, firstFrameColourArgs(filePath), {
107
- timeout: 15e3,
108
- maxBuffer: 1024 * 1024
109
- });
110
- return result.status === 0 ? parseFirstFrameColour(String(result.stderr)) : {};
111
- }
112
- function bitDepthOf(stream) {
113
- const raw = Number(stream.bits_per_raw_sample);
114
- if (Number.isFinite(raw) && raw > 0) return raw;
115
- const suffixed = /(\d{1,3})(?:le|be)$/.exec(stream.pix_fmt ?? "");
116
- return suffixed ? Number(suffixed[1]) : 8;
117
- }
118
- function sourceFactsOf(stream, formatBitrate) {
119
- if (!stream?.width || !stream.height) return void 0;
120
- const bitrate = Number(stream.bit_rate ?? formatBitrate);
121
- return {
122
- width: stream.width,
123
- height: stream.height,
124
- bitDepth: bitDepthOf(stream),
125
- bitrate: Number.isFinite(bitrate) ? bitrate : 0
126
- };
127
- }
128
- var STREAM_SUMMARY_MAX = 12;
129
- function summarizeStream(stream) {
130
- const flags = Object.entries(stream.disposition ?? {}).filter(([, on]) => on === 1).map(([name]) => name);
131
- return `${stream.codec_type ?? "?"}:${stream.codec_name ?? "?"}[${flags.join(",")}]`;
132
- }
133
- function metadataFromProbe(kind, parsed) {
134
- const stream = parsed.streams?.find((item) => {
135
- if (kind === "image") return item.codec_type === "video";
136
- return item.codec_type === kind && item.disposition?.attached_pic !== 1;
137
- });
138
- const metadata = {
139
- kind,
140
- color: classifyMediaColor(stream),
141
- streams: (parsed.streams ?? []).slice(0, STREAM_SUMMARY_MAX).map(summarizeStream)
142
- };
143
- if (kind === "video") {
144
- metadata.hasAudio = (parsed.streams ?? []).some((item) => item.codec_type === "audio");
145
- metadata.hasAlpha = stream !== void 0 && videoStreamHasAlpha(stream);
146
- const duration = Number(parsed.format?.duration);
147
- if (Number.isFinite(duration) && duration > 0) metadata.durationSeconds = duration;
148
- metadata.source = sourceFactsOf(stream, parsed.format?.bit_rate);
149
- }
150
- return metadata;
151
- }
152
- async function probeMediaMetadata(filePath, runner = execFileRunner) {
153
- const kind = inferKindFromPath(filePath);
154
- if (kind === "audio" || kind === "unknown") {
155
- return { kind, color: classifyMediaColor(null) };
156
- }
157
- const ffprobePath = findFfBinary("ffprobe", { configuredMustExist: true }) ?? (runner === execFileRunner ? void 0 : "ffprobe");
158
- if (!ffprobePath) {
159
- return { kind, color: classifyMediaColor(null), probeError: "ffprobe unavailable" };
160
- }
161
- const result = await runner(
162
- ffprobePath,
163
- [
164
- "-v",
165
- "error",
166
- "-show_entries",
167
- "stream=codec_type,codec_name,profile,pix_fmt,color_space,color_transfer,color_primaries,bits_per_raw_sample,width,height,bit_rate:stream_disposition:stream_tags=alpha_mode:format=duration,bit_rate",
168
- "-of",
169
- "json",
170
- "--",
171
- filePath
172
- ],
173
- { timeout: 15e3, maxBuffer: 1024 * 1024 }
174
- );
175
- if (result.error?.code === "ENOENT") {
176
- return { kind, color: classifyMediaColor(null), probeError: "ffprobe unavailable" };
177
- }
178
- if (result.status !== 0) {
179
- return { kind, color: classifyMediaColor(null), probeError: "ffprobe failed" };
180
- }
181
- try {
182
- return metadataFromProbe(kind, JSON.parse(String(result.stdout || "{}")));
183
- } catch {
184
- return { kind, color: classifyMediaColor(null), probeError: "ffprobe returned invalid json" };
185
- }
186
- }
187
-
188
- // src/helpers/mediaCodecMap.ts
189
50
  var BROWSER_HOSTILE_CODECS = {
190
51
  // HEVC decode is platform-bound, not absent: macOS Chrome answers
191
52
  // canPlayType with "probably" and keeps the source, while Chrome on
@@ -258,12 +119,13 @@ function decideMediaProxyEligibility(facts, lightBox) {
258
119
  }
259
120
  return { eligible: false, reason: "browser_safe_codec" };
260
121
  }
261
- function codecFactsFor(codecName, hasAlpha, source) {
122
+ function codecFactsFor(codecName, hasAlpha, source, toneMapsToSdr = false) {
262
123
  const hostile = hostileCodecEntry(codecName);
263
124
  return {
264
125
  codecName,
265
- browserHostile: hostile !== void 0,
266
- representativeMime: hostile?.representativeMime ?? null,
126
+ browserHostile: hostile !== void 0 || toneMapsToSdr,
127
+ // An HDR clip is never 'playable' for preview, whatever its codec: it takes the SDR copy the export uses.
128
+ representativeMime: toneMapsToSdr ? null : hostile?.representativeMime ?? null,
267
129
  hasAlpha,
268
130
  ...source && { source }
269
131
  };
@@ -274,7 +136,22 @@ async function probeCodecFacts(filePath, runner) {
274
136
  if (metadata.kind !== "video") return null;
275
137
  const codecName = metadata.color.codecName;
276
138
  if (!codecName) return null;
277
- return codecFactsFor(codecName, metadata.hasAlpha === true, metadata.source);
139
+ const hasAlpha = metadata.hasAlpha === true;
140
+ const toneMaps = !hasAlpha && await toneMapsHdr(metadata.color.hdrTransfer);
141
+ return codecFactsFor(codecName, hasAlpha, metadata.source, toneMaps);
142
+ }
143
+ var warnedNoHdrFilters = false;
144
+ async function toneMapsHdr(hdrTransfer) {
145
+ if (hdrTransfer !== "pq" && hdrTransfer !== "hlg") return false;
146
+ const ffmpegPath = findFfBinary("ffmpeg");
147
+ if (ffmpegPath && await ffmpegHasHdrFilters(ffmpegPath)) return true;
148
+ if (!warnedNoHdrFilters) {
149
+ warnedNoHdrFilters = true;
150
+ console.warn(
151
+ `[hyperframes] A video in this project is HDR, but this ffmpeg has no zscale/tonemap (libzimg), so the preview plays it as the browser shows it and will not match an SDR export. Install an ffmpeg with libzimg.`
152
+ );
153
+ }
154
+ return false;
278
155
  }
279
156
  function createMediaCodecProbeCache() {
280
157
  return /* @__PURE__ */ new Map();
@@ -368,8 +245,7 @@ async function scanProjectMediaCodecMap(projectDir, htmlSources, options = {}) {
368
245
  }
369
246
 
370
247
  export {
371
- probeFirstFrameColour,
372
- probeMediaMetadata,
248
+ ffmpegHasHdrFilters,
373
249
  BROWSER_HOSTILE_CODECS,
374
250
  shouldPrewarmProxy,
375
251
  mediaProxyDemand,
@@ -0,0 +1,192 @@
1
+ // src/helpers/mediaMetadata.ts
2
+ import { execFile } from "child_process";
3
+ import { extname } from "path";
4
+ import {
5
+ firstFrameColourArgs,
6
+ parseFirstFrameColour
7
+ } from "@hyperframes/core";
8
+ import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
9
+ import { videoStreamHasAlpha } from "@hyperframes/parsers/media-alpha";
10
+ import { decodableAudioStream } from "@hyperframes/parsers/media-streams";
11
+ var execFileRunner = (command, args, options) => new Promise((resolvePromise) => {
12
+ execFile(
13
+ command,
14
+ args,
15
+ { timeout: options?.timeout, maxBuffer: options?.maxBuffer, windowsHide: true },
16
+ (error, stdout, stderr) => {
17
+ if (error && error.code === "ENOENT") {
18
+ resolvePromise({ status: null, stdout: "", stderr: "", error });
19
+ return;
20
+ }
21
+ if (error) {
22
+ const status = typeof error.code === "number" ? error.code : 1;
23
+ resolvePromise({ status, stdout: stdout ?? "", stderr: stderr ?? "" });
24
+ return;
25
+ }
26
+ resolvePromise({ status: 0, stdout: stdout ?? "", stderr: stderr ?? "" });
27
+ }
28
+ );
29
+ });
30
+ var VIDEO_EXT = /* @__PURE__ */ new Set([
31
+ ".mp4",
32
+ ".mov",
33
+ ".webm",
34
+ ".mkv",
35
+ ".avi",
36
+ ".m4v",
37
+ ".mxf",
38
+ ".mts",
39
+ ".m2ts",
40
+ ".ts"
41
+ ]);
42
+ var IMAGE_EXT = /* @__PURE__ */ new Set([".jpg", ".jpeg", ".png", ".webp", ".avif"]);
43
+ var AUDIO_EXT = /* @__PURE__ */ new Set([".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"]);
44
+ function lower(value) {
45
+ return value?.toLowerCase() ?? "";
46
+ }
47
+ function inferKindFromPath(path) {
48
+ const ext = extname(path).toLowerCase();
49
+ if (VIDEO_EXT.has(ext)) return "video";
50
+ if (IMAGE_EXT.has(ext)) return "image";
51
+ if (AUDIO_EXT.has(ext)) return "audio";
52
+ return "unknown";
53
+ }
54
+ function colorLabel(input) {
55
+ if (input.isHdr) {
56
+ if (input.hdrTransfer === "pq") return "HDR PQ";
57
+ if (input.hdrTransfer === "hlg") return "HDR HLG";
58
+ return "HDR";
59
+ }
60
+ if (input.colorPrimaries.includes("bt709") || input.colorSpace.includes("bt709") || input.colorTransfer.includes("bt709")) {
61
+ return "SDR Rec.709";
62
+ }
63
+ return "SDR/unknown";
64
+ }
65
+ function classifyMediaColor(stream) {
66
+ const colorPrimaries = lower(stream?.color_primaries);
67
+ const colorSpace = lower(stream?.color_space);
68
+ const colorTransfer = lower(stream?.color_transfer);
69
+ const isHdr = colorPrimaries.includes("bt2020") || colorSpace.includes("bt2020") || colorTransfer === "smpte2084" || colorTransfer === "arib-std-b67";
70
+ const hdrTransfer = isHdr ? colorTransfer === "smpte2084" ? "pq" : colorTransfer === "arib-std-b67" ? "hlg" : "unknown" : null;
71
+ return {
72
+ dynamicRange: stream ? isHdr ? "hdr" : "sdr" : "unknown",
73
+ hdrTransfer,
74
+ label: stream ? colorLabel({ isHdr, hdrTransfer, colorPrimaries, colorSpace, colorTransfer }) : "Unknown",
75
+ isHdr,
76
+ codecName: stream?.codec_name,
77
+ profile: stream?.profile,
78
+ pixelFormat: stream?.pix_fmt,
79
+ colorSpace: stream?.color_space,
80
+ colorTransfer: stream?.color_transfer,
81
+ colorPrimaries: stream?.color_primaries,
82
+ bitsPerRawSample: stream?.bits_per_raw_sample
83
+ };
84
+ }
85
+ async function probeFirstFrameColour(filePath, runner = execFileRunner) {
86
+ const ffmpegPath = findFfBinary("ffmpeg", { configuredMustExist: true }) ?? (runner === execFileRunner ? void 0 : "ffmpeg");
87
+ if (!ffmpegPath) return {};
88
+ const result = await runner(ffmpegPath, firstFrameColourArgs(filePath), {
89
+ timeout: 15e3,
90
+ maxBuffer: 1024 * 1024
91
+ });
92
+ return result.status === 0 ? parseFirstFrameColour(String(result.stderr)) : {};
93
+ }
94
+ function bitDepthOf(stream) {
95
+ const raw = Number(stream.bits_per_raw_sample);
96
+ if (Number.isFinite(raw) && raw > 0) return raw;
97
+ const suffixed = /(\d{1,3})(?:le|be)$/.exec(stream.pix_fmt ?? "");
98
+ return suffixed ? Number(suffixed[1]) : 8;
99
+ }
100
+ function sourceFactsOf(stream, formatBitrate) {
101
+ if (!stream?.width || !stream.height) return void 0;
102
+ const bitrate = Number(stream.bit_rate ?? formatBitrate);
103
+ return {
104
+ width: stream.width,
105
+ height: stream.height,
106
+ bitDepth: bitDepthOf(stream),
107
+ bitrate: Number.isFinite(bitrate) ? bitrate : 0
108
+ };
109
+ }
110
+ var STREAM_SUMMARY_MAX = 12;
111
+ function summarizeStream(stream) {
112
+ const flags = Object.entries(stream.disposition ?? {}).filter(([, on]) => on === 1).map(([name]) => name);
113
+ return `${stream.codec_type ?? "?"}:${stream.codec_name ?? "?"}[${flags.join(",")}]`;
114
+ }
115
+ function audioOf(streams) {
116
+ const audioStream = decodableAudioStream(streams);
117
+ return audioStream === null ? { hasAudio: false } : { hasAudio: true, audioStream };
118
+ }
119
+ function metadataFromProbe(kind, parsed) {
120
+ const stream = parsed.streams?.find((item) => {
121
+ if (kind === "image") return item.codec_type === "video";
122
+ return item.codec_type === kind && item.disposition?.attached_pic !== 1;
123
+ });
124
+ const metadata = {
125
+ kind,
126
+ color: classifyMediaColor(stream),
127
+ streams: (parsed.streams ?? []).slice(0, STREAM_SUMMARY_MAX).map(summarizeStream)
128
+ };
129
+ if (kind === "video") {
130
+ Object.assign(metadata, audioOf(parsed.streams ?? []));
131
+ metadata.hasAlpha = stream !== void 0 && videoStreamHasAlpha(stream);
132
+ const duration = Number(parsed.format?.duration);
133
+ if (Number.isFinite(duration) && duration > 0) metadata.durationSeconds = duration;
134
+ metadata.source = sourceFactsOf(stream, parsed.format?.bit_rate);
135
+ }
136
+ return metadata;
137
+ }
138
+ async function probeAudioTrack(filePath, runner = execFileRunner) {
139
+ const result = await runner(
140
+ findFfBinary("ffprobe") ?? "ffprobe",
141
+ [
142
+ ...["-v", "error", "-select_streams", "a"],
143
+ ...["-show_entries", "stream=codec_type,codec_name,channels", "-of", "json", "--", filePath]
144
+ ],
145
+ { timeout: 3e4 }
146
+ );
147
+ if (result.status !== 0) return null;
148
+ const streams = JSON.parse(String(result.stdout || "{}")).streams ?? [];
149
+ const stream = decodableAudioStream(streams);
150
+ return stream === null ? null : { stream, channels: streams[stream]?.channels ?? 0 };
151
+ }
152
+ async function probeMediaMetadata(filePath, runner = execFileRunner) {
153
+ const kind = inferKindFromPath(filePath);
154
+ if (kind === "audio" || kind === "unknown") {
155
+ return { kind, color: classifyMediaColor(null) };
156
+ }
157
+ const ffprobePath = findFfBinary("ffprobe", { configuredMustExist: true }) ?? (runner === execFileRunner ? void 0 : "ffprobe");
158
+ if (!ffprobePath) {
159
+ return { kind, color: classifyMediaColor(null), probeError: "ffprobe unavailable" };
160
+ }
161
+ const result = await runner(
162
+ ffprobePath,
163
+ [
164
+ "-v",
165
+ "error",
166
+ "-show_entries",
167
+ "stream=codec_type,codec_name,profile,pix_fmt,color_space,color_transfer,color_primaries,bits_per_raw_sample,width,height,bit_rate:stream_disposition:stream_tags=alpha_mode:format=duration,bit_rate",
168
+ "-of",
169
+ "json",
170
+ "--",
171
+ filePath
172
+ ],
173
+ { timeout: 15e3, maxBuffer: 1024 * 1024 }
174
+ );
175
+ if (result.error?.code === "ENOENT") {
176
+ return { kind, color: classifyMediaColor(null), probeError: "ffprobe unavailable" };
177
+ }
178
+ if (result.status !== 0) {
179
+ return { kind, color: classifyMediaColor(null), probeError: "ffprobe failed" };
180
+ }
181
+ try {
182
+ return metadataFromProbe(kind, JSON.parse(String(result.stdout || "{}")));
183
+ } catch {
184
+ return { kind, color: classifyMediaColor(null), probeError: "ffprobe returned invalid json" };
185
+ }
186
+ }
187
+
188
+ export {
189
+ probeFirstFrameColour,
190
+ probeAudioTrack,
191
+ probeMediaMetadata
192
+ };
@@ -57,7 +57,7 @@ declare function updateAudioVolume(html: string, id: string, volume: number): st
57
57
  * -61.8 LUFS: output-side `-t 4` reported -33.8 LUFS, input-side reports -61.8,
58
58
  * the same as physically cutting the file first.
59
59
  */
60
- declare function loudnessMeasureArgs(file: string, tag: MeasuredWindow): string[];
60
+ declare function loudnessMeasureArgs(file: string, tag: MeasuredWindow, stream: number): string[];
61
61
  type MeasuredWindow = Pick<AudioTag, "mediaStart" | "duration"> & Partial<Pick<AudioTag, "playbackRate">>;
62
62
  declare function measureAudio(ffmpegPath: string, file: string, tag: MeasuredWindow): Promise<LoudnessMeasurement>;
63
63
  declare function requiredFfmpeg(): string;
@@ -11,7 +11,8 @@ import {
11
11
  requiredFfmpeg,
12
12
  resolveLocalAudioPath,
13
13
  updateAudioVolume
14
- } from "../chunk-QZWVJKLI.js";
14
+ } from "../chunk-EGNSA3MU.js";
15
+ import "../chunk-X73QTST2.js";
15
16
  import "../chunk-QF7MFJJU.js";
16
17
  import "../chunk-SVQFE44C.js";
17
18
  import "../chunk-L3HJVTQE.js";
@@ -14,7 +14,8 @@ import {
14
14
  scanProjectMedia,
15
15
  scanProjectMediaCodecMap,
16
16
  shouldPrewarmProxy
17
- } from "../chunk-JKPLDWAB.js";
17
+ } from "../chunk-FUTDRYQK.js";
18
+ import "../chunk-X73QTST2.js";
18
19
  import "../chunk-QF7MFJJU.js";
19
20
  import "../chunk-SVQFE44C.js";
20
21
  import "../chunk-L3HJVTQE.js";
@@ -4,9 +4,10 @@ import {
4
4
  isAutoProxyEnabled,
5
5
  proxyEtagSalt,
6
6
  resolvePreviewMediaCodecProbeCache
7
- } from "../chunk-OAHY2RQP.js";
8
- import "../chunk-MNJZQCBL.js";
9
- import "../chunk-JKPLDWAB.js";
7
+ } from "../chunk-C3KTZ7G3.js";
8
+ import "../chunk-2B5SR4RJ.js";
9
+ import "../chunk-FUTDRYQK.js";
10
+ import "../chunk-X73QTST2.js";
10
11
  import "../chunk-QF7MFJJU.js";
11
12
  import "../chunk-SVQFE44C.js";
12
13
  import "../chunk-L3HJVTQE.js";
@@ -20,8 +20,9 @@ import {
20
20
  retryProxyCopy,
21
21
  sourceFolderKind,
22
22
  waitForProxy
23
- } from "../chunk-MNJZQCBL.js";
24
- import "../chunk-JKPLDWAB.js";
23
+ } from "../chunk-2B5SR4RJ.js";
24
+ import "../chunk-FUTDRYQK.js";
25
+ import "../chunk-X73QTST2.js";
25
26
  import "../chunk-QF7MFJJU.js";
26
27
  import "../chunk-SVQFE44C.js";
27
28
  import "../chunk-L3HJVTQE.js";
@@ -22,7 +22,7 @@ import {
22
22
  recordRenderSuccess,
23
23
  registerRenderOutput,
24
24
  retainSnapshotOutput
25
- } from "../chunk-WAMZ2Z5O.js";
25
+ } from "../chunk-BWZMWYB2.js";
26
26
  import "../chunk-QF7MFJJU.js";
27
27
  import "../chunk-SVQFE44C.js";
28
28
  import "../chunk-L3HJVTQE.js";
package/dist/index.js CHANGED
@@ -62,7 +62,7 @@ import {
62
62
  resolveProjectSignature,
63
63
  retainSnapshotOutput,
64
64
  settledFileTag
65
- } from "./chunk-WAMZ2Z5O.js";
65
+ } from "./chunk-BWZMWYB2.js";
66
66
  import {
67
67
  clearElementScreenshotIsolation,
68
68
  getElementScreenshotClip
@@ -76,13 +76,13 @@ import {
76
76
  requestedProjectPath,
77
77
  requiredFfmpeg,
78
78
  resolveLocalAudioPath
79
- } from "./chunk-QZWVJKLI.js";
79
+ } from "./chunk-EGNSA3MU.js";
80
80
  import {
81
81
  injectMediaCodecMap,
82
82
  isAutoProxyEnabled,
83
83
  proxyEtagSalt,
84
84
  resolvePreviewMediaCodecProbeCache
85
- } from "./chunk-OAHY2RQP.js";
85
+ } from "./chunk-C3KTZ7G3.js";
86
86
  import {
87
87
  CACHE_DIR_NAME,
88
88
  PROXY_PENDING_RETRY_AFTER_SECONDS,
@@ -95,16 +95,19 @@ import {
95
95
  resolveProxy,
96
96
  retryProxyCopy,
97
97
  waitForProxy
98
- } from "./chunk-MNJZQCBL.js";
98
+ } from "./chunk-2B5SR4RJ.js";
99
99
  import {
100
100
  PROXY_VARIANT_CONFIG,
101
101
  decideMediaProxyEligibility,
102
102
  isProxyVariantRequest,
103
103
  probeAssetCodec,
104
- probeMediaMetadata,
105
104
  recordProxyRequest,
106
105
  resolveProxyVariantRequest
107
- } from "./chunk-JKPLDWAB.js";
106
+ } from "./chunk-FUTDRYQK.js";
107
+ import {
108
+ probeAudioTrack,
109
+ probeMediaMetadata
110
+ } from "./chunk-X73QTST2.js";
108
111
  import {
109
112
  folderGone,
110
113
  isInHiddenOrVendorDir,
@@ -315,6 +318,33 @@ function insertGsapCoreBeforeHeadPlugin(html, coreTag) {
315
318
  ${html.slice(at)}`;
316
319
  }
317
320
 
321
+ // src/helpers/rootPaths.ts
322
+ import { mapStartTagsAndStyles } from "@hyperframes/core/compiler/html-document";
323
+ var ROOT_PATH = /^\/(?!\/)/;
324
+ var PATH_ATTR = /(\s(?:src|href|poster|xlink:href)\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi;
325
+ var CSS_ROOT_URL = /(\burl\(\s*)(["']?)(\/(?!\/)[^"')\s]*)\2/gi;
326
+ var projectRelative = (path, base) => ROOT_PATH.test(path) && path !== "/" && !path.startsWith(base) ? path.slice(1) : path;
327
+ var underBase = (path, base) => {
328
+ const relative5 = projectRelative(path, base);
329
+ return relative5 === path ? path : base + relative5;
330
+ };
331
+ function withRootPathsRelative(html, base) {
332
+ const css = (text2) => text2.replace(
333
+ CSS_ROOT_URL,
334
+ (_m, open2, quote, path) => `${open2}${quote}${projectRelative(path, base)}${quote}`
335
+ );
336
+ const attrs = (tag) => tag.replace(PATH_ATTR, (whole, name, dq, sq, bare) => {
337
+ const path = (dq ?? sq ?? bare ?? "").trim();
338
+ const moved = projectRelative(path, base);
339
+ if (moved === path) return whole;
340
+ return dq !== void 0 ? `${name}"${moved}"` : sq !== void 0 ? `${name}'${moved}'` : `${name}${moved}`;
341
+ });
342
+ return mapStartTagsAndStyles(html, {
343
+ tag: (tag) => /^<(?:a|base)[\s/>]/i.test(tag) ? tag : css(attrs(tag)),
344
+ style: css
345
+ });
346
+ }
347
+
318
348
  // src/helpers/subComposition.ts
319
349
  function rewriteRelativePaths(root, compPath, projectDir) {
320
350
  const assetExists = (path) => existsSync(join2(projectDir, path));
@@ -463,6 +493,10 @@ ${headContent}`;
463
493
  }
464
494
  if (compHeadContent) headContent += `
465
495
  ${compHeadContent}`;
496
+ if (baseHref) {
497
+ headContent = withRootPathsRelative(headContent, baseHref);
498
+ rewrittenContent = withRootPathsRelative(rewrittenContent, baseHref);
499
+ }
466
500
  headContent = stripEmbeddedRuntimeScripts(headContent);
467
501
  headContent += `
468
502
  <script ${RUNTIME_BOOTSTRAP_ATTR}="1" src="${runtimeUrl}"></script>`;
@@ -836,13 +870,17 @@ function computePeaks(floats, count) {
836
870
  const maxPeak = Math.max(...peaks, 1e-3);
837
871
  return peaks.map((p) => p / maxPeak);
838
872
  }
839
- function decodeAudioPeaks(audioPath) {
873
+ async function decodeAudioPeaks(audioPath) {
874
+ const track = await probeAudioTrack(audioPath);
875
+ if (!track) throw new Error("no audio stream");
840
876
  return new Promise((resolvePromise, reject) => {
841
877
  const proc = spawn(
842
878
  findFfBinary("ffmpeg") ?? "ffmpeg",
843
879
  [
844
880
  "-i",
845
881
  audioPath,
882
+ "-map",
883
+ `0:a:${track.stream}`,
846
884
  "-af",
847
885
  "atrim=start_sample=1152",
848
886
  "-f",
@@ -917,6 +955,7 @@ import { mkdtempSync as mkdtempSync2, rmSync as rmSync2, writeFileSync as writeF
917
955
  import { tmpdir } from "os";
918
956
  import { basename, join as join4 } from "path";
919
957
  import { FFPROBE_PATH_ENV, findFfBinary as findFfBinary2 } from "@hyperframes/parsers/ff-binaries";
958
+ import { decodableAudioStream } from "@hyperframes/parsers/media-streams";
920
959
  var VIDEO_EXT = /\.(mp4|webm|mov|mkv|avi|m4v|mxf|mts|m2ts|ts)$/i;
921
960
  var AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac|flac)$/i;
922
961
  var defaultRunner = spawnSync;
@@ -931,7 +970,7 @@ function validateUploadedMedia(filePath, runner = defaultRunner) {
931
970
  if (!ffprobe) return { ok: true, unchecked: FFPROBE_MISSING };
932
971
  const result = runner(
933
972
  ffprobe,
934
- ["-v", "error", "-show_entries", "stream=codec_type", "-of", "json", "--", filePath],
973
+ ["-v", "error", "-show_entries", "stream=codec_type,codec_name", "-of", "json", "--", filePath],
935
974
  { windowsHide: true }
936
975
  );
937
976
  if (result.error && process.env[FFPROBE_PATH_ENV]?.trim()) {
@@ -953,7 +992,7 @@ function checkStreams(stdout, isVideo, isAudio) {
953
992
  const parsed = JSON.parse(stdout);
954
993
  const streams = parsed.streams ?? [];
955
994
  const hasVideo = streams.some((stream) => stream.codec_type === "video");
956
- const hasAudio = streams.some((stream) => stream.codec_type === "audio");
995
+ const hasAudio = decodableAudioStream(streams) !== null;
957
996
  if (isVideo && !hasVideo) {
958
997
  return { ok: false, reason: "no supported video stream found" };
959
998
  }
@@ -3907,7 +3946,6 @@ import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
3907
3946
 
3908
3947
  // src/helpers/rootImportMap.ts
3909
3948
  import { findStartTags as findStartTags2, injectTagsAtHeadStart } from "@hyperframes/core/compiler/html-document";
3910
- var ROOT_PATH = /^\/(?!\/)/;
3911
3949
  var IMPORT_MAP_TYPE = /\btype\s*=\s*["']?importmap["']?(?=[\s/>])/i;
3912
3950
  function findImportMap(html) {
3913
3951
  for (const at of findStartTags2(html, "script")) {
@@ -3934,7 +3972,7 @@ function withRootImportMap(html, base) {
3934
3972
  return html;
3935
3973
  }
3936
3974
  if (!map || typeof map !== "object") return html;
3937
- const under = (path) => ROOT_PATH.test(path) && !path.startsWith(base) ? base + path.slice(1) : path;
3975
+ const under = (path) => underBase(path, base);
3938
3976
  const moved = (entries = {}) => Object.fromEntries(
3939
3977
  Object.entries(entries).map(([key, target]) => [key, under(String(target))])
3940
3978
  );
@@ -4352,7 +4390,7 @@ var PREVIEW_BUNDLE_OPTIONS = {
4352
4390
  var previewBaseHref = (projectId) => `/api/projects/${encodeURIComponent(projectId)}/preview/`;
4353
4391
  var withPreviewBase = (html, projectId) => {
4354
4392
  const base = previewBaseHref(projectId);
4355
- const mapped = withRootImportMap(html, base);
4393
+ const mapped = withRootImportMap(withRootPathsRelative(html, base), base);
4356
4394
  return hasBaseElement(mapped) ? mapped : injectTagsAtHeadStart2(mapped, `<base href="${base}">`);
4357
4395
  };
4358
4396
  function resolvePreviewGsapBaseUrl(adapter) {
@@ -4397,10 +4435,9 @@ function registerPreviewRoutes(api, adapter) {
4397
4435
  bundled = ensureHfIds2(diskMain.html);
4398
4436
  mainCompositionPath = diskMain.compositionPath;
4399
4437
  }
4400
- bundled = withPreviewRuntime(bundled, adapter.runtimeUrl);
4438
+ bundled = withPreviewRuntime(withPreviewBase(bundled, project.id), adapter.runtimeUrl);
4401
4439
  recordPreviewReferences(project.dir, bundled);
4402
4440
  recordPreviewBuilt(project.dir);
4403
- bundled = withPreviewBase(bundled, project.id);
4404
4441
  bundled = injectStudioPreviewAugmentations(
4405
4442
  ensureHfIds2(await transformPreviewHtml(bundled, adapter, project, mainCompositionPath)),
4406
4443
  adapter,
@@ -4425,9 +4462,9 @@ function registerPreviewRoutes(api, adapter) {
4425
4462
  } catch {
4426
4463
  const fallback = resolveProjectMainHtml(project.dir, project.id);
4427
4464
  if (fallback) {
4428
- const fallbackHtml = withPreviewBase(
4429
- withPreviewRuntime(ensureHfIds2(fallback.html), adapter.runtimeUrl),
4430
- project.id
4465
+ const fallbackHtml = withPreviewRuntime(
4466
+ withPreviewBase(ensureHfIds2(fallback.html), project.id),
4467
+ adapter.runtimeUrl
4431
4468
  );
4432
4469
  let fallbackAugmented = injectStudioPreviewAugmentations(
4433
4470
  await transformPreviewHtml(fallbackHtml, adapter, project, fallback.compositionPath),
@@ -5873,12 +5910,10 @@ import { join as join14, resolve as resolve6 } from "path";
5873
5910
  import { isWithinProjectRoot as isWithinProjectRoot3 } from "@hyperframes/parsers/asset-resolution";
5874
5911
 
5875
5912
  // src/helpers/peakMap.ts
5876
- import { execFile as execFile2, spawn as spawn2 } from "child_process";
5877
- import { promisify } from "util";
5913
+ import { spawn as spawn2 } from "child_process";
5878
5914
  import { findFfBinary as findFfBinary4 } from "@hyperframes/parsers/ff-binaries";
5879
5915
  var PEAK_BIN_SECONDS = 0.05;
5880
5916
  var PEAK_SAMPLE_RATE = 48e3;
5881
- var execFileAsync = promisify(execFile2);
5882
5917
  var PEAK_MAP_CACHE_VERSION = "peaks-v2";
5883
5918
  function createPeakAccumulator(sampleRate = PEAK_SAMPLE_RATE, binSeconds = PEAK_BIN_SECONDS, channels = 2) {
5884
5919
  return {
@@ -5912,29 +5947,10 @@ function accumulatePeaks(acc, chunk) {
5912
5947
  function finishPeaks(acc) {
5913
5948
  return acc.bins.map((peak) => Number(peak.toFixed(4)));
5914
5949
  }
5915
- async function probeChannels(mediaPath) {
5916
- const { stdout } = await execFileAsync(
5917
- findFfBinary4("ffprobe") ?? "ffprobe",
5918
- [
5919
- "-v",
5920
- "error",
5921
- "-select_streams",
5922
- "a:0",
5923
- "-show_entries",
5924
- "stream=channels",
5925
- "-of",
5926
- "csv=p=0",
5927
- "--",
5928
- mediaPath
5929
- ],
5930
- { encoding: "utf8", timeout: 3e4, windowsHide: true }
5931
- );
5932
- const channels = Number.parseInt(stdout.trim(), 10);
5933
- if (!Number.isFinite(channels) || channels < 1) throw new Error("no audio stream");
5934
- return channels;
5935
- }
5936
5950
  async function decodePeakMap(mediaPath) {
5937
- const channels = await probeChannels(mediaPath);
5951
+ const track = await probeAudioTrack(mediaPath);
5952
+ if (!track || track.channels < 1) throw new Error("no audio stream");
5953
+ const { stream, channels } = track;
5938
5954
  return new Promise((resolvePromise, reject) => {
5939
5955
  const proc = spawn2(
5940
5956
  findFfBinary4("ffmpeg") ?? "ffmpeg",
@@ -5944,7 +5960,7 @@ async function decodePeakMap(mediaPath) {
5944
5960
  "-i",
5945
5961
  mediaPath,
5946
5962
  "-map",
5947
- "0:a:0",
5963
+ `0:a:${stream}`,
5948
5964
  "-vn",
5949
5965
  "-ar",
5950
5966
  String(PEAK_SAMPLE_RATE),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio-server",
3
- "version": "0.8.208",
3
+ "version": "0.8.209",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/heygen-com/hyperframes",
@@ -84,8 +84,8 @@
84
84
  "linkedom": "^0.18.12",
85
85
  "postcss": "^8.5.8",
86
86
  "postcss-selector-parser": "^7.1.2",
87
- "@hyperframes/core": "0.8.208",
88
- "@hyperframes/parsers": "0.8.208"
87
+ "@hyperframes/core": "0.8.209",
88
+ "@hyperframes/parsers": "0.8.209"
89
89
  },
90
90
  "devDependencies": {
91
91
  "@types/node": "^25.0.10",