@hyperframes/studio-server 0.8.80 → 0.8.82

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.
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  splitElementInHtml,
14
14
  unwrapElementsFromHtml,
15
15
  wrapElementsInHtml
16
- } from "./chunk-P5EJPIH3.js";
16
+ } from "./chunk-W6TFBTP2.js";
17
17
  import {
18
18
  getElementScreenshotClip
19
19
  } from "./chunk-W2SBTCO2.js";
@@ -22,21 +22,28 @@ import {
22
22
  isAutoProxyEnabled,
23
23
  proxyEtagSalt,
24
24
  resolvePreviewMediaCodecProbeCache
25
- } from "./chunk-YGANSFUU.js";
25
+ } from "./chunk-FTDTO3QP.js";
26
26
  import {
27
27
  ProxyCapacityError,
28
28
  ProxyTranscodeError,
29
29
  resolveProxy
30
- } from "./chunk-56JREE33.js";
30
+ } from "./chunk-G6IIFKXY.js";
31
31
  import {
32
32
  PROXY_VARIANT_CONFIG,
33
33
  decideMediaProxyEligibility,
34
+ isInHiddenOrVendorDir,
34
35
  isProxyVariantRequest,
36
+ isSafePath,
37
+ pinWithinProject,
35
38
  probeAssetCodec,
36
39
  probeMediaMetadata,
40
+ realFilePath,
41
+ realpath,
37
42
  recordProxyRequest,
38
- resolveProxyVariantRequest
39
- } from "./chunk-KOB22VPG.js";
43
+ resolveProxyVariantRequest,
44
+ resolveWithinProject,
45
+ walkDir
46
+ } from "./chunk-TJQ76IEA.js";
40
47
  import {
41
48
  STUDIO_MANUAL_EDITS_PATH,
42
49
  createStudioManualEditsRenderBodyScript,
@@ -52,64 +59,7 @@ import { Hono } from "hono";
52
59
 
53
60
  // src/routes/projects.ts
54
61
  import { readFile } from "fs/promises";
55
- import { join as join3 } from "path";
56
-
57
- // src/helpers/safePath.ts
58
- import { basename, dirname, isAbsolute, join, relative, sep } from "path";
59
- import { readdirSync, realpathSync } from "fs";
60
- import { resolveWithinProject } from "@hyperframes/core";
61
- import { isSafePath, resolveWithinProject as resolveWithinProject2 } from "@hyperframes/core";
62
- function realFilePath(filePath) {
63
- try {
64
- return realpathSync(filePath);
65
- } catch {
66
- const dir = dirname(filePath);
67
- return dir === filePath ? filePath : join(realFilePath(dir), basename(filePath));
68
- }
69
- }
70
- function pinWithinProject(base, relativePath) {
71
- const checked = resolveWithinProject(base, relativePath);
72
- if (!checked) return null;
73
- const inside = relative(realFilePath(base), realFilePath(checked));
74
- const escapes = inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside);
75
- return escapes ? null : join(base, inside);
76
- }
77
- var IGNORE_DIRS = /* @__PURE__ */ new Set([
78
- ".thumbnails",
79
- ".transcode-cache",
80
- ".waveform-cache",
81
- "node_modules",
82
- ".git"
83
- ]);
84
- function shouldIgnoreDir(rel) {
85
- return rel === ".hyperframes/backup";
86
- }
87
- function isInHiddenOrVendorDir(relPath) {
88
- const segments = relPath.split("/");
89
- return segments.slice(0, -1).some((seg) => seg.startsWith(".") || seg === "node_modules");
90
- }
91
- var UNREADABLE_DIR_CODES = /* @__PURE__ */ new Set(["EACCES", "EPERM", "ENOENT", "ENOTDIR"]);
92
- function walkDir(dir, prefix = "") {
93
- let entries;
94
- try {
95
- entries = readdirSync(dir, { withFileTypes: true });
96
- } catch (err) {
97
- const code = err.code;
98
- if (!prefix || !code || !UNREADABLE_DIR_CODES.has(code)) throw err;
99
- return [];
100
- }
101
- const files = [];
102
- for (const entry of entries) {
103
- const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
104
- if (IGNORE_DIRS.has(entry.name) || shouldIgnoreDir(rel)) continue;
105
- if (entry.isDirectory()) {
106
- files.push(...walkDir(join(dir, entry.name), rel));
107
- } else {
108
- files.push(rel);
109
- }
110
- }
111
- return files;
112
- }
62
+ import { join as join2 } from "path";
113
63
 
114
64
  // src/helpers/hfIdPersist.ts
115
65
  import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
@@ -123,7 +73,7 @@ import {
123
73
  readFileSync,
124
74
  writeFileSync
125
75
  } from "fs";
126
- import { dirname as dirname2, join as join2 } from "path";
76
+ import { dirname, join } from "path";
127
77
 
128
78
  // src/helpers/atomicFile.ts
129
79
  import * as fs from "fs";
@@ -145,10 +95,10 @@ function replaceFileAtomically(filePath, content, mode, operations = fs) {
145
95
 
146
96
  // src/helpers/hfIdPersist.ts
147
97
  var isCompositionSource = (html) => /data-composition-id\s*=/.test(html);
148
- var STAMP_RECORD = join2(".hyperframes", "hf-ids-stamped.json");
98
+ var STAMP_RECORD = join(".hyperframes", "hf-ids-stamped.json");
149
99
  var contentHash = (text2) => createHash("sha1").update(text2).digest("base64url");
150
100
  function stampProjectHfIds(projectDir) {
151
- const recordPath = join2(projectDir, STAMP_RECORD);
101
+ const recordPath = join(projectDir, STAMP_RECORD);
152
102
  let last = {};
153
103
  try {
154
104
  last = JSON.parse(readFileSync(recordPath, "utf-8"));
@@ -157,7 +107,7 @@ function stampProjectHfIds(projectDir) {
157
107
  const next = {};
158
108
  for (const file of walkDir(projectDir)) {
159
109
  if (!file.endsWith(".html") || isInHiddenOrVendorDir(file)) continue;
160
- const absPath = join2(projectDir, file);
110
+ const absPath = join(projectDir, file);
161
111
  let html;
162
112
  try {
163
113
  html = readFileSync(absPath, "utf-8");
@@ -173,7 +123,7 @@ function stampProjectHfIds(projectDir) {
173
123
  next[file] = hash;
174
124
  }
175
125
  try {
176
- mkdirSync(dirname2(recordPath), { recursive: true });
126
+ mkdirSync(dirname(recordPath), { recursive: true });
177
127
  writeFileSync(recordPath, JSON.stringify(next));
178
128
  } catch {
179
129
  }
@@ -226,8 +176,8 @@ function stampFileHfIds(filePath) {
226
176
 
227
177
  // src/helpers/projectSignature.ts
228
178
  import { createHash as createHash2 } from "crypto";
229
- import { lstatSync, readFileSync as readFileSync2, readdirSync as readdirSync2 } from "fs";
230
- import { extname, isAbsolute as isAbsolute2, relative as relative2, resolve, sep as sep2 } from "path";
179
+ import { lstatSync, readFileSync as readFileSync2, readdirSync } from "fs";
180
+ import { extname, isAbsolute, relative, resolve, sep } from "path";
231
181
  var SIGNATURE_TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
232
182
  ".cjs",
233
183
  ".css",
@@ -262,18 +212,18 @@ var STUDIO_SIGNATURE_MANIFEST_PATHS = [
262
212
  ".hyperframes/studio-motion.json"
263
213
  ];
264
214
  function affectsProjectSignature(projectDir, changedPath) {
265
- const relativePath = relative2(resolve(projectDir), resolve(changedPath));
266
- if (relativePath === "" || relativePath.startsWith("..") || isAbsolute2(relativePath)) {
215
+ const relativePath = relative(resolve(projectDir), resolve(changedPath));
216
+ if (relativePath === "" || relativePath.startsWith("..") || isAbsolute(relativePath)) {
267
217
  return false;
268
218
  }
269
- const segments = relativePath.split(sep2);
219
+ const segments = relativePath.split(sep);
270
220
  if (STUDIO_SIGNATURE_MANIFEST_PATHS.includes(segments.join("/"))) return true;
271
221
  return !segments.some((segment) => SIGNATURE_EXCLUDED_DIRS.has(segment));
272
222
  }
273
223
  var projectSignatureCache = /* @__PURE__ */ new Map();
274
224
  function isPathWithin(parentDir, childPath) {
275
- const childRelativePath = relative2(parentDir, childPath);
276
- return childRelativePath === "" || !childRelativePath.startsWith("..") && !isAbsolute2(childRelativePath);
225
+ const childRelativePath = relative(parentDir, childPath);
226
+ return childRelativePath === "" || !childRelativePath.startsWith("..") && !isAbsolute(childRelativePath);
277
227
  }
278
228
  function isTextContentEligible(file, size) {
279
229
  return SIGNATURE_TEXT_EXTENSIONS.has(extname(file).toLowerCase()) && size <= MAX_SIGNATURE_TEXT_BYTES;
@@ -281,7 +231,7 @@ function isTextContentEligible(file, size) {
281
231
  function collectProjectSignatureFiles(projectDir, dir, files) {
282
232
  let entries;
283
233
  try {
284
- entries = readdirSync2(dir).sort();
234
+ entries = readdirSync(dir).sort();
285
235
  } catch {
286
236
  return;
287
237
  }
@@ -334,7 +284,7 @@ function collectProjectSignatureManifestFiles(projectDir, files) {
334
284
  function createProjectFingerprint(projectDir, files) {
335
285
  const hash = createHash2("sha256");
336
286
  for (const entry of files) {
337
- hash.update(relative2(projectDir, entry.file));
287
+ hash.update(relative(projectDir, entry.file));
338
288
  hash.update("\0");
339
289
  hash.update(String(entry.size));
340
290
  hash.update("\0");
@@ -356,7 +306,7 @@ function collectProjectFiles(normalizedProjectDir) {
356
306
  function listProjectFiles(projectDir) {
357
307
  const normalizedProjectDir = resolve(projectDir);
358
308
  return collectProjectFiles(normalizedProjectDir).map((entry) => ({
359
- path: relative2(normalizedProjectDir, entry.file).split(sep2).join("/"),
309
+ path: relative(normalizedProjectDir, entry.file).split(sep).join("/"),
360
310
  size: entry.size,
361
311
  mtimeMs: entry.mtimeMs,
362
312
  ctimeMs: entry.ctimeMs
@@ -374,7 +324,7 @@ function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set(
374
324
  const normalizedProjectDir = resolve(projectDir);
375
325
  const collected = collectProjectFiles(normalizedProjectDir);
376
326
  const files = collected.filter(
377
- (entry) => !excluding.has(relative2(normalizedProjectDir, entry.file).split(sep2).join("/"))
327
+ (entry) => !excluding.has(relative(normalizedProjectDir, entry.file).split(sep).join("/"))
378
328
  );
379
329
  files.sort((a, b) => a.file.localeCompare(b.file));
380
330
  const fingerprint = createProjectFingerprint(normalizedProjectDir, files);
@@ -383,7 +333,7 @@ function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set(
383
333
  if (cached?.fingerprint === fingerprint) return cached.signature;
384
334
  const hash = createHash2("sha256");
385
335
  for (const entry of files) {
386
- const relativePath = relative2(normalizedProjectDir, entry.file);
336
+ const relativePath = relative(normalizedProjectDir, entry.file);
387
337
  hash.update(relativePath);
388
338
  hash.update("\0");
389
339
  hash.update(String(entry.size));
@@ -410,7 +360,7 @@ async function filterCompositionFiles(projectDir, files) {
410
360
  const checks = await Promise.all(
411
361
  htmlFiles.map(async (f) => {
412
362
  try {
413
- const content = await readFile(join3(projectDir, f), "utf-8");
363
+ const content = await readFile(join2(projectDir, f), "utf-8");
414
364
  return isCompositionSource(content);
415
365
  } catch {
416
366
  return false;
@@ -464,9 +414,9 @@ import {
464
414
  statSync as statSync3,
465
415
  fstatSync as fstatSync2,
466
416
  renameSync as renameSync2,
467
- readdirSync as readdirSync4
417
+ readdirSync as readdirSync3
468
418
  } from "fs";
469
- import { resolve as resolve3, dirname as dirname5, join as join7 } from "path";
419
+ import { resolve as resolve3, dirname as dirname4, join as join6 } from "path";
470
420
 
471
421
  // src/helpers/mime.ts
472
422
  var MIME_TYPES = {
@@ -526,7 +476,7 @@ import {
526
476
  rmSync,
527
477
  statSync
528
478
  } from "fs";
529
- import { dirname as dirname3, join as join4 } from "path";
479
+ import { dirname as dirname2, join as join3 } from "path";
530
480
  import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
531
481
  var SAMPLE_RATE = 4e3;
532
482
  var PEAK_COUNT = 4e3;
@@ -593,11 +543,11 @@ function decodeAudioPeaks(audioPath) {
593
543
  });
594
544
  }
595
545
  async function generateWaveformCache(projectDir, assetPath) {
596
- const audioPath = join4(projectDir, assetPath);
546
+ const audioPath = join3(projectDir, assetPath);
597
547
  if (!existsSync(audioPath)) return;
598
548
  const stats = statSync(audioPath);
599
- const cacheDir = join4(projectDir, ".waveform-cache");
600
- const cachePath = join4(cacheDir, buildWaveformCacheKey(assetPath, stats));
549
+ const cacheDir = join3(projectDir, ".waveform-cache");
550
+ const cachePath = join3(cacheDir, buildWaveformCacheKey(assetPath, stats));
601
551
  if (isWaveformCacheDirectory(cacheDir) && existsSync(cachePath)) return;
602
552
  const peaks = await decodeAudioPeaks(audioPath);
603
553
  writeWaveformCache(cachePath, peaks);
@@ -606,7 +556,7 @@ function isWaveformCacheDirectory(cacheDir) {
606
556
  return lstatSync2(cacheDir, { throwIfNoEntry: false })?.isDirectory() ?? false;
607
557
  }
608
558
  function writeWaveformCache(cachePath, peaks) {
609
- const cacheDir = dirname3(cachePath);
559
+ const cacheDir = dirname2(cachePath);
610
560
  try {
611
561
  mkdirSync2(cacheDir, { mode: 448 });
612
562
  } catch (error) {
@@ -615,9 +565,9 @@ function writeWaveformCache(cachePath, peaks) {
615
565
  if (!isWaveformCacheDirectory(cacheDir)) {
616
566
  throw new Error("Waveform cache must be a directory, not a symlink");
617
567
  }
618
- const stagingDir = mkdtempSync(join4(cacheDir, ".waveform-"));
568
+ const stagingDir = mkdtempSync(join3(cacheDir, ".waveform-"));
619
569
  try {
620
- const stagingPath = join4(stagingDir, "peaks.json");
570
+ const stagingPath = join3(stagingDir, "peaks.json");
621
571
  writeFileSync2(stagingPath, JSON.stringify(peaks), { flag: "wx" });
622
572
  renameSync(stagingPath, cachePath);
623
573
  } finally {
@@ -632,28 +582,42 @@ function writeWaveformCache(cachePath, peaks) {
632
582
  import { spawnSync } from "child_process";
633
583
  import { mkdtempSync as mkdtempSync2, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
634
584
  import { tmpdir } from "os";
635
- import { basename as basename2, join as join5 } from "path";
585
+ import { basename, join as join4 } from "path";
586
+ import { FFPROBE_PATH_ENV, findFfBinary as findFfBinary2 } from "@hyperframes/parsers/ff-binaries";
636
587
  var VIDEO_EXT = /\.(mp4|webm|mov|mkv|avi|m4v|mxf|mts|m2ts|ts)$/i;
637
588
  var AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
638
- function validateUploadedMedia(filePath, runner = spawnSync) {
589
+ var defaultRunner = spawnSync;
590
+ var FFPROBE_MISSING = "not checked: ffprobe was not found. Install FFmpeg or set HYPERFRAMES_FFPROBE_PATH.";
591
+ function validateUploadedMedia(filePath, runner = defaultRunner) {
639
592
  const isVideo = VIDEO_EXT.test(filePath);
640
593
  const isAudio = AUDIO_EXT.test(filePath);
641
594
  if (!isVideo && !isAudio) {
642
595
  return { ok: true };
643
596
  }
597
+ const ffprobe = findFfBinary2("ffprobe") ?? (runner === defaultRunner ? void 0 : "ffprobe");
598
+ if (!ffprobe) return { ok: true, unchecked: FFPROBE_MISSING };
644
599
  const result = runner(
645
- "ffprobe",
600
+ ffprobe,
646
601
  ["-v", "error", "-show_entries", "stream=codec_type", "-of", "json", "--", filePath],
647
602
  { windowsHide: true }
648
603
  );
604
+ if (result.error && process.env[FFPROBE_PATH_ENV]?.trim()) {
605
+ return {
606
+ ok: false,
607
+ reason: `${FFPROBE_PATH_ENV} names "${ffprobe}", which cannot run: fix it or unset it`
608
+ };
609
+ }
649
610
  if (result.error?.code === "ENOENT") {
650
- return { ok: true };
611
+ return { ok: true, unchecked: FFPROBE_MISSING };
651
612
  }
652
613
  if (result.status !== 0) {
653
614
  return { ok: false, reason: "ffprobe failed to read the media file" };
654
615
  }
616
+ return checkStreams(String(result.stdout || "{}"), isVideo, isAudio);
617
+ }
618
+ function checkStreams(stdout, isVideo, isAudio) {
655
619
  try {
656
- const parsed = JSON.parse(String(result.stdout || "{}"));
620
+ const parsed = JSON.parse(stdout);
657
621
  const streams = parsed.streams ?? [];
658
622
  const hasVideo = streams.some((stream) => stream.codec_type === "video");
659
623
  const hasAudio = streams.some((stream) => stream.codec_type === "audio");
@@ -668,9 +632,9 @@ function validateUploadedMedia(filePath, runner = spawnSync) {
668
632
  return { ok: false, reason: "ffprobe returned unreadable media metadata" };
669
633
  }
670
634
  }
671
- function validateUploadedMediaBuffer(fileName, buffer, runner = spawnSync) {
672
- const tempDir = mkdtempSync2(join5(tmpdir(), "hyperframes-upload-"));
673
- const tempPath = join5(tempDir, basename2(fileName));
635
+ function validateUploadedMediaBuffer(fileName, buffer, runner = defaultRunner) {
636
+ const tempDir = mkdtempSync2(join4(tmpdir(), "hyperframes-upload-"));
637
+ const tempPath = join4(tempDir, basename(fileName));
674
638
  try {
675
639
  writeFileSync3(tempPath, buffer);
676
640
  return validateUploadedMedia(tempPath, runner);
@@ -680,9 +644,9 @@ function validateUploadedMediaBuffer(fileName, buffer, runner = spawnSync) {
680
644
  }
681
645
 
682
646
  // src/helpers/backupJournal.ts
683
- import { mkdirSync as mkdirSync3, readdirSync as readdirSync3, readFileSync as readFileSync3, unlinkSync, writeFileSync as writeFileSync4 } from "fs";
647
+ import { mkdirSync as mkdirSync3, readdirSync as readdirSync2, readFileSync as readFileSync3, unlinkSync, writeFileSync as writeFileSync4 } from "fs";
684
648
  import { Buffer as Buffer2 } from "buffer";
685
- import { join as join6, relative as relative3 } from "path";
649
+ import { join as join5, relative as relative2 } from "path";
686
650
  var DEFAULT_KEEP_PER_FILE = 10;
687
651
  function backupKeyForPath(path) {
688
652
  return Buffer2.from(path, "utf-8").toString("base64url");
@@ -692,7 +656,7 @@ function timestampPrefix() {
692
656
  }
693
657
  function backupPathForResponse(projectDir, backupPath) {
694
658
  if (!backupPath) return null;
695
- const rel = relative3(projectDir, backupPath);
659
+ const rel = relative2(projectDir, backupPath);
696
660
  if (!rel || rel.startsWith("..")) return null;
697
661
  return rel.split("\\").join("/");
698
662
  }
@@ -700,8 +664,8 @@ function snapshotBeforeWrite(projectDir, absPath, options = {}) {
700
664
  if (!isSafePath(projectDir, absPath)) return { backupPath: null };
701
665
  try {
702
666
  const content = readFileSync3(absPath);
703
- const relativePath = relative3(projectDir, absPath);
704
- const backupDir = join6(projectDir, ".hyperframes", "backup");
667
+ const relativePath = relative2(projectDir, absPath);
668
+ const backupDir = join5(projectDir, ".hyperframes", "backup");
705
669
  mkdirSync3(backupDir, { recursive: true });
706
670
  const backupKey = backupKeyForPath(relativePath);
707
671
  const backupPath = nextBackupPath(backupDir, backupKey);
@@ -717,7 +681,7 @@ function snapshotBeforeWrite(projectDir, absPath, options = {}) {
717
681
  }
718
682
  function nextBackupPath(backupDir, backupKey) {
719
683
  const base = `${timestampPrefix()}-${backupKey}`;
720
- let candidate = join6(backupDir, base);
684
+ let candidate = join5(backupDir, base);
721
685
  let counter = 2;
722
686
  while (true) {
723
687
  try {
@@ -728,7 +692,7 @@ function nextBackupPath(backupDir, backupKey) {
728
692
  }
729
693
  throw error;
730
694
  }
731
- candidate = join6(backupDir, `${base}-${counter}`);
695
+ candidate = join5(backupDir, `${base}-${counter}`);
732
696
  counter += 1;
733
697
  }
734
698
  }
@@ -736,7 +700,7 @@ function pruneBackups(backupDir, backupKey, keepPerFile) {
736
700
  const keep = Math.max(1, Math.floor(keepPerFile));
737
701
  const suffix = `-${backupKey}`;
738
702
  const numberedSuffix = new RegExp(`-${backupKey}-\\d+$`);
739
- const matches = readdirSync3(backupDir).filter((name) => name.endsWith(suffix) || numberedSuffix.test(name)).map((name) => join6(backupDir, name)).sort((a, b) => {
703
+ const matches = readdirSync2(backupDir).filter((name) => name.endsWith(suffix) || numberedSuffix.test(name)).map((name) => join5(backupDir, name)).sort((a, b) => {
740
704
  return b.localeCompare(a);
741
705
  });
742
706
  for (const file of matches.slice(keep)) {
@@ -937,9 +901,9 @@ import { parseHTML as parseHTML2 } from "linkedom";
937
901
  import { ensureHfIds as ensureHfIds2 } from "@hyperframes/parsers/hf-ids";
938
902
 
939
903
  // src/helpers/compositionInsertion.ts
940
- import { existsSync as existsSync2, readFileSync as readFileSync5, realpathSync as realpathSync2 } from "fs";
904
+ import { existsSync as existsSync2, readFileSync as readFileSync5 } from "fs";
941
905
  import { randomUUID as randomUUID3 } from "crypto";
942
- import { dirname as dirname4, relative as relative4, resolve as resolve2, sep as sep3 } from "path";
906
+ import { dirname as dirname3, relative as relative3, resolve as resolve2, sep as sep2 } from "path";
943
907
  import { parseHTML } from "linkedom";
944
908
  var CompositionInsertionError = class extends Error {
945
909
  constructor(message, status) {
@@ -975,8 +939,8 @@ function canonicalProjectPath(projectDir, candidate) {
975
939
  if (!existsSync2(candidate)) {
976
940
  throw new CompositionInsertionError("Composition source was not found", 404);
977
941
  }
978
- const canonical = realpathSync2(candidate);
979
- if (!isSafePath(realpathSync2(projectDir), canonical)) {
942
+ const canonical = realpath(candidate);
943
+ if (!isSafePath(realpath(projectDir), canonical)) {
980
944
  throw new CompositionInsertionError("Composition source escapes the project", 400);
981
945
  }
982
946
  return canonical;
@@ -988,13 +952,13 @@ function validateSourcePath(sourcePath) {
988
952
  }
989
953
  function canonicalProjectFile(projectDir, sourcePath) {
990
954
  validateSourcePath(sourcePath);
991
- return canonicalProjectPath(projectDir, resolveWithinProject2(projectDir, sourcePath));
955
+ return canonicalProjectPath(projectDir, resolveWithinProject(projectDir, sourcePath));
992
956
  }
993
957
  function canonicalDependency(projectDir, ownerAbs, sourcePath) {
994
958
  validateSourcePath(sourcePath);
995
959
  return canonicalProjectPath(
996
960
  projectDir,
997
- resolveWithinProject2(projectDir, relative4(projectDir, resolve2(dirname4(ownerAbs), sourcePath)))
961
+ resolveWithinProject(projectDir, relative3(projectDir, resolve2(dirname3(ownerAbs), sourcePath)))
998
962
  );
999
963
  }
1000
964
  function validateDependencyGraph(projectDir, targetAbs, sourceAbs) {
@@ -1067,7 +1031,7 @@ function uniqueHostId(root, base) {
1067
1031
  return `${base}_${suffix}`;
1068
1032
  }
1069
1033
  function relativeSourcePath(targetAbs, sourceAbs) {
1070
- return relative4(dirname4(targetAbs), sourceAbs).split(sep3).join("/");
1034
+ return relative3(dirname3(targetAbs), sourceAbs).split(sep2).join("/");
1071
1035
  }
1072
1036
  function insertCompositionIntoSource(input) {
1073
1037
  const targetAbs = canonicalProjectFile(input.projectDir, input.targetPath);
@@ -1199,7 +1163,7 @@ async function loadGsapParser() {
1199
1163
  return import("@hyperframes/parsers/gsap-parser-recast");
1200
1164
  }
1201
1165
  function isDanglingSymlinkInProject(projectDir, lexicalPath) {
1202
- if (!isSafePath(projectDir, dirname5(lexicalPath))) return false;
1166
+ if (!isSafePath(projectDir, dirname4(lexicalPath))) return false;
1203
1167
  let stats;
1204
1168
  try {
1205
1169
  stats = lstatSync3(lexicalPath);
@@ -1207,7 +1171,7 @@ function isDanglingSymlinkInProject(projectDir, lexicalPath) {
1207
1171
  return false;
1208
1172
  }
1209
1173
  if (!stats.isSymbolicLink()) return false;
1210
- const target = resolve3(dirname5(lexicalPath), readlinkSync(lexicalPath));
1174
+ const target = resolve3(dirname4(lexicalPath), readlinkSync(lexicalPath));
1211
1175
  if (!isSafePath(projectDir, target)) return false;
1212
1176
  try {
1213
1177
  statSync3(lexicalPath);
@@ -1231,7 +1195,7 @@ async function resolveProjectPath(c, adapter, route, opts) {
1231
1195
  if (filePath.includes("\0")) {
1232
1196
  return { error: c.json({ error: "forbidden", why: "nul" }, 403) };
1233
1197
  }
1234
- const absPath = (opts?.pin ? pinWithinProject : resolveWithinProject2)(project.dir, filePath);
1198
+ const absPath = (opts?.pin ? pinWithinProject : resolveWithinProject)(project.dir, filePath);
1235
1199
  if (!absPath) {
1236
1200
  if (isDanglingSymlinkInProject(project.dir, resolve3(project.dir, filePath))) {
1237
1201
  return { error: c.json({ error: "not found", why: "dangling_symlink" }, 404) };
@@ -1430,7 +1394,7 @@ async function parseMutationBody(c) {
1430
1394
  return { target: body.target, body };
1431
1395
  }
1432
1396
  function ensureDir(filePath) {
1433
- const dir = dirname5(filePath);
1397
+ const dir = dirname4(filePath);
1434
1398
  if (!existsSync3(dir)) mkdirSync4(dir, { recursive: true });
1435
1399
  }
1436
1400
  function generateCopyPath(projectDir, originalPath) {
@@ -1448,8 +1412,8 @@ function generateCopyPath(projectDir, originalPath) {
1448
1412
  }
1449
1413
  function walkFiles(dir, filter) {
1450
1414
  const results = [];
1451
- for (const entry of readdirSync4(dir, { withFileTypes: true })) {
1452
- const full = join7(dir, entry.name);
1415
+ for (const entry of readdirSync3(dir, { withFileTypes: true })) {
1416
+ const full = join6(dir, entry.name);
1453
1417
  if (entry.isDirectory()) {
1454
1418
  if (entry.name === "node_modules" || entry.name === ".thumbnails" || entry.name === "renders" || entry.name === ".transcode-cache")
1455
1419
  continue;
@@ -2552,6 +2516,7 @@ async function processUploadedFiles(formData, targetDir, projectDir) {
2552
2516
  const uploaded = [];
2553
2517
  const skipped = [];
2554
2518
  const invalid = [];
2519
+ const unchecked = [];
2555
2520
  const entries = formData.entries();
2556
2521
  const subDir = targetDir === projectDir ? "" : targetDir.slice(projectDir.length + 1);
2557
2522
  for (const [, value] of entries) {
@@ -2612,14 +2577,15 @@ async function processUploadedFiles(formData, targetDir, projectDir) {
2612
2577
  if (n >= MAX_COPY_INDEX) skipped.push(name);
2613
2578
  continue;
2614
2579
  }
2615
- const relativePath = subDir ? join7(subDir, finalName) : finalName;
2580
+ const relativePath = subDir ? join6(subDir, finalName) : finalName;
2616
2581
  uploaded.push(relativePath);
2582
+ if (validation.unchecked) unchecked.push({ name: finalName, reason: validation.unchecked });
2617
2583
  if (isAudioFile(finalName)) {
2618
2584
  generateWaveformCache(projectDir, relativePath).catch(() => {
2619
2585
  });
2620
2586
  }
2621
2587
  }
2622
- return { uploaded, skipped, invalid };
2588
+ return { uploaded, skipped, invalid, unchecked };
2623
2589
  }
2624
2590
  function registerFileRoutes(api, adapter) {
2625
2591
  api.get("/projects/:id/files/*", async (c) => {
@@ -3295,7 +3261,7 @@ function registerFileRoutes(api, adapter) {
3295
3261
  if (!body.newPath || body.newPath.includes("\0")) {
3296
3262
  return c.json({ error: "newPath required" }, 400);
3297
3263
  }
3298
- const newAbs = resolveWithinProject2(res.project.dir, body.newPath);
3264
+ const newAbs = resolveWithinProject(res.project.dir, body.newPath);
3299
3265
  if (!newAbs) {
3300
3266
  return c.json({ error: "forbidden" }, 403);
3301
3267
  }
@@ -3314,12 +3280,12 @@ function registerFileRoutes(api, adapter) {
3314
3280
  if (!body.path || body.path.includes("\0")) {
3315
3281
  return c.json({ error: "path required" }, 400);
3316
3282
  }
3317
- const srcAbs = resolveWithinProject2(project.dir, body.path);
3283
+ const srcAbs = resolveWithinProject(project.dir, body.path);
3318
3284
  if (!srcAbs || !existsSync3(srcAbs)) {
3319
3285
  return c.json({ error: "not found" }, 404);
3320
3286
  }
3321
3287
  const copyPath = generateCopyPath(project.dir, body.path);
3322
- const destAbs = resolveWithinProject2(project.dir, copyPath);
3288
+ const destAbs = resolveWithinProject(project.dir, copyPath);
3323
3289
  if (!destAbs) {
3324
3290
  return c.json({ error: "forbidden" }, 403);
3325
3291
  }
@@ -3345,13 +3311,19 @@ function registerFileRoutes(api, adapter) {
3345
3311
  const project = await adapter.resolveProject(c.req.param("id"));
3346
3312
  if (!project) return c.json({ error: "not found" }, 404);
3347
3313
  const subDir = c.req.query("dir") ?? "";
3348
- const targetDir = subDir ? resolveWithinProject2(project.dir, subDir) : project.dir;
3314
+ const targetDir = subDir ? resolveWithinProject(project.dir, subDir) : project.dir;
3349
3315
  if (!targetDir) return c.json({ error: "forbidden" }, 403);
3350
3316
  if (subDir && !existsSync3(targetDir)) mkdirSync4(targetDir, { recursive: true });
3351
3317
  const formData = await c.req.formData();
3352
3318
  const result = await processUploadedFiles(formData, targetDir, project.dir);
3353
3319
  return c.json(
3354
- { ok: true, files: result.uploaded, skipped: result.skipped, invalid: result.invalid },
3320
+ {
3321
+ ok: true,
3322
+ files: result.uploaded,
3323
+ skipped: result.skipped,
3324
+ invalid: result.invalid,
3325
+ unchecked: result.unchecked
3326
+ },
3355
3327
  201
3356
3328
  );
3357
3329
  }
@@ -3429,7 +3401,7 @@ function registerFileRoutes(api, adapter) {
3429
3401
  // src/routes/preview.ts
3430
3402
  import { createReadStream, existsSync as existsSync5, readFileSync as readFileSync8, statSync as statSync4 } from "fs";
3431
3403
  import { Readable } from "stream";
3432
- import { join as join10, resolve as resolve5 } from "path";
3404
+ import { join as join9, resolve as resolve5 } from "path";
3433
3405
  import { createHash as createHash4 } from "crypto";
3434
3406
  import {
3435
3407
  addScenePartsManifest,
@@ -3437,11 +3409,13 @@ import {
3437
3409
  insertBeforeCloseTag as insertBeforeCloseTag2,
3438
3410
  stripEmbeddedRuntimeScripts as stripEmbeddedRuntimeScripts2
3439
3411
  } from "@hyperframes/core/compiler";
3412
+ import { STUDIO_PREVIEW_MARK_META } from "@hyperframes/core/studio-preview-mark";
3413
+ import { injectTagsAtHeadStart } from "@hyperframes/core/compiler/html-document";
3440
3414
  import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
3441
3415
 
3442
3416
  // src/helpers/subComposition.ts
3443
3417
  import { existsSync as existsSync4, readFileSync as readFileSync7 } from "fs";
3444
- import { join as join8 } from "path";
3418
+ import { join as join7 } from "path";
3445
3419
  import { parseHTML as parseHTML3 } from "linkedom";
3446
3420
  import {
3447
3421
  rewriteAssetPaths,
@@ -3451,7 +3425,7 @@ import {
3451
3425
  import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler";
3452
3426
  import { isFullHtmlDocument } from "@hyperframes/core/compiler/html-document";
3453
3427
  function rewriteRelativePaths(root, compPath, projectDir) {
3454
- const assetExists = (path) => existsSync4(join8(projectDir, path));
3428
+ const assetExists = (path) => existsSync4(join7(projectDir, path));
3455
3429
  rewriteAssetPaths(
3456
3430
  root.querySelectorAll("[src], [href]"),
3457
3431
  compPath,
@@ -3548,7 +3522,7 @@ function tagRootCompositionFile(bodyHtml, compPath) {
3548
3522
  return bodyHtml.slice(0, tagEnd) + ` data-composition-file="${compPath}"` + bodyHtml.slice(tagEnd);
3549
3523
  }
3550
3524
  function buildSubCompositionHtml(projectDir, compPath, runtimeUrl, baseHref, rawOverride) {
3551
- const compFile = join8(projectDir, compPath);
3525
+ const compFile = join7(projectDir, compPath);
3552
3526
  if (!existsSync4(compFile)) return null;
3553
3527
  const rawComp = rawOverride ?? readFileSync7(compFile, "utf-8");
3554
3528
  let compHeadContent = "";
@@ -3580,7 +3554,7 @@ function buildSubCompositionHtml(projectDir, compPath, runtimeUrl, baseHref, raw
3580
3554
  }
3581
3555
  rewrittenContent = stripEmbeddedRuntimeScripts(rewrittenContent);
3582
3556
  rewrittenContent = tagRootCompositionFile(rewrittenContent, compPath);
3583
- const indexPath = join8(projectDir, "index.html");
3557
+ const indexPath = join7(projectDir, "index.html");
3584
3558
  let headContent = "";
3585
3559
  if (existsSync4(indexPath)) {
3586
3560
  const indexHtml = readFileSync7(indexPath, "utf-8");
@@ -3624,14 +3598,14 @@ import { ensureHfIds as ensureHfIds3 } from "@hyperframes/parsers/hf-ids";
3624
3598
 
3625
3599
  // src/helpers/previewReads.ts
3626
3600
  import { readlinkSync as readlinkSync2 } from "fs";
3627
- import { dirname as dirname6, join as join9, parse, relative as relative5, resolve as resolve4 } from "path";
3601
+ import { dirname as dirname5, join as join8, parse, relative as relative4, resolve as resolve4 } from "path";
3628
3602
  var ALWAYS_AFFECTS = ["hyperframes.json", ...STUDIO_SIGNATURE_MANIFEST_PATHS];
3629
3603
  var REFERENCE = /\b(?:src|href|poster|data-composition-src)\s*=\s*(?:"([^"\n]*)"|'([^'\n]*)')|url\(\s*(?:"([^"\n]*)"|'([^'\n]*)'|([^"'\s)]+))/gi;
3630
3604
  var pathKey = process.platform === "darwin" || process.platform === "win32" ? (path) => resolve4(path).toLowerCase() : (path) => resolve4(path);
3631
3605
  var readsByProject = /* @__PURE__ */ new Map();
3632
3606
  var builtProjects = /* @__PURE__ */ new Set();
3633
3607
  var projectKey = (projectDir) => pathKey(realFilePath(resolve4(projectDir)));
3634
- var inRealProject = (projectDir, path) => join9(realFilePath(resolve4(projectDir)), relative5(resolve4(projectDir), resolve4(projectDir, path)));
3608
+ var inRealProject = (projectDir, path) => join8(realFilePath(resolve4(projectDir)), relative4(resolve4(projectDir), resolve4(projectDir, path)));
3635
3609
  var readLink = (path) => {
3636
3610
  try {
3637
3611
  return readlinkSync2(path);
@@ -3647,10 +3621,10 @@ function linksOnTheWay(path) {
3647
3621
  const part = rest.shift();
3648
3622
  if (!part || part === ".") continue;
3649
3623
  if (part === "..") {
3650
- at = dirname6(at);
3624
+ at = dirname5(at);
3651
3625
  continue;
3652
3626
  }
3653
- const next = join9(at, part);
3627
+ const next = join8(at, part);
3654
3628
  const target = hops < 40 ? readLink(next) : null;
3655
3629
  if (target === null) {
3656
3630
  at = next;
@@ -3671,7 +3645,7 @@ function recordPreviewRead(projectDir, filePath) {
3671
3645
  if (!reads) readsByProject.set(key, reads = /* @__PURE__ */ new Set());
3672
3646
  const read = inRealProject(projectDir, filePath);
3673
3647
  for (const found of [read, ...linksOnTheWay(read)]) {
3674
- for (let path = pathKey(found); !reads.has(path); path = dirname6(path)) reads.add(path);
3648
+ for (let path = pathKey(found); !reads.has(path); path = dirname5(path)) reads.add(path);
3675
3649
  }
3676
3650
  }
3677
3651
  function recordPreviewReferences(projectDir, html) {
@@ -3721,6 +3695,34 @@ function injectPreviewVariables(html, values) {
3721
3695
  return tag + html;
3722
3696
  }
3723
3697
 
3698
+ // src/helpers/lazyPreviewImages.ts
3699
+ import { findStartTags } from "@hyperframes/core/compiler/html-document";
3700
+ import { parseHTML as parseHTML4 } from "linkedom";
3701
+ import { STUDIO_PREVIEW_LAZY_ATTR } from "@hyperframes/core/studio-preview-mark";
3702
+ function startsAfterZero(el) {
3703
+ for (let node = el; node; node = node.parentElement) {
3704
+ if (Number(node.getAttribute("data-start")) > 0) return true;
3705
+ }
3706
+ return false;
3707
+ }
3708
+ var hasLoading = (img) => Array.from(img.attributes).some((attr) => attr.name.toLowerCase() === "loading");
3709
+ function lazyPreviewImages(html) {
3710
+ if (!/<!doctype|<html[\s>]/i.test(html)) return html;
3711
+ const images = [...parseHTML4(html).document.querySelectorAll("img")];
3712
+ const tags = findStartTags(html, "img");
3713
+ if (tags.length !== images.length) return html;
3714
+ const parts = [];
3715
+ let from = 0;
3716
+ images.forEach((img, i) => {
3717
+ if (hasLoading(img) || !startsAfterZero(img)) return;
3718
+ const at = (tags[i] ?? 0) + 4;
3719
+ parts.push(html.slice(from, at), ` loading="lazy" ${STUDIO_PREVIEW_LAZY_ATTR}`);
3720
+ from = at;
3721
+ });
3722
+ parts.push(html.slice(from));
3723
+ return parts.join("");
3724
+ }
3725
+
3724
3726
  // src/routes/preview.ts
3725
3727
  var PROJECT_SIGNATURE_META = "hyperframes-project-signature";
3726
3728
  var GSAP_CDN_VERSION = "3.15.0";
@@ -3740,7 +3742,7 @@ function injectProjectSignature(html, signature) {
3740
3742
  ${html}`;
3741
3743
  }
3742
3744
  function readStudioMotionManifestContent(projectDir) {
3743
- const manifestPath = join10(projectDir, STUDIO_MOTION_PATH);
3745
+ const manifestPath = join9(projectDir, STUDIO_MOTION_PATH);
3744
3746
  if (!existsSync5(manifestPath)) return "";
3745
3747
  try {
3746
3748
  return readFileSync8(manifestPath, "utf-8");
@@ -3813,7 +3815,8 @@ function injectStudioMotionScript(html, projectDir, activeCompositionPath) {
3813
3815
  false
3814
3816
  );
3815
3817
  }
3816
- var GSAP_CDN_FALLBACK_SCRIPT = `<script data-hf-gsap-fallback>
3818
+ var GSAP_FALLBACK_ATTR = "data-hf-gsap-fallback";
3819
+ var GSAP_CDN_FALLBACK_SCRIPT = `<script ${GSAP_FALLBACK_ATTR}>
3817
3820
  (function(){
3818
3821
  var cdnBase="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/";
3819
3822
  var loaded={};
@@ -3834,7 +3837,7 @@ var GSAP_CDN_FALLBACK_SCRIPT = `<script data-hf-gsap-fallback>
3834
3837
  })();
3835
3838
  </script>`;
3836
3839
  function injectGsapCdnFallback(html) {
3837
- if (html.includes("data-hf-gsap-fallback")) return html;
3840
+ if (html.includes(GSAP_FALLBACK_ATTR)) return html;
3838
3841
  if (html.includes("<head>")) return html.replace("<head>", "<head>" + GSAP_CDN_FALLBACK_SCRIPT);
3839
3842
  return GSAP_CDN_FALLBACK_SCRIPT + html;
3840
3843
  }
@@ -3860,11 +3863,13 @@ function previewVariablesFromRequest(rawVariables) {
3860
3863
  if (!parse2.ok) return { error: parse2.error };
3861
3864
  return { raw: rawVariables, values: parse2.values };
3862
3865
  }
3863
- function injectStudioPreviewAugmentations(html, adapter, projectDir, activeCompositionPath) {
3866
+ var PREVIEW_CAPTURE_PARAM = "hf-capture";
3867
+ function injectStudioPreviewAugmentations(html, adapter, projectDir, activeCompositionPath, capture) {
3868
+ const marked = capture ? html : injectTagsAtHeadStart(lazyPreviewImages(html), `<meta name="${STUDIO_PREVIEW_MARK_META}">`);
3864
3869
  return injectStudioMotionScript(
3865
3870
  injectMotionPathPluginIfNeeded(
3866
3871
  injectGsapCdnFallback(
3867
- injectProjectSignature(html, resolveProjectSignature(adapter, projectDir))
3872
+ injectProjectSignature(marked, resolveProjectSignature(adapter, projectDir))
3868
3873
  )
3869
3874
  ),
3870
3875
  projectDir,
@@ -3885,8 +3890,8 @@ async function transformPreviewHtml(html, adapter, project, activeCompositionPat
3885
3890
  }
3886
3891
  }
3887
3892
  function resolveProjectMainHtml(projectDir, projectId) {
3888
- const indexPath = join10(projectDir, "index.html");
3889
- const blockHtmlPath = join10(projectDir, `${projectId}.html`);
3893
+ const indexPath = join9(projectDir, "index.html");
3894
+ const blockHtmlPath = join9(projectDir, `${projectId}.html`);
3890
3895
  recordPreviewRead(projectDir, indexPath);
3891
3896
  recordPreviewRead(projectDir, blockHtmlPath);
3892
3897
  if (existsSync5(indexPath)) {
@@ -3922,7 +3927,7 @@ function registerPreviewRoutes(api, adapter) {
3922
3927
  if (builtPreviews.size > 4) builtPreviews.delete(builtPreviews.keys().next().value);
3923
3928
  };
3924
3929
  const previewBuilds = /* @__PURE__ */ new Map();
3925
- async function buildPreview(project, previewVariables, builtKey) {
3930
+ async function buildPreview(project, previewVariables, builtKey, capture) {
3926
3931
  const diskMain = resolveProjectMainHtml(project.dir, project.id);
3927
3932
  const normalizedDisk = diskMain ? ensureHfIds3(diskMain.html) : null;
3928
3933
  try {
@@ -3952,7 +3957,8 @@ ${runtimeTag}`;
3952
3957
  ensureHfIds3(await transformPreviewHtml(bundled, adapter, project, mainCompositionPath)),
3953
3958
  adapter,
3954
3959
  project.dir,
3955
- mainCompositionPath
3960
+ mainCompositionPath,
3961
+ capture
3956
3962
  );
3957
3963
  if (previewVariables) bundled = injectPreviewVariables(bundled, previewVariables);
3958
3964
  bundled = await injectMediaCodecMap(
@@ -3964,7 +3970,7 @@ ${runtimeTag}`;
3964
3970
  );
3965
3971
  bundled = addScenePartsManifest(bundled, [`meta[name="${PROJECT_SIGNATURE_META}"]`]);
3966
3972
  rememberPreview(builtKey, bundled);
3967
- adapter.previewDocuments?.write(builtKey, bundled);
3973
+ if (!capture) adapter.previewDocuments?.write(builtKey, bundled);
3968
3974
  return bundled;
3969
3975
  } catch {
3970
3976
  const fallback = resolveProjectMainHtml(project.dir, project.id);
@@ -3974,7 +3980,8 @@ ${runtimeTag}`;
3974
3980
  await transformPreviewHtml(fallbackHtml, adapter, project, fallback.compositionPath),
3975
3981
  adapter,
3976
3982
  project.dir,
3977
- fallback.compositionPath
3983
+ fallback.compositionPath,
3984
+ capture
3978
3985
  );
3979
3986
  if (previewVariables) {
3980
3987
  fallbackAugmented = injectPreviewVariables(fallbackAugmented, previewVariables);
@@ -3998,7 +4005,8 @@ ${runtimeTag}`;
3998
4005
  const vars = previewVariablesFromRequest(c.req.query("variables"));
3999
4006
  if (vars.error !== void 0) return c.json({ error: vars.error }, 400);
4000
4007
  const previewVariables = vars.values;
4001
- const etag = `"preview:${signature}${variablesEtagSalt(vars.raw)}"`;
4008
+ const capture = c.req.query(PREVIEW_CAPTURE_PARAM) !== void 0;
4009
+ const etag = `"preview:${signature}${variablesEtagSalt(vars.raw)}${capture ? ":capture" : ""}"`;
4002
4010
  const ifNoneMatch = c.req.header("If-None-Match");
4003
4011
  if (ifNoneMatch === etag) {
4004
4012
  return new Response(null, {
@@ -4015,7 +4023,7 @@ ${etag}`;
4015
4023
  }
4016
4024
  let pending = previewBuilds.get(builtKey);
4017
4025
  if (!pending) {
4018
- pending = buildPreview(project, previewVariables, builtKey).finally(
4026
+ pending = buildPreview(project, previewVariables, builtKey, capture).finally(
4019
4027
  () => previewBuilds.delete(builtKey)
4020
4028
  );
4021
4029
  previewBuilds.set(builtKey, pending);
@@ -4040,14 +4048,15 @@ ${etag}`;
4040
4048
  if (vars.error !== void 0) return c.json({ error: vars.error }, 400);
4041
4049
  const previewVariables = vars.values;
4042
4050
  const compPath = requestSubPath(c.req.url, "projects/:id/preview/comp");
4043
- const compFile = resolveWithinProject2(project.dir, compPath);
4051
+ const compFile = resolveWithinProject(project.dir, compPath);
4044
4052
  if (compFile) recordPreviewRead(project.dir, compFile);
4045
4053
  recordPreviewRead(project.dir, "index.html");
4046
4054
  if (!compFile || !existsSync5(compFile) || !statSync4(compFile).isFile()) {
4047
4055
  return c.text("not found", 404);
4048
4056
  }
4049
4057
  const compPathHash = createHash4("sha1").update(compPath).digest("hex");
4050
- const etag = `"comp:v2:${compPathHash}:${signature}${variablesEtagSalt(vars.raw)}"`;
4058
+ const capture = c.req.query(PREVIEW_CAPTURE_PARAM) !== void 0;
4059
+ const etag = `"comp:v2:${compPathHash}:${signature}${variablesEtagSalt(vars.raw)}${capture ? ":capture" : ""}"`;
4051
4060
  const ifNoneMatch = c.req.header("If-None-Match");
4052
4061
  if (ifNoneMatch === etag) {
4053
4062
  return new Response(null, {
@@ -4068,7 +4077,7 @@ ${etag}`;
4068
4077
  if (!html) return c.text("not found", 404);
4069
4078
  recordPreviewReferences(project.dir, html);
4070
4079
  html = ensureHfIds3(await transformPreviewHtml(html, adapter, project, compPath));
4071
- html = injectStudioPreviewAugmentations(html, adapter, project.dir, compPath);
4080
+ html = injectStudioPreviewAugmentations(html, adapter, project.dir, compPath, capture);
4072
4081
  if (previewVariables) html = injectPreviewVariables(html, previewVariables);
4073
4082
  html = await injectMediaCodecMap(html, adapter, project.dir, compPath, mediaCodecProbeCache);
4074
4083
  return c.html(html, 200, previewCacheHeaders(etag));
@@ -4175,7 +4184,7 @@ ${etag}`;
4175
4184
 
4176
4185
  // src/routes/lint.ts
4177
4186
  import { readFileSync as readFileSync9 } from "fs";
4178
- import { join as join11 } from "path";
4187
+ import { join as join10 } from "path";
4179
4188
  async function collectProjectFindings(adapter, projectDir) {
4180
4189
  const findings = [];
4181
4190
  const coveredFiles = /* @__PURE__ */ new Set();
@@ -4193,7 +4202,7 @@ async function lintUncoveredHtml(adapter, projectDir, htmlFiles, coveredFiles) {
4193
4202
  const findings = [];
4194
4203
  for (const file of htmlFiles) {
4195
4204
  if (coveredFiles.has(file)) continue;
4196
- const content = readFileSync9(join11(projectDir, file), "utf-8");
4205
+ const content = readFileSync9(join10(projectDir, file), "utf-8");
4197
4206
  const result = await adapter.lint(content, { filePath: file, isSubComposition: true });
4198
4207
  for (const finding of result?.findings ?? []) {
4199
4208
  findings.push({ ...finding, file });
@@ -4226,8 +4235,8 @@ function registerLintRoutes(api, adapter) {
4226
4235
 
4227
4236
  // src/routes/render.ts
4228
4237
  import { streamSSE } from "hono/streaming";
4229
- import { existsSync as existsSync6, readFileSync as readFileSync10, mkdirSync as mkdirSync5, unlinkSync as unlinkSync3, readdirSync as readdirSync5, statSync as statSync5 } from "fs";
4230
- import { join as join12 } from "path";
4238
+ import { existsSync as existsSync6, readFileSync as readFileSync10, mkdirSync as mkdirSync5, unlinkSync as unlinkSync3, readdirSync as readdirSync4, statSync as statSync5 } from "fs";
4239
+ import { join as join11 } from "path";
4231
4240
  import { VALID_CANVAS_RESOLUTIONS } from "@hyperframes/parsers";
4232
4241
  import { formatRenderOutputTimestamp, parseFps } from "@hyperframes/core";
4233
4242
  var VALID_RESOLUTIONS = new Set(VALID_CANVAS_RESOLUTIONS);
@@ -4278,7 +4287,7 @@ function registerRenderRoutes(api, adapter) {
4278
4287
  const outputResolution = VALID_RESOLUTIONS.has(body.resolution ?? "") ? body.resolution : void 0;
4279
4288
  let composition;
4280
4289
  if (typeof body.composition === "string" && body.composition.length > 0) {
4281
- if (!resolveWithinProject2(project.dir, body.composition)) {
4290
+ if (!resolveWithinProject(project.dir, body.composition)) {
4282
4291
  return c.json({ error: "composition path must be within the project directory" }, 400);
4283
4292
  }
4284
4293
  composition = body.composition;
@@ -4295,7 +4304,7 @@ function registerRenderRoutes(api, adapter) {
4295
4304
  const rendersDir = adapter.rendersDir(project);
4296
4305
  if (!existsSync6(rendersDir)) mkdirSync5(rendersDir, { recursive: true });
4297
4306
  const ext = FORMAT_EXT[format] ?? ".mp4";
4298
- const outputPath = join12(rendersDir, `${jobId}${ext}`);
4307
+ const outputPath = join11(rendersDir, `${jobId}${ext}`);
4299
4308
  const jobState = adapter.startRender({
4300
4309
  project,
4301
4310
  outputPath,
@@ -4396,7 +4405,7 @@ function registerRenderRoutes(api, adapter) {
4396
4405
  if (state.id === jobId && state.outputPath) {
4397
4406
  const dir = state.outputPath.replace(/\/[^/]+$/, "");
4398
4407
  for (const ext of [".mp4", ".webm", ".mov", ".meta.json"]) {
4399
- const fp = join12(dir, `${jobId}${ext}`);
4408
+ const fp = join11(dir, `${jobId}${ext}`);
4400
4409
  if (existsSync6(fp)) unlinkSync3(fp);
4401
4410
  }
4402
4411
  break;
@@ -4411,7 +4420,7 @@ function registerRenderRoutes(api, adapter) {
4411
4420
  const filename = c.req.path.split("/renders/file/")[1];
4412
4421
  if (!filename) return c.json({ error: "missing filename" }, 400);
4413
4422
  const rendersDir = adapter.rendersDir(project);
4414
- const fp = resolveWithinProject2(rendersDir, filename);
4423
+ const fp = resolveWithinProject(rendersDir, filename);
4415
4424
  if (!fp) return c.json({ error: "forbidden" }, 403);
4416
4425
  if (!existsSync6(fp)) return c.json({ error: "not found" }, 404);
4417
4426
  const contentType = renderContentType(fp);
@@ -4430,11 +4439,11 @@ function registerRenderRoutes(api, adapter) {
4430
4439
  if (!project) return c.json({ error: "not found" }, 404);
4431
4440
  const rendersDir = adapter.rendersDir(project);
4432
4441
  if (!existsSync6(rendersDir)) return c.json({ renders: [] });
4433
- const files = readdirSync5(rendersDir).filter((f) => f.endsWith(".mp4") || f.endsWith(".webm") || f.endsWith(".mov")).map((f) => {
4434
- const fp = join12(rendersDir, f);
4442
+ const files = readdirSync4(rendersDir).filter((f) => f.endsWith(".mp4") || f.endsWith(".webm") || f.endsWith(".mov")).map((f) => {
4443
+ const fp = join11(rendersDir, f);
4435
4444
  const stat3 = statSync5(fp);
4436
4445
  const rid = f.replace(/\.(mp4|webm|mov)$/, "");
4437
- const metaPath = join12(rendersDir, `${rid}.meta.json`);
4446
+ const metaPath = join11(rendersDir, `${rid}.meta.json`);
4438
4447
  let status = "complete";
4439
4448
  let durationMs;
4440
4449
  if (existsSync6(metaPath)) {
@@ -4460,7 +4469,7 @@ function registerRenderRoutes(api, adapter) {
4460
4469
  id: file.id,
4461
4470
  status: file.status,
4462
4471
  progress: 100,
4463
- outputPath: join12(rendersDir, file.filename),
4472
+ outputPath: join11(rendersDir, file.filename),
4464
4473
  createdAt: file.createdAt
4465
4474
  });
4466
4475
  }
@@ -4608,14 +4617,14 @@ async function generateThumbnail(path, signal) {
4608
4617
  async function resolveSource(adapter, id, url) {
4609
4618
  const project = await adapter.resolveProject(id);
4610
4619
  if (!project) throw new HTTPException(404);
4611
- let relative7;
4620
+ let relative6;
4612
4621
  try {
4613
- relative7 = requestSubPath(url, "projects/:id/image-thumbnail");
4622
+ relative6 = requestSubPath(url, "projects/:id/image-thumbnail");
4614
4623
  } catch {
4615
4624
  throw new HTTPException(400);
4616
4625
  }
4617
- if (!/\.jpe?g$/i.test(relative7)) throw new HTTPException(415);
4618
- const path = pinWithinProject(project.dir, relative7);
4626
+ if (!/\.jpe?g$/i.test(relative6)) throw new HTTPException(415);
4627
+ const path = pinWithinProject(project.dir, relative6);
4619
4628
  if (!path) throw new HTTPException(404);
4620
4629
  const info = await stat(path).catch(() => {
4621
4630
  throw new HTTPException(404);
@@ -4676,14 +4685,14 @@ import {
4676
4685
  openSync as openSync3,
4677
4686
  mkdirSync as mkdirSync6,
4678
4687
  readFileSync as readFileSync12,
4679
- readdirSync as readdirSync6,
4688
+ readdirSync as readdirSync5,
4680
4689
  renameSync as renameSync3,
4681
4690
  rmSync as rmSync4,
4682
4691
  statSync as statSync6,
4683
4692
  unlinkSync as unlinkSync4,
4684
4693
  writeFileSync as writeFileSync6
4685
4694
  } from "fs";
4686
- import { join as join13 } from "path";
4695
+ import { join as join12 } from "path";
4687
4696
  import { createHash as createHash6, randomUUID as randomUUID4 } from "crypto";
4688
4697
 
4689
4698
  // src/helpers/compositionInputs.ts
@@ -4694,7 +4703,7 @@ function projectReader(projectDir) {
4694
4703
  const sources = /* @__PURE__ */ new Map();
4695
4704
  return (compPath) => {
4696
4705
  if (!sources.has(compPath)) {
4697
- const file = resolveWithinProject2(projectDir, compPath);
4706
+ const file = resolveWithinProject(projectDir, compPath);
4698
4707
  let source = null;
4699
4708
  try {
4700
4709
  source = file ? readFileSync11(file, "utf-8") : null;
@@ -4753,14 +4762,14 @@ var prunedCacheDirs = /* @__PURE__ */ new Set();
4753
4762
  function pruneThumbnailCache(cacheDir, protectedPaths, now = Date.now()) {
4754
4763
  let entries;
4755
4764
  try {
4756
- entries = readdirSync6(cacheDir, { withFileTypes: true });
4765
+ entries = readdirSync5(cacheDir, { withFileTypes: true });
4757
4766
  } catch (err) {
4758
4767
  if (err.code === "ENOENT") return;
4759
4768
  throw err;
4760
4769
  }
4761
4770
  const files = entries.flatMap((entry) => {
4762
4771
  if (!entry.isFile()) return [];
4763
- const path = join13(cacheDir, entry.name);
4772
+ const path = join12(cacheDir, entry.name);
4764
4773
  try {
4765
4774
  const stats = statSync6(path);
4766
4775
  return [{ path, bytes: stats.size, mtimeMs: stats.mtimeMs }];
@@ -4830,7 +4839,7 @@ function registerThumbnailRoutes(api, adapter) {
4830
4839
  const { project, signature: projectSignature } = resolved;
4831
4840
  let compPath = requestSubPath(c.req.url, "projects/:id/thumbnail");
4832
4841
  if (compPath && !compPath.includes(".")) compPath += ".html";
4833
- const htmlFile = resolveWithinProject2(project.dir, compPath);
4842
+ const htmlFile = resolveWithinProject(project.dir, compPath);
4834
4843
  const source = htmlFile ? readFileOnce(htmlFile) : "not-a-file";
4835
4844
  if (source === "not-a-file") return c.json({ error: "not found" }, 404);
4836
4845
  const inputSignature = compositionInputSignature(project.dir, compPath, projectSignature);
@@ -4863,12 +4872,13 @@ function registerThumbnailRoutes(api, adapter) {
4863
4872
  if (hMatch?.[1]) compH = parseInt(hMatch[1]);
4864
4873
  }
4865
4874
  }
4866
- const manualEdits = manifestKey(join13(project.dir, STUDIO_MANUAL_EDITS_PATH));
4867
- const motion = manifestKey(join13(project.dir, STUDIO_MOTION_PATH));
4875
+ const manualEdits = manifestKey(join12(project.dir, STUDIO_MANUAL_EDITS_PATH));
4876
+ const motion = manifestKey(join12(project.dir, STUDIO_MOTION_PATH));
4868
4877
  sourceMtime = Math.max(sourceMtime, manualEdits.mtimeMs, motion.mtimeMs);
4869
4878
  const projectUrl = `http://${c.req.header("host")}/api/projects/${encodeURIComponent(project.id)}`;
4870
- const previewUrl = compPath === "index.html" ? `${projectUrl}/preview` : `${projectUrl}/preview/comp/${compPath.split("/").map(encodeURIComponent).join("/")}`;
4871
- const cacheDir = join13(project.dir, ".thumbnails");
4879
+ const previewPath = compPath === "index.html" ? `${projectUrl}/preview` : `${projectUrl}/preview/comp/${compPath.split("/").map(encodeURIComponent).join("/")}`;
4880
+ const previewUrl = `${previewPath}?${PREVIEW_CAPTURE_PARAM}=1`;
4881
+ const cacheDir = join12(project.dir, ".thumbnails");
4872
4882
  const selectorKey = selector ? `_${selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}_${selectorIndex ?? 0}` : "";
4873
4883
  const urlVersionKey = urlVersion ? `_${urlVersion.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 32)}` : "";
4874
4884
  const inputSignatureKey = `_${createHash6("sha1").update(inputSignature).digest("hex").slice(0, 16)}`;
@@ -4876,7 +4886,7 @@ function registerThumbnailRoutes(api, adapter) {
4876
4886
  const outputWidth = Math.max(1, Math.round(compW * outputScale));
4877
4887
  const outputHeight = Math.max(1, Math.round(compH * outputScale));
4878
4888
  const cacheKey = `${THUMBNAIL_CACHE_VERSION}${urlVersionKey}${inputSignatureKey}${manualEdits.key}${motion.key}${sourceKey}_${format}_${outputMode}_${compPath.replace(/\//g, "_")}_${compW}x${compH}_${outputWidth}x${outputHeight}_${sourceMtime}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`;
4879
- const cachePath = join13(cacheDir, cacheKey);
4889
+ const cachePath = join12(cacheDir, cacheKey);
4880
4890
  if (!prunedCacheDirs.has(cacheDir)) {
4881
4891
  prunedCacheDirs.add(cacheDir);
4882
4892
  pruneThumbnailCache(
@@ -4943,7 +4953,7 @@ function registerThumbnailRoutes(api, adapter) {
4943
4953
 
4944
4954
  // src/routes/waveform.ts
4945
4955
  import { existsSync as existsSync7, readFileSync as readFileSync13, statSync as statSync7 } from "fs";
4946
- import { join as join14, resolve as resolve6 } from "path";
4956
+ import { join as join13, resolve as resolve6 } from "path";
4947
4957
  import { isWithinProjectRoot as isWithinProjectRoot2 } from "@hyperframes/parsers/asset-resolution";
4948
4958
  function registerWaveformRoutes(api, adapter) {
4949
4959
  api.get("/projects/:id/waveform/*", async (c) => {
@@ -4956,8 +4966,8 @@ function registerWaveformRoutes(api, adapter) {
4956
4966
  }
4957
4967
  const stats = statSync7(audioPath, { throwIfNoEntry: false });
4958
4968
  if (!stats) return c.json({ error: "file not found" }, 404);
4959
- const cacheDir = join14(project.dir, ".waveform-cache");
4960
- const cachePath = join14(cacheDir, buildWaveformCacheKey(assetPath, stats));
4969
+ const cacheDir = join13(project.dir, ".waveform-cache");
4970
+ const cachePath = join13(cacheDir, buildWaveformCacheKey(assetPath, stats));
4961
4971
  try {
4962
4972
  if (isWaveformCacheDirectory(cacheDir) && existsSync7(cachePath)) {
4963
4973
  const peaks2 = JSON.parse(readFileSync13(cachePath, "utf-8"));
@@ -5244,7 +5254,7 @@ function registerSelectionRoutes(api, adapter) {
5244
5254
  // src/routes/media.ts
5245
5255
  import { streamSSE as streamSSE2 } from "hono/streaming";
5246
5256
  import { existsSync as existsSync8, mkdirSync as mkdirSync7 } from "fs";
5247
- import { basename as basename3, dirname as dirname7, extname as extname2, join as join15 } from "path";
5257
+ import { basename as basename2, dirname as dirname6, extname as extname2, join as join14, posix } from "path";
5248
5258
  var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([
5249
5259
  ".mp4",
5250
5260
  ".mov",
@@ -5274,24 +5284,34 @@ function containsNullByte(path) {
5274
5284
  return path.includes("\0");
5275
5285
  }
5276
5286
  function slugFileBase(path) {
5277
- const name = basename3(path, extname2(path)).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
5287
+ const name = basename2(path, extname2(path)).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
5278
5288
  return name || "media";
5279
5289
  }
5280
- function uniqueAssetPath(projectDir, assetPath) {
5290
+ function uniqueAssetPath(projectDir, assetPath, taken) {
5281
5291
  const ext = extname2(assetPath);
5282
5292
  const withoutExt = assetPath.slice(0, -ext.length);
5283
5293
  let candidate = assetPath;
5284
- for (let index = 2; existsSync8(join15(projectDir, candidate)); index++) {
5294
+ for (let index = 2; taken.has(candidate) || existsSync8(join14(projectDir, candidate)); index++) {
5285
5295
  candidate = `${withoutExt}-${index}${ext}`;
5286
5296
  }
5287
5297
  return candidate;
5288
5298
  }
5289
- function defaultOutputPath(projectDir, inputPath) {
5299
+ function defaultOutputPath(projectDir, inputPath, taken) {
5290
5300
  const ext = isImagePath(inputPath) ? ".png" : ".webm";
5291
- return uniqueAssetPath(projectDir, `assets/cutouts/${slugFileBase(inputPath)}-cutout${ext}`);
5301
+ return uniqueAssetPath(
5302
+ projectDir,
5303
+ `assets/cutouts/${slugFileBase(inputPath)}-cutout${ext}`,
5304
+ taken
5305
+ );
5292
5306
  }
5293
- function defaultPlatePath(projectDir, inputPath) {
5294
- return uniqueAssetPath(projectDir, `assets/cutouts/${slugFileBase(inputPath)}-plate.webm`);
5307
+ function defaultPlatePath(projectDir, inputPath, taken) {
5308
+ return uniqueAssetPath(projectDir, `assets/cutouts/${slugFileBase(inputPath)}-plate.webm`, taken);
5309
+ }
5310
+ function outputsInFlight(mediaJobs) {
5311
+ const running = [...mediaJobs.values()].filter((job) => job.status === "processing");
5312
+ return new Set(
5313
+ running.flatMap((job) => [job.outputAssetPath, job.backgroundOutputAssetPath ?? ""])
5314
+ );
5295
5315
  }
5296
5316
  function makeJobId(projectId, mediaJobs) {
5297
5317
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
@@ -5330,7 +5350,7 @@ function registerMediaRoutes(api, adapter, options = {}) {
5330
5350
  if (/^(?:https?:|data:|blob:)/i.test(assetPath)) {
5331
5351
  return c.json({ error: "media metadata requires a project-local asset" }, 400);
5332
5352
  }
5333
- const filePath = resolveWithinProject2(project.dir, assetPath);
5353
+ const filePath = resolveWithinProject(project.dir, assetPath);
5334
5354
  if (!filePath) return c.json({ error: "forbidden" }, 403);
5335
5355
  if (!existsSync8(filePath)) return c.json({ error: "media not found" }, 404);
5336
5356
  return c.json({ path: assetPath, metadata: await readMediaMetadata(filePath) });
@@ -5352,7 +5372,7 @@ function registerMediaRoutes(api, adapter, options = {}) {
5352
5372
  if (/^(?:https?:|data:|blob:)/i.test(inputAssetPath)) {
5353
5373
  return c.json({ error: "background removal requires a project-local media asset" }, 400);
5354
5374
  }
5355
- const inputPath = resolveWithinProject2(project.dir, inputAssetPath);
5375
+ const inputPath = resolveWithinProject(project.dir, inputAssetPath);
5356
5376
  if (!inputPath) return c.json({ error: "forbidden" }, 403);
5357
5377
  if (!existsSync8(inputPath)) return c.json({ error: "input media not found" }, 404);
5358
5378
  const inputIsVideo = isVideoPath(inputAssetPath);
@@ -5360,15 +5380,16 @@ function registerMediaRoutes(api, adapter, options = {}) {
5360
5380
  if (!inputIsVideo && !inputIsImage) {
5361
5381
  return c.json({ error: "background removal supports video or image assets only" }, 400);
5362
5382
  }
5363
- const requestedOutput = body.outputPath ? normalizeProjectAssetPath(body.outputPath) : "";
5383
+ const requestedOutput = body.outputPath ? posix.normalize(normalizeProjectAssetPath(body.outputPath)) : "";
5364
5384
  if (requestedOutput && containsNullByte(requestedOutput)) {
5365
5385
  return c.json({ error: "forbidden" }, 403);
5366
5386
  }
5367
- if (requestedOutput && !resolveWithinProject2(project.dir, requestedOutput)) {
5387
+ if (requestedOutput && !resolveWithinProject(project.dir, requestedOutput)) {
5368
5388
  return c.json({ error: "forbidden" }, 403);
5369
5389
  }
5370
- const outputAssetPath = requestedOutput ? uniqueAssetPath(project.dir, requestedOutput) : defaultOutputPath(project.dir, inputAssetPath);
5371
- const outputPath = resolveWithinProject2(project.dir, outputAssetPath);
5390
+ const taken = outputsInFlight(mediaJobs);
5391
+ const outputAssetPath = requestedOutput ? uniqueAssetPath(project.dir, requestedOutput, taken) : defaultOutputPath(project.dir, inputAssetPath, taken);
5392
+ const outputPath = pinWithinProject(project.dir, outputAssetPath);
5372
5393
  if (!outputPath) return c.json({ error: "forbidden" }, 403);
5373
5394
  if (inputIsVideo && !VIDEO_OUTPUT_EXTENSIONS.has(extname2(outputAssetPath).toLowerCase())) {
5374
5395
  return c.json({ error: "video background removal output must be .webm or .mov" }, 400);
@@ -5382,14 +5403,15 @@ function registerMediaRoutes(api, adapter, options = {}) {
5382
5403
  if (!inputIsVideo) {
5383
5404
  return c.json({ error: "background plates are only supported for video inputs" }, 400);
5384
5405
  }
5385
- backgroundOutputAssetPath = defaultPlatePath(project.dir, inputAssetPath);
5386
- backgroundOutputPath = resolveWithinProject2(project.dir, backgroundOutputAssetPath) ?? void 0;
5406
+ taken.add(outputAssetPath);
5407
+ backgroundOutputAssetPath = defaultPlatePath(project.dir, inputAssetPath, taken);
5408
+ backgroundOutputPath = pinWithinProject(project.dir, backgroundOutputAssetPath) ?? void 0;
5387
5409
  if (!backgroundOutputPath) {
5388
5410
  return c.json({ error: "forbidden" }, 403);
5389
5411
  }
5390
5412
  }
5391
- mkdirSync7(dirname7(outputPath), { recursive: true });
5392
- if (backgroundOutputPath) mkdirSync7(dirname7(backgroundOutputPath), { recursive: true });
5413
+ mkdirSync7(dirname6(outputPath), { recursive: true });
5414
+ if (backgroundOutputPath) mkdirSync7(dirname6(backgroundOutputPath), { recursive: true });
5393
5415
  const jobId = makeJobId(project.id, mediaJobs);
5394
5416
  const state = adapter.startBackgroundRemoval({
5395
5417
  project,
@@ -5448,9 +5470,9 @@ function registerMediaRoutes(api, adapter, options = {}) {
5448
5470
  // src/routes/globalAssets.ts
5449
5471
  import { existsSync as existsSync9, readFileSync as readFileSync14 } from "fs";
5450
5472
  import { homedir } from "os";
5451
- import { join as join16 } from "path";
5473
+ import { join as join15 } from "path";
5452
5474
  function readGlobalAssets(home = homedir()) {
5453
- const manifestPath = join16(home, ".media", "manifest.jsonl");
5475
+ const manifestPath = join15(home, ".media", "manifest.jsonl");
5454
5476
  if (!existsSync9(manifestPath)) return [];
5455
5477
  const out = [];
5456
5478
  for (const line of readFileSync14(manifestPath, "utf8").split("\n")) {
@@ -5471,26 +5493,26 @@ function registerGlobalAssetRoutes(api) {
5471
5493
  }
5472
5494
 
5473
5495
  // src/history/projectHistory.ts
5474
- import { randomUUID as randomUUID8 } from "crypto";
5475
- import { readdir as readdir2, rm as rm2, rmdir, writeFile } from "fs/promises";
5496
+ import { randomUUID as randomUUID9 } from "crypto";
5497
+ import { readdir as readdir3, rm as rm3, rmdir, writeFile } from "fs/promises";
5476
5498
  import {
5477
5499
  existsSync as existsSync11,
5478
5500
  lstatSync as lstatSync4,
5479
- statSync as statSync9,
5501
+ statSync as statSync10,
5480
5502
  mkdirSync as mkdirSync11,
5481
5503
  readdirSync as readdirSync7,
5482
- readFileSync as readFileSync18,
5483
- realpathSync as realpathSync3,
5504
+ readFileSync as readFileSync19,
5505
+ realpathSync,
5484
5506
  rmSync as rmSync6
5485
5507
  } from "fs";
5486
5508
  import { homedir as homedir2 } from "os";
5487
- import { dirname as dirname10, isAbsolute as isAbsolute3, join as join20, relative as relative6, resolve as resolve8, sep as sep4 } from "path";
5509
+ import { dirname as dirname10, isAbsolute as isAbsolute2, join as join20, relative as relative5, resolve as resolve8, sep as sep3 } from "path";
5488
5510
 
5489
5511
  // src/history/blobStore.ts
5490
5512
  import { createHash as createHash7, randomUUID as randomUUID5 } from "crypto";
5491
5513
  import { constants as constants3, createReadStream as createReadStream2, renameSync as renameSync4 } from "fs";
5492
5514
  import { copyFile, mkdir, readdir, readFile as readFile2, rename, rm, stat as stat2 } from "fs/promises";
5493
- import { dirname as dirname8, join as join17 } from "path";
5515
+ import { dirname as dirname7, join as join16 } from "path";
5494
5516
  var BLOB_HASH = /^[0-9a-f]{64}$/;
5495
5517
  async function hashFile(path) {
5496
5518
  const hash = createHash7("sha256");
@@ -5498,7 +5520,7 @@ async function hashFile(path) {
5498
5520
  return hash.digest("hex");
5499
5521
  }
5500
5522
  async function cloneOrCopy(from, to, beforeReplace) {
5501
- await mkdir(dirname8(to), { recursive: true });
5523
+ await mkdir(dirname7(to), { recursive: true });
5502
5524
  const temp = `${to}.${randomUUID5()}.tmp`;
5503
5525
  try {
5504
5526
  await copyFile(from, temp, constants3.COPYFILE_FICLONE);
@@ -5513,16 +5535,16 @@ async function openBlobStore(dir) {
5513
5535
  const sizes = /* @__PURE__ */ new Map();
5514
5536
  await mkdir(dir, { recursive: true });
5515
5537
  for (const shard of await readdir(dir))
5516
- for (const hash of await readdir(join17(dir, shard)).catch(() => []))
5517
- sizes.set(hash, (await stat2(join17(dir, shard, hash))).size);
5538
+ for (const hash of await readdir(join16(dir, shard)).catch(() => []))
5539
+ sizes.set(hash, (await stat2(join16(dir, shard, hash))).size);
5518
5540
  const pathOf = (hash) => {
5519
5541
  if (!BLOB_HASH.test(hash)) throw new Error("That is not a history blob.");
5520
- return join17(dir, hash.slice(0, 2), hash);
5542
+ return join16(dir, hash.slice(0, 2), hash);
5521
5543
  };
5522
5544
  let total = [...sizes.values()].reduce((sum, size) => sum + size, 0);
5523
5545
  return {
5524
5546
  async put(absPath) {
5525
- const temp = join17(dir, `incoming-${randomUUID5()}`);
5547
+ const temp = join16(dir, `incoming-${randomUUID5()}`);
5526
5548
  await mkdir(dir, { recursive: true });
5527
5549
  await copyFile(absPath, temp, constants3.COPYFILE_FICLONE);
5528
5550
  const hash = await hashFile(temp);
@@ -5530,7 +5552,7 @@ async function openBlobStore(dir) {
5530
5552
  await rm(temp, { force: true });
5531
5553
  return hash;
5532
5554
  }
5533
- await mkdir(dirname8(pathOf(hash)), { recursive: true });
5555
+ await mkdir(dirname7(pathOf(hash)), { recursive: true });
5534
5556
  const size = (await stat2(temp)).size;
5535
5557
  await rename(temp, pathOf(hash));
5536
5558
  sizes.set(hash, size);
@@ -5553,15 +5575,23 @@ async function openBlobStore(dir) {
5553
5575
  };
5554
5576
  }
5555
5577
 
5578
+ // src/history/pruneHistories.ts
5579
+ import { randomUUID as randomUUID8 } from "crypto";
5580
+ import { readdirSync as readdirSync6, readFileSync as readFileSync17, renameSync as renameSync5, statSync as statSync9, writeFileSync as writeFileSync9 } from "fs";
5581
+ import { lstat, readdir as readdir2, rm as rm2 } from "fs/promises";
5582
+ import { tmpdir as tmpdir2 } from "os";
5583
+ import { dirname as dirname8, join as join19 } from "path";
5584
+
5556
5585
  // src/history/historyId.ts
5557
5586
  import { randomUUID as randomUUID6 } from "crypto";
5558
5587
  import { existsSync as existsSync10, mkdirSync as mkdirSync8, readFileSync as readFileSync15, statSync as statSync8, writeFileSync as writeFileSync7 } from "fs";
5559
- import { join as join18, resolve as resolve7 } from "path";
5560
- var ID_PATH = join18(".hyperframes", "history-id");
5588
+ import { basename as basename3, join as join17, resolve as resolve7 } from "path";
5589
+ var ID_PATH = join17(".hyperframes", "history-id");
5561
5590
  var ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
5591
+ var isHistoryId = (name) => ID_SHAPE.test(name);
5562
5592
  function readId(projectDir) {
5563
5593
  try {
5564
- const id = readFileSync15(join18(projectDir, ID_PATH), "utf-8").trim();
5594
+ const id = readFileSync15(join17(projectDir, ID_PATH), "utf-8").trim();
5565
5595
  return ID_SHAPE.test(id) ? id : null;
5566
5596
  } catch {
5567
5597
  return null;
@@ -5569,36 +5599,62 @@ function readId(projectDir) {
5569
5599
  }
5570
5600
  var sameFolder = (a, b) => a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
5571
5601
  function isRecordedFolder(historyDir, folder) {
5602
+ const was = readRecord(historyDir);
5603
+ if (typeof was?.ino === "number")
5604
+ return sameFolder({ ino: was.ino, birthtimeMs: was.born ?? NaN }, folder);
5605
+ const unreadableSo0878KeptTheId = typeof was?.dir !== "string";
5606
+ if (unreadableSo0878KeptTheId) return true;
5607
+ return !isCopyOf0878Folder(was.dir, basename3(historyDir), folder);
5608
+ }
5609
+ function readRecord(historyDir) {
5610
+ try {
5611
+ return JSON.parse(readFileSync15(join17(historyDir, "project.json"), "utf-8"));
5612
+ } catch {
5613
+ return null;
5614
+ }
5615
+ }
5616
+ function isCopyOf0878Folder(recordedDir, id, folder) {
5617
+ let there;
5572
5618
  try {
5573
- const was = JSON.parse(readFileSync15(join18(historyDir, "project.json"), "utf-8"));
5574
- return sameFolder({ ino: was.ino, birthtimeMs: was.born }, folder);
5619
+ there = statSync8(recordedDir, { throwIfNoEntry: false });
5575
5620
  } catch {
5576
5621
  return false;
5577
5622
  }
5623
+ return !!there && !sameFolder(there, folder) && readId(recordedDir) === id;
5578
5624
  }
5579
5625
  function projectHistoryId(projectDir, historyRoot) {
5580
5626
  const dir = resolve7(projectDir);
5581
5627
  const folder = statSync8(dir);
5582
5628
  let id = readId(dir);
5583
- if (!id || existsSync10(join18(historyRoot, id)) && !isRecordedFolder(join18(historyRoot, id), folder)) {
5629
+ if (!id && existsSync10(join17(dir, ID_PATH)))
5630
+ throw new HistoryIdError(
5631
+ `${join17(dir, ID_PATH)} holds no history id this version can read; move it aside to start anew.`
5632
+ );
5633
+ if (!id || existsSync10(join17(historyRoot, id)) && !isRecordedFolder(join17(historyRoot, id), folder)) {
5584
5634
  id = randomUUID6();
5585
- mkdirSync8(join18(dir, ".hyperframes"), { recursive: true });
5586
- writeFileSync7(join18(dir, ID_PATH), `${id}
5635
+ mkdirSync8(join17(dir, ".hyperframes"), { recursive: true });
5636
+ writeFileSync7(join17(dir, ID_PATH), `${id}
5587
5637
  `);
5588
5638
  }
5589
- recordProject(join18(historyRoot, id), dir, folder);
5639
+ recordProject(join17(historyRoot, id), dir, folder);
5590
5640
  return id;
5591
5641
  }
5642
+ var HistoryIdError = class extends Error {
5643
+ constructor(message) {
5644
+ super(message);
5645
+ this.name = "HistoryIdError";
5646
+ }
5647
+ };
5592
5648
  function recordProject(historyDir, dir, folder) {
5593
- const record = { dir, ino: folder.ino, born: folder.birthtimeMs };
5649
+ const record = { dir, ino: folder.ino, born: folder.birthtimeMs, dev: folder.dev };
5594
5650
  mkdirSync8(historyDir, { recursive: true });
5595
- replaceFileAtomically(join18(historyDir, "project.json"), JSON.stringify(record), 420);
5651
+ replaceFileAtomically(join17(historyDir, "project.json"), JSON.stringify(record), 420);
5596
5652
  }
5597
5653
 
5598
5654
  // src/history/ownerLock.ts
5599
5655
  import { randomUUID as randomUUID7 } from "crypto";
5600
5656
  import { linkSync, mkdirSync as mkdirSync9, readFileSync as readFileSync16, rmSync as rmSync5, writeFileSync as writeFileSync8 } from "fs";
5601
- import { join as join19 } from "path";
5657
+ import { join as join18 } from "path";
5602
5658
  var HistoryBusyError = class extends Error {
5603
5659
  constructor(pid) {
5604
5660
  super(`This project's history is open in another process (pid ${pid}).`);
@@ -5657,7 +5713,7 @@ function evictDeadOwner(file) {
5657
5713
  }
5658
5714
  }
5659
5715
  async function takeHistoryOwnership(home, waitMs) {
5660
- const file = join19(home, "owner.pid");
5716
+ const file = join18(home, "owner.pid");
5661
5717
  const deadline = Date.now() + waitMs;
5662
5718
  mkdirSync9(home, { recursive: true });
5663
5719
  for (; ; ) {
@@ -5676,8 +5732,144 @@ async function takeHistoryOwnership(home, waitMs) {
5676
5732
  }
5677
5733
  }
5678
5734
 
5735
+ // src/history/pruneHistories.ts
5736
+ var DAY_MS = 24 * 60 * 60 * 1e3;
5737
+ var KEEP_GONE_PROJECT_HISTORY_MS = 14 * DAY_MS;
5738
+ var PRUNED_PREFIX = ".pruned-";
5739
+ var LAST_PRUNE_FILE = ".last-prune";
5740
+ async function pruneGoneProjectHistories(historyRoot, options = {}) {
5741
+ return (await pruneWithin(historyRoot, options)).pruned;
5742
+ }
5743
+ async function pruneWithin(historyRoot, {
5744
+ now = Date.now(),
5745
+ dryRun = false,
5746
+ tempDir = tmpdir2(),
5747
+ onError = (error) => console.warn("[history] could not prune a history:", error),
5748
+ budgetMs = Infinity,
5749
+ measure = true
5750
+ }) {
5751
+ const started = performance.now();
5752
+ let names;
5753
+ try {
5754
+ names = readdirSync6(historyRoot);
5755
+ } catch (error) {
5756
+ if (error.code === "ENOENT") return { pruned: [], finished: true };
5757
+ throw error;
5758
+ }
5759
+ const pruned = [];
5760
+ for (const name of names) {
5761
+ if (performance.now() - started >= budgetMs) return { pruned, finished: false };
5762
+ try {
5763
+ const gone = await pruneEntry(historyRoot, name, { now, dryRun, tempDir, measure });
5764
+ if (gone) pruned.push(gone);
5765
+ } catch (error) {
5766
+ onError(error);
5767
+ }
5768
+ }
5769
+ return { pruned, finished: true };
5770
+ }
5771
+ async function pruneEntry(historyRoot, name, run) {
5772
+ if (name.startsWith(PRUNED_PREFIX)) {
5773
+ if (!run.dryRun) await rm2(join19(historyRoot, name), { recursive: true, force: true });
5774
+ return null;
5775
+ }
5776
+ return isHistoryId(name) ? pruneOne(historyRoot, name, run) : null;
5777
+ }
5778
+ async function pruneOne(historyRoot, id, run) {
5779
+ const home = join19(historyRoot, id);
5780
+ if (abandonedProject(home, id, run.now, run.tempDir) === null) return null;
5781
+ const release = await takeUnlessBusy(home);
5782
+ if (!release) return null;
5783
+ let taken;
5784
+ try {
5785
+ taken = recheckAndMoveAway(historyRoot, id, run);
5786
+ } catch (error) {
5787
+ release();
5788
+ throw error;
5789
+ }
5790
+ if (!taken?.trash) release();
5791
+ if (!taken) return null;
5792
+ const bytes = await clearAway(taken.trash, home, run.measure);
5793
+ return { id, projectDir: taken.projectDir, bytes };
5794
+ }
5795
+ async function clearAway(trash, home, measure) {
5796
+ const bytes = measure ? await bytesIn(trash ?? home) : 0;
5797
+ if (trash) await rm2(trash, { recursive: true, force: true });
5798
+ return bytes;
5799
+ }
5800
+ async function takeUnlessBusy(home) {
5801
+ try {
5802
+ return await takeHistoryOwnership(home, 0);
5803
+ } catch (error) {
5804
+ if (error instanceof HistoryBusyError) return null;
5805
+ throw error;
5806
+ }
5807
+ }
5808
+ function recheckAndMoveAway(historyRoot, id, { now, dryRun, tempDir }) {
5809
+ const home = join19(historyRoot, id);
5810
+ const projectDir = abandonedProject(home, id, now, tempDir);
5811
+ if (projectDir === null) return null;
5812
+ if (dryRun) return { projectDir, trash: null };
5813
+ const trash = join19(historyRoot, `${PRUNED_PREFIX}${id}-${randomUUID8()}`);
5814
+ renameSync5(home, trash);
5815
+ return { projectDir, trash };
5816
+ }
5817
+ function abandonedProject(home, id, now, tempDir) {
5818
+ const record = readRecord(home);
5819
+ const dir = record?.dir;
5820
+ if (typeof dir !== "string") return null;
5821
+ if (now - lastUsed(home) < KEEP_GONE_PROJECT_HISTORY_MS && !isSafePath(tempDir, dir)) return null;
5822
+ return projectGone(dir, id, record?.dev, tempDir) ? dir : null;
5823
+ }
5824
+ function projectGone(dir, id, dev, tempDir) {
5825
+ if (!statSync9(join19(dir, ID_PATH), { throwIfNoEntry: false }))
5826
+ return diskStillHere(dir, dev, tempDir);
5827
+ const there = readId(dir);
5828
+ return there !== null && there !== id;
5829
+ }
5830
+ function diskStillHere(dir, dev, tempDir) {
5831
+ let at = dir;
5832
+ let stat3 = statSync9(at, { throwIfNoEntry: false });
5833
+ while (!stat3) {
5834
+ if (dirname8(at) === at) return false;
5835
+ at = dirname8(at);
5836
+ stat3 = statSync9(at, { throwIfNoEntry: false });
5837
+ }
5838
+ if (typeof dev === "number") return stat3.dev === dev;
5839
+ return at === dir || isSafePath(tempDir, at);
5840
+ }
5841
+ function lastUsed(home) {
5842
+ const mtime = (file) => statSync9(join19(home, file), { throwIfNoEntry: false })?.mtimeMs ?? 0;
5843
+ return Math.max(mtime("project.json"), mtime("log.jsonl"));
5844
+ }
5845
+ async function bytesIn(dir) {
5846
+ let total = 0;
5847
+ for (const entry of await readdir2(dir, { withFileTypes: true })) {
5848
+ const path = join19(dir, entry.name);
5849
+ total += entry.isDirectory() ? await bytesIn(path) : (await lstat(path)).size;
5850
+ }
5851
+ return total;
5852
+ }
5853
+ function prunedWithinADay(stamp, now) {
5854
+ try {
5855
+ const last = Number(readFileSync17(stamp, "utf-8"));
5856
+ return last <= now && now - last < DAY_MS;
5857
+ } catch {
5858
+ return false;
5859
+ }
5860
+ }
5861
+ var pruning = /* @__PURE__ */ new Set();
5862
+ function pruneGoneProjectHistoriesDaily(historyRoot, now, { onError, budgetMs } = {}) {
5863
+ const stamp = join19(historyRoot, LAST_PRUNE_FILE);
5864
+ if (pruning.has(historyRoot) || prunedWithinADay(stamp, now)) return;
5865
+ pruning.add(historyRoot);
5866
+ setImmediate(() => {
5867
+ pruneWithin(historyRoot, { now, onError, budgetMs, measure: false }).then(({ finished }) => finished && writeFileSync9(stamp, String(now))).catch((error) => onError?.(error)).finally(() => pruning.delete(historyRoot));
5868
+ });
5869
+ }
5870
+
5679
5871
  // src/history/historyLog.ts
5680
- import { closeSync as closeSync5, constants as constants4, mkdirSync as mkdirSync10, openSync as openSync5, readFileSync as readFileSync17, writeSync as writeSync2 } from "fs";
5872
+ import { closeSync as closeSync5, constants as constants4, mkdirSync as mkdirSync10, openSync as openSync5, readFileSync as readFileSync18, writeSync as writeSync2 } from "fs";
5681
5873
  import { dirname as dirname9 } from "path";
5682
5874
  var START = "start";
5683
5875
  function parseRecord(line) {
@@ -5690,7 +5882,7 @@ function parseRecord(line) {
5690
5882
  function readLog(file, onUnreadable) {
5691
5883
  let text2;
5692
5884
  try {
5693
- text2 = readFileSync17(file, "utf-8");
5885
+ text2 = readFileSync18(file, "utf-8");
5694
5886
  } catch {
5695
5887
  return null;
5696
5888
  }
@@ -5823,7 +6015,7 @@ var isMissingFolder = (error) => ["ENOENT", "ENOTDIR"].includes(error.code ?? ""
5823
6015
  async function removeEmptyFolders(dir) {
5824
6016
  let entries;
5825
6017
  try {
5826
- entries = await readdir2(dir, { withFileTypes: true });
6018
+ entries = await readdir3(dir, { withFileTypes: true });
5827
6019
  } catch (error) {
5828
6020
  if (isMissingFolder(error)) return;
5829
6021
  throw error;
@@ -5841,8 +6033,8 @@ function fileLeftIn(dir, at, deleted) {
5841
6033
  return void 0;
5842
6034
  }
5843
6035
  function ignoresCase(dir) {
5844
- const self = statSync9(join20(dir, ID_PATH), { throwIfNoEntry: false });
5845
- const other = statSync9(join20(dir, ID_PATH.toUpperCase()), { throwIfNoEntry: false });
6036
+ const self = statSync10(join20(dir, ID_PATH), { throwIfNoEntry: false });
6037
+ const other = statSync10(join20(dir, ID_PATH.toUpperCase()), { throwIfNoEntry: false });
5846
6038
  return self !== void 0 && other?.ino === self.ino && other.dev === self.dev;
5847
6039
  }
5848
6040
  var blocks = (removed, added) => added.startsWith(`${removed}/`) || removed.startsWith(`${added}/`);
@@ -5861,7 +6053,7 @@ var Engine = class {
5861
6053
  this.dir = resolve8(options.projectDir);
5862
6054
  this.home = join20(options.historyRoot, projectId);
5863
6055
  this.foldsCase = ignoresCase(this.dir);
5864
- const folder = statSync9(this.dir, { throwIfNoEntry: false });
6056
+ const folder = statSync10(this.dir, { throwIfNoEntry: false });
5865
6057
  if (!folder || readId(this.dir) !== projectId || !isRecordedFolder(this.home, folder))
5866
6058
  throw this.replaced();
5867
6059
  this.folder = { dev: folder.dev, ino: folder.ino, birthtimeMs: folder.birthtimeMs };
@@ -5907,7 +6099,7 @@ var Engine = class {
5907
6099
  }
5908
6100
  /** Another project if the folder or its id changed, or the id is gone: a recreated folder may reuse the inode. */
5909
6101
  whereFolder() {
5910
- const now = statSync9(this.dir, { throwIfNoEntry: false });
6102
+ const now = statSync10(this.dir, { throwIfNoEntry: false });
5911
6103
  if (!now) return "gone";
5912
6104
  const same = now.dev === this.folder.dev && sameFolder(now, this.folder);
5913
6105
  return same && readId(this.dir) === this.projectId ? "here" : "replaced";
@@ -5970,7 +6162,7 @@ var Engine = class {
5970
6162
  }
5971
6163
  readStatCache() {
5972
6164
  try {
5973
- const files = JSON.parse(readFileSync18(join20(this.home, "stat.json"), "utf-8"));
6165
+ const files = JSON.parse(readFileSync19(join20(this.home, "stat.json"), "utf-8"));
5974
6166
  return new Map(Object.entries(files));
5975
6167
  } catch {
5976
6168
  return /* @__PURE__ */ new Map();
@@ -6082,7 +6274,7 @@ var Engine = class {
6082
6274
  return group && taken.every((change) => after(change.path, change.before) === change.before) && [...group.changes.values()].every(untouched) ? group : null;
6083
6275
  }
6084
6276
  logPath(path) {
6085
- return relative6(this.dir, resolve8(this.dir, path)).split(sep4).join("/");
6277
+ return relative5(this.dir, resolve8(this.dir, path)).split(sep3).join("/");
6086
6278
  }
6087
6279
  /** Takes `paths`' uncommitted changes from others, cut at what `who` overwrote. */
6088
6280
  async takeClaimed(who, paths, overwrote) {
@@ -6126,12 +6318,12 @@ var Engine = class {
6126
6318
  if (hash === change.before || hash === told) return hash;
6127
6319
  }
6128
6320
  if (bytes === void 0) return void 0;
6129
- const staged = join20(this.home, `overwrote-${randomUUID8()}`);
6321
+ const staged = join20(this.home, `overwrote-${randomUUID9()}`);
6130
6322
  await writeFile(staged, bytes);
6131
6323
  try {
6132
6324
  return await this.blobs.put(staged);
6133
6325
  } finally {
6134
- await rm2(staged, { force: true });
6326
+ await rm3(staged, { force: true });
6135
6327
  }
6136
6328
  }
6137
6329
  cutOut(group, change, cut) {
@@ -6148,6 +6340,7 @@ var Engine = class {
6148
6340
  part.changes.set(path, window.changes.get(path));
6149
6341
  window.changes.delete(path);
6150
6342
  }
6343
+ (window.parts ??= /* @__PURE__ */ new Set()).add(part.id);
6151
6344
  window.entry = await this.commit(part);
6152
6345
  }
6153
6346
  holdClaim(group, key, idleMs = this.options.quietMs ?? 2e3) {
@@ -6180,7 +6373,7 @@ var Engine = class {
6180
6373
  return this.outside;
6181
6374
  }
6182
6375
  newGroup(who, label) {
6183
- return { id: randomUUID8(), who, label, startedAt: this.now(), changes: /* @__PURE__ */ new Map() };
6376
+ return { id: randomUUID9(), who, label, startedAt: this.now(), changes: /* @__PURE__ */ new Map() };
6184
6377
  }
6185
6378
  async commitOutside() {
6186
6379
  if (this.outside?.changes.size) await this.commitClaim();
@@ -6309,8 +6502,8 @@ var Engine = class {
6309
6502
  if (!standing) return void 0;
6310
6503
  if (standing.stat.isSymbolicLink()) return standing.at;
6311
6504
  if (standing.at === path && !standing.stat.isDirectory()) return void 0;
6312
- const real = realpathSync3.native(join20(this.dir, standing.at));
6313
- const at = relative6(realpathSync3.native(this.dir), real).split(sep4).join("/");
6505
+ const real = realpathSync.native(join20(this.dir, standing.at));
6506
+ const at = relative5(realpathSync.native(this.dir), real).split(sep3).join("/");
6314
6507
  if (standing.at !== path) return deleted.has(at) ? void 0 : at;
6315
6508
  return fileLeftIn(real, at, deleted);
6316
6509
  }
@@ -6387,7 +6580,13 @@ var Engine = class {
6387
6580
  (group) => group?.changes.size && mine(group.who)
6388
6581
  );
6389
6582
  if (pending) return direction === "back" ? this.pendingEntry(pending) : void 0;
6390
- return stepTarget(this.log.entries, direction, (entry) => mine(entry.who));
6583
+ const everyone = this.options.undoScope === "everyone";
6584
+ const ofOpenTurn = (entry) => this.windows.some((open2) => open2.parts?.has(entry.id));
6585
+ return stepTarget(
6586
+ this.log.entries,
6587
+ direction,
6588
+ (entry) => mine(entry.who) || everyone && !ofOpenTurn(entry)
6589
+ );
6391
6590
  }
6392
6591
  /** A pending group as the entry it becomes once committed (a window's part gets a fresh id). */
6393
6592
  pendingEntry(group) {
@@ -6462,14 +6661,14 @@ var Engine = class {
6462
6661
  const dest = resolve8(emptyDir);
6463
6662
  if (isWithin(this.dir, dest))
6464
6663
  throw new Error(`Checkout writes outside the project only: ${dest}`);
6465
- if ((await readdir2(dest).catch(missingIsEmpty)).length > 0)
6664
+ if ((await readdir3(dest).catch(missingIsEmpty)).length > 0)
6466
6665
  throw new Error(`Checkout writes into an empty folder only: ${dest}`);
6467
6666
  const files = manifestAround(this.log, entryId, side);
6468
6667
  try {
6469
6668
  for (const [path, hash] of files) await this.blobs.writeTo(hash, join20(dest, path));
6470
6669
  } catch (error) {
6471
- for (const name of await readdir2(dest).catch(() => []))
6472
- await rm2(join20(dest, name), { recursive: true, force: true });
6670
+ for (const name of await readdir3(dest).catch(() => []))
6671
+ await rm3(join20(dest, name), { recursive: true, force: true });
6473
6672
  throw error;
6474
6673
  }
6475
6674
  }),
@@ -6506,8 +6705,8 @@ var Engine = class {
6506
6705
  function isWithin(dir, path) {
6507
6706
  let probe = resolve8(path);
6508
6707
  while (!existsSync11(probe) && dirname10(probe) !== probe) probe = dirname10(probe);
6509
- const rel = relative6(realpathSync3.native(dir), realpathSync3.native(probe));
6510
- return rel === "" || rel !== ".." && !rel.startsWith(`..${sep4}`) && !isAbsolute3(rel);
6708
+ const rel = relative5(realpathSync.native(dir), realpathSync.native(probe));
6709
+ return rel === "" || rel !== ".." && !rel.startsWith(`..${sep3}`) && !isAbsolute2(rel);
6511
6710
  }
6512
6711
  function missingIsEmpty(error) {
6513
6712
  if (error.code === "ENOENT") return [];
@@ -6531,6 +6730,10 @@ async function openProjectHistory(options) {
6531
6730
  const blobs = await openBlobStore(join20(options.historyRoot, projectId, "blobs"));
6532
6731
  const engine = new Engine(options, projectId, blobs);
6533
6732
  await engine.queue(() => engine.start());
6733
+ pruneGoneProjectHistoriesDaily(options.historyRoot, engine.now(), {
6734
+ onError: options.onError,
6735
+ budgetMs: options.pruneGoneProjectsBudgetMs
6736
+ });
6534
6737
  const api = engine.api();
6535
6738
  return {
6536
6739
  ...api,
@@ -6777,7 +6980,7 @@ function numericAttribute(element, name) {
6777
6980
 
6778
6981
  // src/helpers/previewDocumentStore.ts
6779
6982
  import { createHash as createHash8 } from "crypto";
6780
- import { mkdirSync as mkdirSync12, readFileSync as readFileSync19, readdirSync as readdirSync8, rmSync as rmSync7 } from "fs";
6983
+ import { mkdirSync as mkdirSync12, readFileSync as readFileSync20, readdirSync as readdirSync8, rmSync as rmSync7 } from "fs";
6781
6984
  import { join as join21 } from "path";
6782
6985
  function createPreviewDocumentStore(dir, salt) {
6783
6986
  const fileFor = (key) => join21(dir, `${createHash8("sha256").update(`${salt}
@@ -6785,7 +6988,7 @@ ${key}`).digest("hex").slice(0, 32)}.html`);
6785
6988
  return {
6786
6989
  read(key) {
6787
6990
  try {
6788
- return readFileSync19(fileFor(key), "utf-8");
6991
+ return readFileSync20(fileFor(key), "utf-8");
6789
6992
  } catch {
6790
6993
  return null;
6791
6994
  }
@@ -6877,6 +7080,7 @@ export {
6877
7080
  START as HISTORY_START,
6878
7081
  HistoryBusyError,
6879
7082
  HistoryClosedError,
7083
+ HistoryIdError,
6880
7084
  MAX_WINDOW_IDLE_MS,
6881
7085
  MIME_TYPES,
6882
7086
  PREVIEW_BUNDLE_OPTIONS,
@@ -6908,6 +7112,7 @@ export {
6908
7112
  openProjectHistory,
6909
7113
  parseSourceDocument,
6910
7114
  patchElementInHtml,
7115
+ pruneGoneProjectHistories,
6911
7116
  removeElementFromHtml,
6912
7117
  requestSubPath,
6913
7118
  settledFileTag,