@hyperframes/studio-server 0.8.199 → 0.8.201

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.
@@ -2,7 +2,7 @@ import {
2
2
  PROXY_VARIANT_CONFIG,
3
3
  probeFirstFrameColour,
4
4
  probeMediaMetadata
5
- } from "./chunk-XMNHDWQI.js";
5
+ } from "./chunk-C2RFW2FX.js";
6
6
  import {
7
7
  mkdirWithinProject,
8
8
  realProjectRoot,
@@ -22,7 +22,7 @@ import {
22
22
  utimesSync,
23
23
  writeFileSync
24
24
  } from "fs";
25
- import { availableParallelism, tmpdir } from "os";
25
+ import { availableParallelism, tmpdir, totalmem } from "os";
26
26
  import { basename, dirname, extname as extname2, isAbsolute, join as join2, relative, resolve, sep } from "path";
27
27
  import {
28
28
  formatPreviewProxyBox,
@@ -144,7 +144,7 @@ function cleanupProxyCache(cacheDir, options = {}) {
144
144
  }
145
145
 
146
146
  // src/helpers/proxyTranscoder.ts
147
- var PROXY_PARAMS_VERSION = "v6";
147
+ var PROXY_PARAMS_VERSION = "v7";
148
148
  var CACHE_DIR_NAME = ".transcode-cache";
149
149
  function boundedEnvInteger(name, fallback, min, max) {
150
150
  const raw = process.env[name]?.trim();
@@ -420,28 +420,91 @@ function chunkRanges(durationSeconds) {
420
420
  lengthSeconds: index === count - 1 ? durationSeconds - index * PROXY_CHUNK_SECONDS : PROXY_CHUNK_SECONDS
421
421
  }));
422
422
  }
423
- async function encodeInChunks(ranges, chunkPrefix, tempPath, encode, concat, onDone = () => {
423
+ var UNGATED = { size: 1, acquire: async () => {
424
+ }, release: () => {
425
+ } };
426
+ function createPieceGate(size) {
427
+ let free = size;
428
+ const waiters = [];
429
+ return {
430
+ size,
431
+ acquire: (signal) => {
432
+ signal.throwIfAborted();
433
+ if (free > 0) {
434
+ free--;
435
+ return Promise.resolve();
436
+ }
437
+ return new Promise((resolve2, reject) => {
438
+ const grant = () => {
439
+ signal.removeEventListener("abort", onAbort);
440
+ resolve2();
441
+ };
442
+ const onAbort = () => {
443
+ waiters.splice(waiters.indexOf(grant), 1);
444
+ reject(signal.reason);
445
+ };
446
+ signal.addEventListener("abort", onAbort, { once: true });
447
+ waiters.push(grant);
448
+ });
449
+ },
450
+ release: () => {
451
+ const next = waiters.shift();
452
+ if (next) next();
453
+ else free++;
454
+ }
455
+ };
456
+ }
457
+ async function encodeInChunks(ranges, chunkPrefix, tempPath, encode, concat, signal, gate, onDone = () => {
424
458
  }) {
425
459
  const extension = extname2(tempPath);
426
460
  const pieces = ranges.map((_, index) => `${chunkPrefix}${index}${extension}`);
461
+ const pending = ranges.flatMap((_, index) => existsSync2(pieces[index]) ? [] : [index]);
427
462
  let doneSeconds = 0;
428
463
  for (const [index, range] of ranges.entries()) {
429
- if (existsSync2(pieces[index])) doneSeconds += range.lengthSeconds;
464
+ if (!pending.includes(index)) doneSeconds += range.lengthSeconds;
430
465
  }
431
466
  if (doneSeconds) onDone(doneSeconds);
432
- for (const [index, range] of ranges.entries()) {
433
- const piece = pieces[index];
434
- if (existsSync2(piece)) continue;
435
- const pieceTemp = join2(dirname(piece), `.tmp-${randomUUID()}-${basename(piece)}`);
436
- try {
437
- await encode(pieceTemp, range);
438
- renameSync(pieceTemp, piece);
439
- doneSeconds += range.lengthSeconds;
440
- onDone(doneSeconds);
441
- } finally {
442
- if (existsSync2(pieceTemp)) unlinkSync2(pieceTemp);
467
+ const inFlight2 = /* @__PURE__ */ new Map();
468
+ const stop = new AbortController();
469
+ const pieceSignal = AbortSignal.any([signal, stop.signal]);
470
+ let failure;
471
+ let cursor = 0;
472
+ const work = async () => {
473
+ for (; ; ) {
474
+ await gate.acquire(pieceSignal);
475
+ try {
476
+ if (cursor >= pending.length) return;
477
+ const index = pending[cursor++];
478
+ const range = ranges[index];
479
+ const piece = pieces[index];
480
+ const pieceTemp = join2(dirname(piece), `.tmp-${randomUUID()}-${basename(piece)}`);
481
+ try {
482
+ await encode(pieceTemp, range, pieceSignal, (encodedSeconds) => {
483
+ inFlight2.set(index, encodedSeconds);
484
+ onDone(doneSeconds + [...inFlight2.values()].reduce((sum, seconds) => sum + seconds, 0));
485
+ });
486
+ renameSync(pieceTemp, piece);
487
+ inFlight2.delete(index);
488
+ doneSeconds += range.lengthSeconds;
489
+ onDone(doneSeconds);
490
+ } finally {
491
+ if (existsSync2(pieceTemp)) unlinkSync2(pieceTemp);
492
+ }
493
+ } finally {
494
+ gate.release();
495
+ }
443
496
  }
444
- }
497
+ };
498
+ await Promise.all(
499
+ Array.from(
500
+ { length: Math.min(gate.size, pending.length) },
501
+ () => work().catch((error) => {
502
+ failure ??= error;
503
+ stop.abort();
504
+ })
505
+ )
506
+ );
507
+ if (failure !== void 0) throw failure;
445
508
  const listDir = mkdtempSync(join2(tmpdir(), "hf-proxy-join-"));
446
509
  try {
447
510
  const listPath = join2(listDir, "pieces.txt");
@@ -456,6 +519,7 @@ async function encodeInChunks(ranges, chunkPrefix, tempPath, encode, concat, onD
456
519
  }
457
520
  for (const piece of pieces) if (existsSync2(piece)) unlinkSync2(piece);
458
521
  }
522
+ var KEYFRAME_EVERY_SECOND_ARGS = ["-force_key_frames", "expr:gte(t,n_forced*1)"];
459
523
  var H264_ARGS = [
460
524
  "-c:v",
461
525
  "libx264",
@@ -478,7 +542,11 @@ var H264_ARGS = [
478
542
  "-movflags",
479
543
  "+faststart"
480
544
  ];
481
- var VP8_THREADS = String(Math.min(4, availableParallelism()));
545
+ var CORES = availableParallelism();
546
+ var PIECE_MEMORY_BYTES = 3 * 1024 ** 3;
547
+ var ALPHA_PIECE_GATE = createPieceGate(
548
+ Math.max(1, Math.min(4, CORES - 1, Math.floor(totalmem() / PIECE_MEMORY_BYTES)))
549
+ );
482
550
  var VP8_ARGS = [
483
551
  "-c:v",
484
552
  "libvpx",
@@ -498,8 +566,6 @@ var VP8_ARGS = [
498
566
  "bt709",
499
567
  "-cpu-used",
500
568
  "4",
501
- "-threads",
502
- VP8_THREADS,
503
569
  "-auto-alt-ref",
504
570
  "0",
505
571
  "-metadata:s:v:0",
@@ -533,7 +599,12 @@ async function runFfmpeg(sourcePath, outputPath, chunkPrefix, variant, box, sign
533
599
  const alphaDecoder = keepsAlpha ? alphaDecoderFor(metadata.color.codecName) : null;
534
600
  const decoderArgs = alphaDecoder ? ["-c:v", alphaDecoder] : [];
535
601
  const totalSeconds = metadata.durationSeconds;
536
- const encode = (target, range) => spawnFfmpeg(
602
+ const gate = keepsAlpha ? ALPHA_PIECE_GATE : UNGATED;
603
+ const vp8Threads = (pieces) => [
604
+ "-threads",
605
+ String(Math.min(4, Math.floor(CORES / pieces)))
606
+ ];
607
+ const encode = (target, range, pieceSignal, onEncoded) => spawnFfmpeg(
537
608
  ffmpegPath,
538
609
  [
539
610
  "-y",
@@ -547,21 +618,31 @@ async function runFfmpeg(sourcePath, outputPath, chunkPrefix, variant, box, sign
547
618
  ...range ? ["-t", String(range.lengthSeconds)] : [],
548
619
  "-vf",
549
620
  videoFilter,
550
- ...variant === "vp8" ? VP8_ARGS : H264_ARGS,
621
+ ...KEYFRAME_EVERY_SECOND_ARGS,
622
+ ...keepsAlpha ? [...VP8_ARGS, ...vp8Threads(range ? gate.size : 1)] : H264_ARGS,
551
623
  target
552
624
  ],
553
625
  TRANSCODE_STALL_MS,
554
- signal,
555
- (encodedSeconds) => onProgress({ doneSeconds: (range?.startSeconds ?? 0) + encodedSeconds, totalSeconds })
626
+ pieceSignal,
627
+ onEncoded
556
628
  );
557
629
  const ranges = chunkRanges(totalSeconds);
558
- if (!ranges) return encode(outputPath);
630
+ if (!ranges) {
631
+ return encode(
632
+ outputPath,
633
+ void 0,
634
+ signal,
635
+ (encodedSeconds) => onProgress({ doneSeconds: encodedSeconds, totalSeconds })
636
+ );
637
+ }
559
638
  await encodeInChunks(
560
639
  ranges,
561
640
  chunkPrefix,
562
641
  outputPath,
563
642
  encode,
564
643
  (listPath, target) => spawnFfmpeg(ffmpegPath, concatArgs(listPath, target, variant), CONCAT_TIMEOUT_MS, signal),
644
+ signal,
645
+ gate,
565
646
  (doneSeconds) => onProgress({ doneSeconds, totalSeconds })
566
647
  );
567
648
  }
@@ -13,6 +13,9 @@ import {
13
13
  isUnresolvedAssetPlaceholder,
14
14
  resolveExistingLocalAsset as resolveAsset
15
15
  } from "@hyperframes/parsers/asset-resolution";
16
+ import {
17
+ isHeavyForPreview
18
+ } from "@hyperframes/core";
16
19
 
17
20
  // src/helpers/mediaMetadata.ts
18
21
  import { execFile } from "child_process";
@@ -106,6 +109,22 @@ async function probeFirstFrameColour(filePath, runner = execFileRunner) {
106
109
  });
107
110
  return result.status === 0 ? parseFirstFrameColour(String(result.stderr)) : {};
108
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
+ }
109
128
  async function probeMediaMetadata(filePath, runner = execFileRunner) {
110
129
  const kind = inferKindFromPath(filePath);
111
130
  if (kind === "audio" || kind === "unknown") {
@@ -121,7 +140,7 @@ async function probeMediaMetadata(filePath, runner = execFileRunner) {
121
140
  "-v",
122
141
  "error",
123
142
  "-show_entries",
124
- "stream=codec_type,codec_name,profile,pix_fmt,color_space,color_transfer,color_primaries,bits_per_raw_sample:stream_disposition=attached_pic:stream_tags=alpha_mode:format=duration",
143
+ "stream=codec_type,codec_name,profile,pix_fmt,color_space,color_transfer,color_primaries,bits_per_raw_sample,width,height,bit_rate:stream_disposition=attached_pic:stream_tags=alpha_mode:format=duration,bit_rate",
125
144
  "-of",
126
145
  "json",
127
146
  "--",
@@ -147,6 +166,7 @@ async function probeMediaMetadata(filePath, runner = execFileRunner) {
147
166
  metadata.hasAlpha = stream !== void 0 && videoStreamHasAlpha(stream);
148
167
  const duration = Number(parsed.format?.duration);
149
168
  if (Number.isFinite(duration) && duration > 0) metadata.durationSeconds = duration;
169
+ metadata.source = sourceFactsOf(stream, parsed.format?.bit_rate);
150
170
  }
151
171
  return metadata;
152
172
  } catch {
@@ -219,18 +239,22 @@ function resolveProxyVariantRequest(request, facts) {
219
239
  const expected = proxyVariantFor(facts);
220
240
  return request === "auto" || request === expected ? expected : null;
221
241
  }
222
- function decideMediaProxyEligibility(facts) {
242
+ function decideMediaProxyEligibility(facts, lightBox) {
223
243
  if (!facts) return { eligible: false, reason: "unknown_codec" };
224
- if (!facts.browserHostile) return { eligible: false, reason: "browser_safe_codec" };
225
- return { eligible: true };
244
+ if (facts.browserHostile) return { eligible: true };
245
+ if (lightBox && facts.source && isHeavyForPreview(facts.source, lightBox)) {
246
+ return { eligible: true };
247
+ }
248
+ return { eligible: false, reason: "browser_safe_codec" };
226
249
  }
227
- function codecFactsFor(codecName, hasAlpha) {
250
+ function codecFactsFor(codecName, hasAlpha, source) {
228
251
  const hostile = hostileCodecEntry(codecName);
229
252
  return {
230
253
  codecName,
231
254
  browserHostile: hostile !== void 0,
232
255
  representativeMime: hostile?.representativeMime ?? null,
233
- hasAlpha
256
+ hasAlpha,
257
+ ...source && { source }
234
258
  };
235
259
  }
236
260
  async function probeCodecFacts(filePath, runner) {
@@ -239,7 +263,7 @@ async function probeCodecFacts(filePath, runner) {
239
263
  if (metadata.kind !== "video") return null;
240
264
  const codecName = metadata.color.codecName;
241
265
  if (!codecName) return null;
242
- return codecFactsFor(codecName, metadata.hasAlpha === true);
266
+ return codecFactsFor(codecName, metadata.hasAlpha === true, metadata.source);
243
267
  }
244
268
  function createMediaCodecProbeCache() {
245
269
  return /* @__PURE__ */ new Map();
@@ -301,9 +325,8 @@ function collectLocalVideoAssets(projectDir, htmlSources) {
301
325
  return candidates;
302
326
  }
303
327
  var PROBE_CONCURRENCY = 8;
304
- async function scanProjectMediaCodecMap(projectDir, htmlSources, options = {}) {
328
+ async function probeProjectAssets(projectDir, htmlSources, options) {
305
329
  const candidates = collectLocalVideoAssets(projectDir, htmlSources);
306
- if (candidates.size === 0) return {};
307
330
  const cache = options.cache ?? defaultProbeCache;
308
331
  const entries = [...candidates.entries()];
309
332
  const facts = new Array(entries.length).fill(null);
@@ -319,12 +342,18 @@ async function scanProjectMediaCodecMap(projectDir, htmlSources, options = {}) {
319
342
  }
320
343
  })
321
344
  );
322
- const map = {};
323
- entries.forEach(([, pathname], index) => {
324
- const entryFacts = facts[index];
325
- if (entryFacts?.browserHostile) map[pathname] = entryFacts;
326
- });
327
- return map;
345
+ return entries.map(([, pathname], index) => [pathname, facts[index] ?? null]);
346
+ }
347
+ async function scanProjectMedia(projectDir, htmlSources, options = {}) {
348
+ const scan = { codecMap: {}, sourceFacts: {} };
349
+ for (const [pathname, facts] of await probeProjectAssets(projectDir, htmlSources, options)) {
350
+ if (facts?.browserHostile) scan.codecMap[pathname] = facts;
351
+ if (facts?.source) scan.sourceFacts[pathname] = facts.source;
352
+ }
353
+ return scan;
354
+ }
355
+ async function scanProjectMediaCodecMap(projectDir, htmlSources, options = {}) {
356
+ return (await scanProjectMedia(projectDir, htmlSources, options)).codecMap;
328
357
  }
329
358
 
330
359
  export {
@@ -343,5 +372,6 @@ export {
343
372
  decideMediaProxyEligibility,
344
373
  createMediaCodecProbeCache,
345
374
  probeAssetCodec,
375
+ scanProjectMedia,
346
376
  scanProjectMediaCodecMap
347
377
  };
@@ -1,14 +1,14 @@
1
1
  import {
2
2
  PROXY_PARAMS_VERSION,
3
3
  resolveProxy
4
- } from "./chunk-URMIRXBS.js";
4
+ } from "./chunk-5UEDEOD2.js";
5
5
  import {
6
6
  createMediaCodecProbeCache,
7
7
  proxyVariantFor,
8
8
  recordProxyPrewarm,
9
- scanProjectMediaCodecMap,
9
+ scanProjectMedia,
10
10
  shouldPrewarmProxy
11
- } from "./chunk-XMNHDWQI.js";
11
+ } from "./chunk-C2RFW2FX.js";
12
12
 
13
13
  // src/helpers/mediaProxyPreview.ts
14
14
  import { resolve } from "path";
@@ -25,17 +25,15 @@ function proxyEtagSalt(raw, box) {
25
25
  return `:proxy:${raw}:${PROXY_PARAMS_VERSION}${box ? `:${formatPreviewProxyBox(box)}` : ""}`;
26
26
  }
27
27
  async function injectMediaCodecMapIntoHtml(html, projectDir, htmlSources, probeCache, prewarm = true, signal, box) {
28
- let map;
28
+ let scan;
29
29
  try {
30
- map = await scanProjectMediaCodecMap(
31
- projectDir,
32
- htmlSources,
33
- probeCache ? { cache: probeCache } : {}
34
- );
30
+ scan = await scanProjectMedia(projectDir, htmlSources, probeCache ? { cache: probeCache } : {});
35
31
  } catch {
36
32
  return html;
37
33
  }
38
- if (Object.keys(map).length === 0) return html;
34
+ const { codecMap: map, sourceFacts } = scan;
35
+ const hasMap = Object.keys(map).length > 0;
36
+ if (!hasMap && Object.keys(sourceFacts).length === 0) return html;
39
37
  for (const [rootRelativePathname, facts] of Object.entries(map)) {
40
38
  if (!prewarm || !shouldPrewarmProxy(facts)) continue;
41
39
  recordProxyPrewarm();
@@ -48,8 +46,12 @@ async function injectMediaCodecMapIntoHtml(html, projectDir, htmlSources, probeC
48
46
  ).catch(() => {
49
47
  });
50
48
  }
51
- const json = JSON.stringify(map).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
52
- const tag = `<script data-hf-media-codec-map>window.__HF_MEDIA_CODEC_MAP__=${json};</script>`;
49
+ const safeJson = (value) => JSON.stringify(value).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
50
+ const assignments = [
51
+ ...hasMap ? [`window.__HF_MEDIA_CODEC_MAP__=${safeJson(map)};`] : [],
52
+ `window.__HF_MEDIA_SOURCE_FACTS__=${safeJson(sourceFacts)};`
53
+ ];
54
+ const tag = `<script data-hf-media-codec-map>${assignments.join("")}</script>`;
53
55
  return insertBeforeCloseTag(html, "head", `${tag}
54
56
  `) ?? `${tag}
55
57
  ${html}`;
@@ -106,6 +106,7 @@ import { isAbsolute, relative, resolve, sep } from "path";
106
106
  import { replaceFileAtomically } from "@hyperframes/core/atomic-file";
107
107
 
108
108
  // src/history/ownerLock.ts
109
+ import { spawnSync } from "child_process";
109
110
  import { randomUUID as randomUUID2 } from "crypto";
110
111
  import {
111
112
  linkSync,
@@ -116,6 +117,7 @@ import {
116
117
  statSync,
117
118
  writeFileSync
118
119
  } from "fs";
120
+ import { uptime } from "os";
119
121
  import { join } from "path";
120
122
  var HistoryBusyError = class extends Error {
121
123
  constructor(pid, url) {
@@ -130,6 +132,7 @@ var HistoryBusyError = class extends Error {
130
132
  var WINDOWS_BUSY = /* @__PURE__ */ new Set(["EPERM", "EBUSY", "EACCES"]);
131
133
  var NO_HARD_LINKS = /* @__PURE__ */ new Set(["ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM"]);
132
134
  var TAKING_MS = 2e3;
135
+ var BOOT_SLACK_MS = 6e4;
133
136
  function busyOnWindows(error) {
134
137
  return process.platform === "win32" && WINDOWS_BUSY.has(error.code ?? "");
135
138
  }
@@ -142,6 +145,48 @@ function alive(pid) {
142
145
  return error.code === "EPERM";
143
146
  }
144
147
  }
148
+ function processStart(pid) {
149
+ try {
150
+ if (process.platform === "linux") {
151
+ const stat4 = readFileSync(`/proc/${pid}/stat`, "utf-8");
152
+ return stat4.slice(stat4.lastIndexOf(")") + 2).split(" ")[19] ?? null;
153
+ }
154
+ if (process.platform === "win32") return null;
155
+ const ps = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], {
156
+ encoding: "utf-8",
157
+ env: { ...process.env, LC_ALL: "C" }
158
+ });
159
+ return ps.status === 0 ? ps.stdout.trim() || null : null;
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+ function recordedStart(file, pid) {
165
+ try {
166
+ const [owner, ...start] = readFileSync(`${file}.start`, "utf-8").split("\n");
167
+ return Number(owner) === pid && start.length > 0 ? start.join("\n") : null;
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+ function recordStart(file) {
173
+ const start = processStart(process.pid);
174
+ if (start) writeFileSync(`${file}.start`, `${process.pid}
175
+ ${start}`);
176
+ }
177
+ function holdsLock(file, pid) {
178
+ if (!alive(pid)) return false;
179
+ const recorded = recordedStart(file, pid);
180
+ if (recorded !== null) {
181
+ const now = processStart(pid);
182
+ return now === null || now === recorded;
183
+ }
184
+ try {
185
+ return statSync(file).mtimeMs > Date.now() - uptime() * 1e3 - BOOT_SLACK_MS;
186
+ } catch {
187
+ return true;
188
+ }
189
+ }
145
190
  function ownerOf(file) {
146
191
  try {
147
192
  const text = readFileSync(file, "utf-8");
@@ -160,13 +205,14 @@ function beingTaken(file) {
160
205
  }
161
206
  }
162
207
  function deadHolder(file, pid) {
163
- return !alive(pid) && !beingTaken(file);
208
+ return !holdsLock(file, pid) && !beingTaken(file);
164
209
  }
165
210
  function claim(file) {
166
211
  const draft = `${file}-${process.pid}-${randomUUID2()}.tmp`;
167
212
  writeFileSync(draft, String(process.pid));
168
213
  try {
169
214
  linkSync(draft, file);
215
+ recordStart(file);
170
216
  return true;
171
217
  } catch (error) {
172
218
  const code = error.code ?? "";
@@ -180,6 +226,7 @@ function claim(file) {
180
226
  function createExclusively(file) {
181
227
  try {
182
228
  writeFileSync(file, String(process.pid), { flag: "wx" });
229
+ recordStart(file);
183
230
  return true;
184
231
  } catch (error) {
185
232
  if (error.code !== "EEXIST") throw error;
@@ -187,7 +234,9 @@ function createExclusively(file) {
187
234
  }
188
235
  }
189
236
  function releaseOwn(file) {
190
- if (ownerOf(file) === process.pid) rmSync(file, { force: true });
237
+ if (ownerOf(file) !== process.pid) return;
238
+ rmSync(file, { force: true });
239
+ rmSync(`${file}.start`, { force: true });
191
240
  }
192
241
  function evictDeadOwner(file) {
193
242
  const evictor = `${file}.evict`;
@@ -198,7 +247,10 @@ function evictDeadOwner(file) {
198
247
  }
199
248
  try {
200
249
  const pid = ownerOf(file);
201
- if (pid !== null && deadHolder(file, pid)) rmSync(file, { force: true });
250
+ if (pid !== null && deadHolder(file, pid)) {
251
+ rmSync(file, { force: true });
252
+ rmSync(`${file}.start`, { force: true });
253
+ }
202
254
  return true;
203
255
  } finally {
204
256
  releaseOwn(evictor);
@@ -213,7 +265,7 @@ function advertise(urlFile, url) {
213
265
  function servedBy(file, urlFile, pid) {
214
266
  try {
215
267
  const said = JSON.parse(readFileSync(urlFile, "utf-8"));
216
- const live = said.pid === pid && alive(pid) && ownerOf(file) === pid;
268
+ const live = said.pid === pid && holdsLock(file, pid) && ownerOf(file) === pid;
217
269
  return live && typeof said.url === "string" ? said.url : void 0;
218
270
  } catch {
219
271
  return void 0;
@@ -1,3 +1,5 @@
1
+ import { PreviewSourceFacts, PreviewProxyBox } from '@hyperframes/core';
2
+
1
3
  interface FfprobeRunResult {
2
4
  status: number | null;
3
5
  stdout: string | Buffer;
@@ -31,6 +33,8 @@ interface AssetCodecFacts {
31
33
  /** Source carries an alpha channel (`MediaMetadata.hasAlpha`). Alpha sources use a
32
34
  * VP8/WebM proxy so their transparency is preserved across Chromium builds. */
33
35
  hasAlpha: boolean;
36
+ /** Size, bit depth and bitrate, for deciding whether a playable file deserves a lighter copy. */
37
+ source?: PreviewSourceFacts;
34
38
  }
35
39
  /** Server-root-relative URL pathname -> that asset's codec facts. */
36
40
  type MediaCodecMap = Record<string, AssetCodecFacts>;
@@ -96,8 +100,11 @@ type MediaProxyEligibility = {
96
100
  eligible: false;
97
101
  reason: MediaProxyIneligibilityReason;
98
102
  };
99
- /** Single policy gate shared by proactive scans and on-demand proxy routes. */
100
- declare function decideMediaProxyEligibility(facts: AssetCodecFacts | null): MediaProxyEligibility;
103
+ /**
104
+ * Single policy gate shared by proactive scans and on-demand proxy routes. A file Chrome can play is
105
+ * eligible only for a lighter copy (`lightBox`, the box that request asks for) and only when it is heavy there.
106
+ */
107
+ declare function decideMediaProxyEligibility(facts: AssetCodecFacts | null, lightBox?: PreviewProxyBox): MediaProxyEligibility;
101
108
  interface CachedAssetProbe {
102
109
  /** The file a symlinked path pointed at, so retargeting the link is a miss. */
103
110
  target: string;
@@ -126,12 +133,15 @@ interface ScanProjectMediaCodecMapOptions {
126
133
  cache?: MediaCodecProbeCache;
127
134
  runner?: FfprobeRunner;
128
135
  }
129
- /**
130
- * Scans a project's composition HTML for local `<video src>` references and
131
- * returns the injection map: root-relative URL pathname -> codec facts.
132
- * Best-effort throughout — a video whose codec can't be determined (missing
133
- * ffprobe, probe error, no video stream) is simply omitted, never thrown.
134
- */
136
+ /** What a scan of a project's local videos finds: the hostile ones for the proxy map, and every readable one's size. */
137
+ interface ProjectMediaScan {
138
+ codecMap: MediaCodecMap;
139
+ /** Root-relative URL pathname -> size, bit depth and bitrate. */
140
+ sourceFacts: Record<string, PreviewSourceFacts>;
141
+ }
142
+ /** Scans composition HTML for local `<video src>` references; a video that can't be probed is omitted. */
143
+ declare function scanProjectMedia(projectDir: string, htmlSources: HtmlSourceLike[], options?: ScanProjectMediaCodecMapOptions): Promise<ProjectMediaScan>;
144
+ /** The injection map alone: root-relative URL pathname -> codec facts of the hostile videos. */
135
145
  declare function scanProjectMediaCodecMap(projectDir: string, htmlSources: HtmlSourceLike[], options?: ScanProjectMediaCodecMapOptions): Promise<MediaCodecMap>;
136
146
 
137
- export { type AssetCodecFacts, BROWSER_HOSTILE_CODECS, type BrowserHostileCodec, type HtmlSourceLike, type MediaCodecMap, type MediaCodecProbeCache, type MediaProxyEligibility, type MediaProxyIneligibilityReason, PROXY_VARIANT_CONFIG, type ProxyVariant, type ProxyVariantRequest, type ScanProjectMediaCodecMapOptions, createMediaCodecProbeCache, decideMediaProxyEligibility, isProxyVariant, isProxyVariantRequest, mediaProxyDemand, probeAssetCodec, proxyVariantFor, recordProxyPrewarm, recordProxyRequest, resolveProxyVariantRequest, scanProjectMediaCodecMap, shouldPrewarmProxy };
147
+ export { type AssetCodecFacts, BROWSER_HOSTILE_CODECS, type BrowserHostileCodec, type HtmlSourceLike, type MediaCodecMap, type MediaCodecProbeCache, type MediaProxyEligibility, type MediaProxyIneligibilityReason, PROXY_VARIANT_CONFIG, type ProjectMediaScan, type ProxyVariant, type ProxyVariantRequest, type ScanProjectMediaCodecMapOptions, createMediaCodecProbeCache, decideMediaProxyEligibility, isProxyVariant, isProxyVariantRequest, mediaProxyDemand, probeAssetCodec, proxyVariantFor, recordProxyPrewarm, recordProxyRequest, resolveProxyVariantRequest, scanProjectMedia, scanProjectMediaCodecMap, shouldPrewarmProxy };
@@ -11,9 +11,10 @@ import {
11
11
  recordProxyPrewarm,
12
12
  recordProxyRequest,
13
13
  resolveProxyVariantRequest,
14
+ scanProjectMedia,
14
15
  scanProjectMediaCodecMap,
15
16
  shouldPrewarmProxy
16
- } from "../chunk-XMNHDWQI.js";
17
+ } from "../chunk-C2RFW2FX.js";
17
18
  import "../chunk-QF7MFJJU.js";
18
19
  import "../chunk-SVQFE44C.js";
19
20
  import "../chunk-L3HJVTQE.js";
@@ -30,6 +31,7 @@ export {
30
31
  recordProxyPrewarm,
31
32
  recordProxyRequest,
32
33
  resolveProxyVariantRequest,
34
+ scanProjectMedia,
33
35
  scanProjectMediaCodecMap,
34
36
  shouldPrewarmProxy
35
37
  };
@@ -4,9 +4,9 @@ import {
4
4
  isAutoProxyEnabled,
5
5
  proxyEtagSalt,
6
6
  resolvePreviewMediaCodecProbeCache
7
- } from "../chunk-ZCCJNXS7.js";
8
- import "../chunk-URMIRXBS.js";
9
- import "../chunk-XMNHDWQI.js";
7
+ } from "../chunk-HZCA2O76.js";
8
+ import "../chunk-5UEDEOD2.js";
9
+ import "../chunk-C2RFW2FX.js";
10
10
  import "../chunk-QF7MFJJU.js";
11
11
  import "../chunk-SVQFE44C.js";
12
12
  import "../chunk-L3HJVTQE.js";
@@ -11,7 +11,7 @@ import { ProxyVariant } from './mediaCodecMap.js';
11
11
  * caller's `signal` detaches that caller; once no caller is left, the copy is
12
12
  * dropped from the queue or its ffmpeg is killed.
13
13
  */
14
- declare const PROXY_PARAMS_VERSION = "v6";
14
+ declare const PROXY_PARAMS_VERSION = "v7";
15
15
  declare const CACHE_DIR_NAME = ".transcode-cache";
16
16
  /** A copy that makes no progress this long is ended: a hung encode, not a slow one. */
17
17
  declare const TRANSCODE_STALL_MS: number;
@@ -17,8 +17,8 @@ import {
17
17
  resolveProxies,
18
18
  resolveProxy,
19
19
  waitForProxy
20
- } from "../chunk-URMIRXBS.js";
21
- import "../chunk-XMNHDWQI.js";
20
+ } from "../chunk-5UEDEOD2.js";
21
+ import "../chunk-C2RFW2FX.js";
22
22
  import "../chunk-QF7MFJJU.js";
23
23
  import "../chunk-SVQFE44C.js";
24
24
  import "../chunk-L3HJVTQE.js";
@@ -22,7 +22,7 @@ import {
22
22
  recordRenderSuccess,
23
23
  registerRenderOutput,
24
24
  retainSnapshotOutput
25
- } from "../chunk-VKQTZKGA.js";
25
+ } from "../chunk-OSZE5WT3.js";
26
26
  import "../chunk-QF7MFJJU.js";
27
27
  import "../chunk-SVQFE44C.js";
28
28
  import "../chunk-L3HJVTQE.js";
package/dist/index.d.ts CHANGED
@@ -93,7 +93,7 @@ interface DuplicateElementResult {
93
93
  matched: boolean;
94
94
  newId: string | null;
95
95
  }
96
- declare function duplicateElementInHtml(source: string, target: SourceMutationTarget, newId: string, at: number): DuplicateElementResult;
96
+ declare function duplicateElementInHtml(source: string, target: SourceMutationTarget, newId: string, at: number, fallbackDuration?: number): DuplicateElementResult;
97
97
 
98
98
  interface FileMutationInput {
99
99
  sourceFile: string;
package/dist/index.js CHANGED
@@ -62,7 +62,7 @@ import {
62
62
  resolveProjectSignature,
63
63
  retainSnapshotOutput,
64
64
  settledFileTag
65
- } from "./chunk-VKQTZKGA.js";
65
+ } from "./chunk-OSZE5WT3.js";
66
66
  import {
67
67
  clearElementScreenshotIsolation,
68
68
  getElementScreenshotClip
@@ -82,7 +82,7 @@ import {
82
82
  isAutoProxyEnabled,
83
83
  proxyEtagSalt,
84
84
  resolvePreviewMediaCodecProbeCache
85
- } from "./chunk-ZCCJNXS7.js";
85
+ } from "./chunk-HZCA2O76.js";
86
86
  import {
87
87
  CACHE_DIR_NAME,
88
88
  PROXY_PENDING_RETRY_AFTER_SECONDS,
@@ -93,7 +93,7 @@ import {
93
93
  proxyActivityMark,
94
94
  resolveProxy,
95
95
  waitForProxy
96
- } from "./chunk-URMIRXBS.js";
96
+ } from "./chunk-5UEDEOD2.js";
97
97
  import {
98
98
  PROXY_VARIANT_CONFIG,
99
99
  decideMediaProxyEligibility,
@@ -102,7 +102,7 @@ import {
102
102
  probeMediaMetadata,
103
103
  recordProxyRequest,
104
104
  resolveProxyVariantRequest
105
- } from "./chunk-XMNHDWQI.js";
105
+ } from "./chunk-C2RFW2FX.js";
106
106
  import {
107
107
  folderGone,
108
108
  isInHiddenOrVendorDir,
@@ -3885,7 +3885,11 @@ import {
3885
3885
  STUDIO_PREVIEW_MARK_META,
3886
3886
  STUDIO_PREVIEW_ERRORS
3887
3887
  } from "@hyperframes/core/studio-preview-mark";
3888
- import { PREVIEW_PROXY_BOX_PARAM, parsePreviewProxyBox } from "@hyperframes/core";
3888
+ import {
3889
+ PREVIEW_PROXY_BOX_PARAM,
3890
+ PREVIEW_PROXY_LIGHT_PARAM,
3891
+ parsePreviewProxyBox
3892
+ } from "@hyperframes/core";
3889
3893
  import {
3890
3894
  GSAP_BASE_URL_META,
3891
3895
  gsapCdnDist as gsapCdnDist2,
@@ -4502,7 +4506,8 @@ ${etag}`;
4502
4506
  const proxyParam = c.req.query("hf-proxy");
4503
4507
  const boxParam = c.req.query(PREVIEW_PROXY_BOX_PARAM);
4504
4508
  const proxyBox = boxParam === void 0 ? void 0 : parsePreviewProxyBox(boxParam);
4505
- if (proxyBox === null || proxyBox && proxyParam === void 0) {
4509
+ const lightRequested = c.req.query(PREVIEW_PROXY_LIGHT_PARAM) !== void 0;
4510
+ if (proxyBox === null || proxyBox && proxyParam === void 0 || lightRequested && !proxyBox) {
4506
4511
  return c.text("not found", 404);
4507
4512
  }
4508
4513
  let proxyVariant;
@@ -4511,7 +4516,7 @@ ${etag}`;
4511
4516
  return c.text("not found", 404);
4512
4517
  }
4513
4518
  const facts = await probeAssetCodec(file, void 0, mediaCodecProbeCache);
4514
- const eligibility = decideMediaProxyEligibility(facts);
4519
+ const eligibility = decideMediaProxyEligibility(facts, lightRequested ? proxyBox : void 0);
4515
4520
  if (!eligibility.eligible) {
4516
4521
  return c.text(`media proxy unavailable: ${eligibility.reason}`, 422);
4517
4522
  }
@@ -6648,9 +6653,29 @@ function registerHistoryRoutes(api, adapter) {
6648
6653
  );
6649
6654
  }
6650
6655
 
6656
+ // src/helpers/previewErrorPage.ts
6657
+ import { STUDIO_PREVIEW_ERRORS as STUDIO_PREVIEW_ERRORS2 } from "@hyperframes/core/studio-preview-mark";
6658
+ function previewErrorPage(status) {
6659
+ const message = `The preview page could not be loaded (HTTP ${status}).`;
6660
+ return `<!doctype html><meta charset="utf-8"><script>window.${STUDIO_PREVIEW_ERRORS2}=[${JSON.stringify(message)}]</script><p>${message}</p>`;
6661
+ }
6662
+
6651
6663
  // src/createStudioApi.ts
6664
+ async function answerRefusedFrameWithPage(c, next) {
6665
+ await next();
6666
+ if (c.req.header("sec-fetch-dest") !== "iframe" || c.res.status < 400) return;
6667
+ const headers = new Headers(c.res.headers);
6668
+ headers.set("content-type", "text/html; charset=utf-8");
6669
+ headers.delete("content-length");
6670
+ c.res = new Response(previewErrorPage(c.res.status), {
6671
+ status: c.res.status,
6672
+ headers
6673
+ });
6674
+ }
6652
6675
  function createStudioApi(adapter) {
6653
6676
  const api = new Hono();
6677
+ api.use("/projects/:id/preview/*", answerRefusedFrameWithPage);
6678
+ api.use("/projects/:id/preview", answerRefusedFrameWithPage);
6654
6679
  api.use(async function answerProjectDirMissingAfterErrorHandlers(c, next) {
6655
6680
  const hostHeaders = new Headers(c.res.headers);
6656
6681
  await next();
@@ -6701,13 +6726,14 @@ function createStudioApi(adapter) {
6701
6726
  // src/helpers/duplicateElement.ts
6702
6727
  import { sameInstant } from "@hyperframes/core/clip-facts";
6703
6728
  import { ensureHfIds as ensureHfIds4 } from "@hyperframes/parsers/hf-ids";
6704
- function duplicateElementInHtml(source, target, newId, at) {
6729
+ function duplicateElementInHtml(source, target, newId, at, fallbackDuration = 0) {
6705
6730
  const { document, wrappedFragment } = parseSourceDocument(source);
6706
6731
  const element = findTargetElement(document, target);
6707
6732
  if (!element || !element.parentElement) return { html: source, matched: false, newId: null };
6708
- const duration = numericAttribute(element, "data-duration");
6733
+ const duration = element.hasAttribute("data-duration") ? numericAttribute(element, "data-duration") : fallbackDuration;
6734
+ const start = numericAttribute(element, "data-start");
6709
6735
  const track = numericAttribute(element, "data-track-index") ?? 0;
6710
- if (duration === null || numericAttribute(element, "data-start") === null) {
6736
+ if (duration === null || start === null) {
6711
6737
  return { html: source, matched: false, newId: null };
6712
6738
  }
6713
6739
  const uniqueId = nextUniqueId(document, newId);
@@ -6717,13 +6743,13 @@ function duplicateElementInHtml(source, target, newId, at) {
6717
6743
  clone.setAttribute("id", uniqueId);
6718
6744
  dedupeClonedCompositionId(document, clone);
6719
6745
  stripStableIds(clone);
6720
- clone.setAttribute("data-start", String(at));
6746
+ moveElementStart(clone, start, at);
6721
6747
  element.parentElement.insertBefore(clone, element.nextSibling);
6722
6748
  const html = wrappedFragment ? document.body.innerHTML || "" : document.toString();
6723
6749
  return { html: ensureHfIds4(html), matched: true, newId: uniqueId };
6724
6750
  }
6725
6751
  function rippleElements(document, element, at, duration, track) {
6726
- for (const candidate of Array.from(document.querySelectorAll("[data-start][data-duration]"))) {
6752
+ for (const candidate of Array.from(document.querySelectorAll("[data-start]"))) {
6727
6753
  if (candidate === element || candidate.getAttribute("data-track-index") !== String(track)) {
6728
6754
  continue;
6729
6755
  }
@@ -6731,9 +6757,15 @@ function rippleElements(document, element, at, duration, track) {
6731
6757
  if (start === null) continue;
6732
6758
  const metInsertionPoint = sameInstant(start, at);
6733
6759
  if (start < at && !metInsertionPoint) continue;
6734
- candidate.setAttribute("data-start", String((metInsertionPoint ? at : start) + duration));
6760
+ moveElementStart(candidate, start, (metInsertionPoint ? at : start) + duration);
6735
6761
  }
6736
6762
  }
6763
+ function moveElementStart(element, start, nextStart) {
6764
+ element.setAttribute("data-start", String(nextStart));
6765
+ if (!element.hasAttribute("data-end")) return;
6766
+ const end = numericAttribute(element, "data-end");
6767
+ if (end !== null) element.setAttribute("data-end", String(end + (nextStart - start)));
6768
+ }
6737
6769
  function stripStableIds(element) {
6738
6770
  element.removeAttribute("data-hf-id");
6739
6771
  for (const child of Array.from(element.querySelectorAll("[data-hf-id]"))) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio-server",
3
- "version": "0.8.199",
3
+ "version": "0.8.201",
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/parsers": "0.8.199",
88
- "@hyperframes/core": "0.8.199"
87
+ "@hyperframes/core": "0.8.201",
88
+ "@hyperframes/parsers": "0.8.201"
89
89
  },
90
90
  "devDependencies": {
91
91
  "@types/node": "^25.0.10",