@hyperframes/studio-server 0.8.81 → 0.8.83

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,18 +22,21 @@ import {
22
22
  isAutoProxyEnabled,
23
23
  proxyEtagSalt,
24
24
  resolvePreviewMediaCodecProbeCache
25
- } from "./chunk-FTDTO3QP.js";
25
+ } from "./chunk-HWBORC2U.js";
26
26
  import {
27
27
  ProxyCapacityError,
28
28
  ProxyTranscodeError,
29
29
  resolveProxy
30
- } from "./chunk-G6IIFKXY.js";
30
+ } from "./chunk-EL3UQI46.js";
31
31
  import {
32
32
  PROXY_VARIANT_CONFIG,
33
33
  decideMediaProxyEligibility,
34
+ folderGone,
34
35
  isInHiddenOrVendorDir,
36
+ isProjectRootMissing,
35
37
  isProxyVariantRequest,
36
38
  isSafePath,
39
+ mkdirWithinProject,
37
40
  pinWithinProject,
38
41
  probeAssetCodec,
39
42
  probeMediaMetadata,
@@ -43,7 +46,7 @@ import {
43
46
  resolveProxyVariantRequest,
44
47
  resolveWithinProject,
45
48
  walkDir
46
- } from "./chunk-TJQ76IEA.js";
49
+ } from "./chunk-WQ2WF2YJ.js";
47
50
  import {
48
51
  STUDIO_MANUAL_EDITS_PATH,
49
52
  createStudioManualEditsRenderBodyScript,
@@ -64,15 +67,7 @@ import { join as join2 } from "path";
64
67
  // src/helpers/hfIdPersist.ts
65
68
  import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
66
69
  import { createHash } from "crypto";
67
- import {
68
- closeSync,
69
- constants,
70
- fstatSync,
71
- mkdirSync,
72
- openSync,
73
- readFileSync,
74
- writeFileSync
75
- } from "fs";
70
+ import { closeSync, constants, fstatSync, openSync, readFileSync, writeFileSync } from "fs";
76
71
  import { dirname, join } from "path";
77
72
 
78
73
  // src/helpers/atomicFile.ts
@@ -123,7 +118,7 @@ function stampProjectHfIds(projectDir) {
123
118
  next[file] = hash;
124
119
  }
125
120
  try {
126
- mkdirSync(dirname(recordPath), { recursive: true });
121
+ mkdirWithinProject(projectDir, dirname(recordPath));
127
122
  writeFileSync(recordPath, JSON.stringify(next));
128
123
  } catch {
129
124
  }
@@ -408,7 +403,6 @@ import {
408
403
  readlinkSync,
409
404
  writeFileSync as writeFileSync5,
410
405
  writeSync,
411
- mkdirSync as mkdirSync4,
412
406
  unlinkSync as unlinkSync2,
413
407
  rmSync as rmSync3,
414
408
  statSync as statSync3,
@@ -469,7 +463,7 @@ import { spawn } from "child_process";
469
463
  import {
470
464
  existsSync,
471
465
  writeFileSync as writeFileSync2,
472
- mkdirSync as mkdirSync2,
466
+ mkdirSync,
473
467
  lstatSync as lstatSync2,
474
468
  mkdtempSync,
475
469
  renameSync,
@@ -558,7 +552,7 @@ function isWaveformCacheDirectory(cacheDir) {
558
552
  function writeWaveformCache(cachePath, peaks) {
559
553
  const cacheDir = dirname2(cachePath);
560
554
  try {
561
- mkdirSync2(cacheDir, { mode: 448 });
555
+ mkdirSync(cacheDir, { mode: 448 });
562
556
  } catch (error) {
563
557
  if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
564
558
  }
@@ -583,27 +577,41 @@ import { spawnSync } from "child_process";
583
577
  import { mkdtempSync as mkdtempSync2, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
584
578
  import { tmpdir } from "os";
585
579
  import { basename, join as join4 } from "path";
580
+ import { FFPROBE_PATH_ENV, findFfBinary as findFfBinary2 } from "@hyperframes/parsers/ff-binaries";
586
581
  var VIDEO_EXT = /\.(mp4|webm|mov|mkv|avi|m4v|mxf|mts|m2ts|ts)$/i;
587
- var AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
588
- function validateUploadedMedia(filePath, runner = spawnSync) {
582
+ var AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac|flac)$/i;
583
+ var defaultRunner = spawnSync;
584
+ var FFPROBE_MISSING = "not checked: ffprobe was not found. Install FFmpeg or set HYPERFRAMES_FFPROBE_PATH.";
585
+ function validateUploadedMedia(filePath, runner = defaultRunner) {
589
586
  const isVideo = VIDEO_EXT.test(filePath);
590
587
  const isAudio = AUDIO_EXT.test(filePath);
591
588
  if (!isVideo && !isAudio) {
592
589
  return { ok: true };
593
590
  }
591
+ const ffprobe = findFfBinary2("ffprobe") ?? (runner === defaultRunner ? void 0 : "ffprobe");
592
+ if (!ffprobe) return { ok: true, unchecked: FFPROBE_MISSING };
594
593
  const result = runner(
595
- "ffprobe",
594
+ ffprobe,
596
595
  ["-v", "error", "-show_entries", "stream=codec_type", "-of", "json", "--", filePath],
597
596
  { windowsHide: true }
598
597
  );
598
+ if (result.error && process.env[FFPROBE_PATH_ENV]?.trim()) {
599
+ return {
600
+ ok: false,
601
+ reason: `${FFPROBE_PATH_ENV} names "${ffprobe}", which cannot run: fix it or unset it`
602
+ };
603
+ }
599
604
  if (result.error?.code === "ENOENT") {
600
- return { ok: true };
605
+ return { ok: true, unchecked: FFPROBE_MISSING };
601
606
  }
602
607
  if (result.status !== 0) {
603
608
  return { ok: false, reason: "ffprobe failed to read the media file" };
604
609
  }
610
+ return checkStreams(String(result.stdout || "{}"), isVideo, isAudio);
611
+ }
612
+ function checkStreams(stdout, isVideo, isAudio) {
605
613
  try {
606
- const parsed = JSON.parse(String(result.stdout || "{}"));
614
+ const parsed = JSON.parse(stdout);
607
615
  const streams = parsed.streams ?? [];
608
616
  const hasVideo = streams.some((stream) => stream.codec_type === "video");
609
617
  const hasAudio = streams.some((stream) => stream.codec_type === "audio");
@@ -618,7 +626,7 @@ function validateUploadedMedia(filePath, runner = spawnSync) {
618
626
  return { ok: false, reason: "ffprobe returned unreadable media metadata" };
619
627
  }
620
628
  }
621
- function validateUploadedMediaBuffer(fileName, buffer, runner = spawnSync) {
629
+ function validateUploadedMediaBuffer(fileName, buffer, runner = defaultRunner) {
622
630
  const tempDir = mkdtempSync2(join4(tmpdir(), "hyperframes-upload-"));
623
631
  const tempPath = join4(tempDir, basename(fileName));
624
632
  try {
@@ -630,7 +638,7 @@ function validateUploadedMediaBuffer(fileName, buffer, runner = spawnSync) {
630
638
  }
631
639
 
632
640
  // src/helpers/backupJournal.ts
633
- import { mkdirSync as mkdirSync3, readdirSync as readdirSync2, readFileSync as readFileSync3, unlinkSync, writeFileSync as writeFileSync4 } from "fs";
641
+ import { readdirSync as readdirSync2, readFileSync as readFileSync3, unlinkSync, writeFileSync as writeFileSync4 } from "fs";
634
642
  import { Buffer as Buffer2 } from "buffer";
635
643
  import { join as join5, relative as relative2 } from "path";
636
644
  var DEFAULT_KEEP_PER_FILE = 10;
@@ -652,13 +660,14 @@ function snapshotBeforeWrite(projectDir, absPath, options = {}) {
652
660
  const content = readFileSync3(absPath);
653
661
  const relativePath = relative2(projectDir, absPath);
654
662
  const backupDir = join5(projectDir, ".hyperframes", "backup");
655
- mkdirSync3(backupDir, { recursive: true });
663
+ mkdirWithinProject(projectDir, backupDir);
656
664
  const backupKey = backupKeyForPath(relativePath);
657
665
  const backupPath = nextBackupPath(backupDir, backupKey);
658
666
  writeFileSync4(backupPath, content);
659
667
  pruneBackups(backupDir, backupKey, options.keepPerFile ?? DEFAULT_KEEP_PER_FILE);
660
668
  return { backupPath };
661
669
  } catch (error) {
670
+ if (isProjectRootMissing(error)) throw error;
662
671
  if (error && typeof error === "object" && "code" in error && (error.code === "ENOENT" || error.code === "EISDIR")) {
663
672
  return { backupPath: null };
664
673
  }
@@ -697,6 +706,15 @@ function pruneBackups(backupDir, backupKey, keepPerFile) {
697
706
  }
698
707
  }
699
708
 
709
+ // src/helpers/projectDirMissing.ts
710
+ var PROJECT_DIR_MISSING = { error: "not found", why: "project_dir_missing" };
711
+ var projectDirMissing = (c) => c.json(PROJECT_DIR_MISSING, 404);
712
+ function replaceWithProjectDirMissing(c, hostHeaders) {
713
+ hostHeaders.delete("content-type");
714
+ c.res = void 0;
715
+ c.res = Response.json(PROJECT_DIR_MISSING, { status: 404, headers: hostHeaders });
716
+ }
717
+
700
718
  // src/helpers/fileVersion.ts
701
719
  import { createHash as createHash3, randomUUID as randomUUID2 } from "crypto";
702
720
  var RECEIPT_TTL_MS = 1e4;
@@ -891,6 +909,7 @@ import { existsSync as existsSync2, readFileSync as readFileSync5 } from "fs";
891
909
  import { randomUUID as randomUUID3 } from "crypto";
892
910
  import { dirname as dirname3, relative as relative3, resolve as resolve2, sep as sep2 } from "path";
893
911
  import { parseHTML } from "linkedom";
912
+ import { ProjectRootMissingError } from "@hyperframes/core";
894
913
  var CompositionInsertionError = class extends Error {
895
914
  constructor(message, status) {
896
915
  super(message);
@@ -920,6 +939,7 @@ function positiveAttribute(root, ...names) {
920
939
  }
921
940
  function canonicalProjectPath(projectDir, candidate) {
922
941
  if (!candidate) {
942
+ if (folderGone(projectDir)) throw new ProjectRootMissingError(projectDir);
923
943
  throw new CompositionInsertionError("Composition source escapes the project", 400);
924
944
  }
925
945
  if (!existsSync2(candidate)) {
@@ -1172,10 +1192,8 @@ async function resolveProjectPath(c, adapter, route, opts) {
1172
1192
  if (!project) {
1173
1193
  return { error: c.json({ error: "not found" }, 404) };
1174
1194
  }
1175
- if (!existsSync3(project.dir)) {
1176
- return {
1177
- error: c.json({ error: "not found", why: "project_dir_missing" }, 404)
1178
- };
1195
+ if (folderGone(project.dir)) {
1196
+ return { error: projectDirMissing(c) };
1179
1197
  }
1180
1198
  const filePath = requestSubPath(c.req.url, `projects/:id/${route}`);
1181
1199
  if (filePath.includes("\0")) {
@@ -1379,9 +1397,8 @@ async function parseMutationBody(c) {
1379
1397
  }
1380
1398
  return { target: body.target, body };
1381
1399
  }
1382
- function ensureDir(filePath) {
1383
- const dir = dirname4(filePath);
1384
- if (!existsSync3(dir)) mkdirSync4(dir, { recursive: true });
1400
+ function ensureDir(projectDir, filePath) {
1401
+ mkdirWithinProject(projectDir, dirname4(filePath));
1385
1402
  }
1386
1403
  function generateCopyPath(projectDir, originalPath) {
1387
1404
  const ext = originalPath.includes(".") ? "." + originalPath.split(".").pop() : "";
@@ -2502,6 +2519,7 @@ async function processUploadedFiles(formData, targetDir, projectDir) {
2502
2519
  const uploaded = [];
2503
2520
  const skipped = [];
2504
2521
  const invalid = [];
2522
+ const unchecked = [];
2505
2523
  const entries = formData.entries();
2506
2524
  const subDir = targetDir === projectDir ? "" : targetDir.slice(projectDir.length + 1);
2507
2525
  for (const [, value] of entries) {
@@ -2564,12 +2582,13 @@ async function processUploadedFiles(formData, targetDir, projectDir) {
2564
2582
  }
2565
2583
  const relativePath = subDir ? join6(subDir, finalName) : finalName;
2566
2584
  uploaded.push(relativePath);
2585
+ if (validation.unchecked) unchecked.push({ name: finalName, reason: validation.unchecked });
2567
2586
  if (isAudioFile(finalName)) {
2568
2587
  generateWaveformCache(projectDir, relativePath).catch(() => {
2569
2588
  });
2570
2589
  }
2571
2590
  }
2572
- return { uploaded, skipped, invalid };
2591
+ return { uploaded, skipped, invalid, unchecked };
2573
2592
  }
2574
2593
  function registerFileRoutes(api, adapter) {
2575
2594
  api.get("/projects/:id/files/*", async (c) => {
@@ -2629,7 +2648,7 @@ function registerFileRoutes(api, adapter) {
2629
2648
  let backup = { backupPath: null };
2630
2649
  let overwrote;
2631
2650
  if (createOnly) {
2632
- ensureDir(res.absPath);
2651
+ ensureDir(res.project.dir, res.absPath);
2633
2652
  let fd;
2634
2653
  try {
2635
2654
  fd = openSync2(res.absPath, "wx");
@@ -2711,7 +2730,7 @@ function registerFileRoutes(api, adapter) {
2711
2730
  api.post("/projects/:id/files/*", async (c) => {
2712
2731
  const res = await resolveProjectFile(c, adapter);
2713
2732
  if ("error" in res) return res.error;
2714
- ensureDir(res.absPath);
2733
+ ensureDir(res.project.dir, res.absPath);
2715
2734
  const body = Buffer.from(await c.req.arrayBuffer());
2716
2735
  try {
2717
2736
  writeFileSync5(res.absPath, body, { flag: "wx" });
@@ -3252,7 +3271,7 @@ function registerFileRoutes(api, adapter) {
3252
3271
  if (existsSync3(newAbs)) {
3253
3272
  return c.json({ error: "already exists" }, 409);
3254
3273
  }
3255
- ensureDir(newAbs);
3274
+ ensureDir(res.project.dir, newAbs);
3256
3275
  renameSync2(res.absPath, newAbs);
3257
3276
  const updatedFiles = updateReferences(res.project.dir, res.filePath, body.newPath);
3258
3277
  return c.json({ ok: true, path: body.newPath, updatedReferences: updatedFiles });
@@ -3273,7 +3292,7 @@ function registerFileRoutes(api, adapter) {
3273
3292
  if (!destAbs) {
3274
3293
  return c.json({ error: "forbidden" }, 403);
3275
3294
  }
3276
- ensureDir(destAbs);
3295
+ ensureDir(project.dir, destAbs);
3277
3296
  try {
3278
3297
  writeFileSync5(destAbs, readFileSync6(srcAbs), { flag: "wx" });
3279
3298
  } catch (error) {
@@ -3294,14 +3313,22 @@ function registerFileRoutes(api, adapter) {
3294
3313
  async (c) => {
3295
3314
  const project = await adapter.resolveProject(c.req.param("id"));
3296
3315
  if (!project) return c.json({ error: "not found" }, 404);
3316
+ if (folderGone(project.dir)) return projectDirMissing(c);
3297
3317
  const subDir = c.req.query("dir") ?? "";
3298
3318
  const targetDir = subDir ? resolveWithinProject(project.dir, subDir) : project.dir;
3299
3319
  if (!targetDir) return c.json({ error: "forbidden" }, 403);
3300
- if (subDir && !existsSync3(targetDir)) mkdirSync4(targetDir, { recursive: true });
3301
3320
  const formData = await c.req.formData();
3321
+ mkdirWithinProject(project.dir, targetDir);
3302
3322
  const result = await processUploadedFiles(formData, targetDir, project.dir);
3323
+ if (folderGone(project.dir)) return projectDirMissing(c);
3303
3324
  return c.json(
3304
- { ok: true, files: result.uploaded, skipped: result.skipped, invalid: result.invalid },
3325
+ {
3326
+ ok: true,
3327
+ files: result.uploaded,
3328
+ skipped: result.skipped,
3329
+ invalid: result.invalid,
3330
+ unchecked: result.unchecked
3331
+ },
3305
3332
  201
3306
3333
  );
3307
3334
  }
@@ -3387,6 +3414,8 @@ import {
3387
3414
  insertBeforeCloseTag as insertBeforeCloseTag2,
3388
3415
  stripEmbeddedRuntimeScripts as stripEmbeddedRuntimeScripts2
3389
3416
  } from "@hyperframes/core/compiler";
3417
+ import { STUDIO_PREVIEW_MARK_META } from "@hyperframes/core/studio-preview-mark";
3418
+ import { injectTagsAtHeadStart } from "@hyperframes/core/compiler/html-document";
3390
3419
  import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
3391
3420
 
3392
3421
  // src/helpers/subComposition.ts
@@ -3671,6 +3700,34 @@ function injectPreviewVariables(html, values) {
3671
3700
  return tag + html;
3672
3701
  }
3673
3702
 
3703
+ // src/helpers/lazyPreviewImages.ts
3704
+ import { findStartTags } from "@hyperframes/core/compiler/html-document";
3705
+ import { parseHTML as parseHTML4 } from "linkedom";
3706
+ import { STUDIO_PREVIEW_LAZY_ATTR } from "@hyperframes/core/studio-preview-mark";
3707
+ function startsAfterZero(el) {
3708
+ for (let node = el; node; node = node.parentElement) {
3709
+ if (Number(node.getAttribute("data-start")) > 0) return true;
3710
+ }
3711
+ return false;
3712
+ }
3713
+ var hasLoading = (img) => Array.from(img.attributes).some((attr) => attr.name.toLowerCase() === "loading");
3714
+ function lazyPreviewImages(html) {
3715
+ if (!/<!doctype|<html[\s>]/i.test(html)) return html;
3716
+ const images = [...parseHTML4(html).document.querySelectorAll("img")];
3717
+ const tags = findStartTags(html, "img");
3718
+ if (tags.length !== images.length) return html;
3719
+ const parts = [];
3720
+ let from = 0;
3721
+ images.forEach((img, i) => {
3722
+ if (hasLoading(img) || !startsAfterZero(img)) return;
3723
+ const at = (tags[i] ?? 0) + 4;
3724
+ parts.push(html.slice(from, at), ` loading="lazy" ${STUDIO_PREVIEW_LAZY_ATTR}`);
3725
+ from = at;
3726
+ });
3727
+ parts.push(html.slice(from));
3728
+ return parts.join("");
3729
+ }
3730
+
3674
3731
  // src/routes/preview.ts
3675
3732
  var PROJECT_SIGNATURE_META = "hyperframes-project-signature";
3676
3733
  var GSAP_CDN_VERSION = "3.15.0";
@@ -3763,7 +3820,8 @@ function injectStudioMotionScript(html, projectDir, activeCompositionPath) {
3763
3820
  false
3764
3821
  );
3765
3822
  }
3766
- var GSAP_CDN_FALLBACK_SCRIPT = `<script data-hf-gsap-fallback>
3823
+ var GSAP_FALLBACK_ATTR = "data-hf-gsap-fallback";
3824
+ var GSAP_CDN_FALLBACK_SCRIPT = `<script ${GSAP_FALLBACK_ATTR}>
3767
3825
  (function(){
3768
3826
  var cdnBase="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/";
3769
3827
  var loaded={};
@@ -3784,7 +3842,7 @@ var GSAP_CDN_FALLBACK_SCRIPT = `<script data-hf-gsap-fallback>
3784
3842
  })();
3785
3843
  </script>`;
3786
3844
  function injectGsapCdnFallback(html) {
3787
- if (html.includes("data-hf-gsap-fallback")) return html;
3845
+ if (html.includes(GSAP_FALLBACK_ATTR)) return html;
3788
3846
  if (html.includes("<head>")) return html.replace("<head>", "<head>" + GSAP_CDN_FALLBACK_SCRIPT);
3789
3847
  return GSAP_CDN_FALLBACK_SCRIPT + html;
3790
3848
  }
@@ -3810,11 +3868,13 @@ function previewVariablesFromRequest(rawVariables) {
3810
3868
  if (!parse2.ok) return { error: parse2.error };
3811
3869
  return { raw: rawVariables, values: parse2.values };
3812
3870
  }
3813
- function injectStudioPreviewAugmentations(html, adapter, projectDir, activeCompositionPath) {
3871
+ var PREVIEW_CAPTURE_PARAM = "hf-capture";
3872
+ function injectStudioPreviewAugmentations(html, adapter, projectDir, activeCompositionPath, capture) {
3873
+ const marked = capture ? html : injectTagsAtHeadStart(lazyPreviewImages(html), `<meta name="${STUDIO_PREVIEW_MARK_META}">`);
3814
3874
  return injectStudioMotionScript(
3815
3875
  injectMotionPathPluginIfNeeded(
3816
3876
  injectGsapCdnFallback(
3817
- injectProjectSignature(html, resolveProjectSignature(adapter, projectDir))
3877
+ injectProjectSignature(marked, resolveProjectSignature(adapter, projectDir))
3818
3878
  )
3819
3879
  ),
3820
3880
  projectDir,
@@ -3872,7 +3932,7 @@ function registerPreviewRoutes(api, adapter) {
3872
3932
  if (builtPreviews.size > 4) builtPreviews.delete(builtPreviews.keys().next().value);
3873
3933
  };
3874
3934
  const previewBuilds = /* @__PURE__ */ new Map();
3875
- async function buildPreview(project, previewVariables, builtKey) {
3935
+ async function buildPreview(project, previewVariables, builtKey, capture) {
3876
3936
  const diskMain = resolveProjectMainHtml(project.dir, project.id);
3877
3937
  const normalizedDisk = diskMain ? ensureHfIds3(diskMain.html) : null;
3878
3938
  try {
@@ -3902,7 +3962,8 @@ ${runtimeTag}`;
3902
3962
  ensureHfIds3(await transformPreviewHtml(bundled, adapter, project, mainCompositionPath)),
3903
3963
  adapter,
3904
3964
  project.dir,
3905
- mainCompositionPath
3965
+ mainCompositionPath,
3966
+ capture
3906
3967
  );
3907
3968
  if (previewVariables) bundled = injectPreviewVariables(bundled, previewVariables);
3908
3969
  bundled = await injectMediaCodecMap(
@@ -3914,7 +3975,7 @@ ${runtimeTag}`;
3914
3975
  );
3915
3976
  bundled = addScenePartsManifest(bundled, [`meta[name="${PROJECT_SIGNATURE_META}"]`]);
3916
3977
  rememberPreview(builtKey, bundled);
3917
- adapter.previewDocuments?.write(builtKey, bundled);
3978
+ if (!capture) adapter.previewDocuments?.write(builtKey, bundled);
3918
3979
  return bundled;
3919
3980
  } catch {
3920
3981
  const fallback = resolveProjectMainHtml(project.dir, project.id);
@@ -3924,7 +3985,8 @@ ${runtimeTag}`;
3924
3985
  await transformPreviewHtml(fallbackHtml, adapter, project, fallback.compositionPath),
3925
3986
  adapter,
3926
3987
  project.dir,
3927
- fallback.compositionPath
3988
+ fallback.compositionPath,
3989
+ capture
3928
3990
  );
3929
3991
  if (previewVariables) {
3930
3992
  fallbackAugmented = injectPreviewVariables(fallbackAugmented, previewVariables);
@@ -3948,7 +4010,8 @@ ${runtimeTag}`;
3948
4010
  const vars = previewVariablesFromRequest(c.req.query("variables"));
3949
4011
  if (vars.error !== void 0) return c.json({ error: vars.error }, 400);
3950
4012
  const previewVariables = vars.values;
3951
- const etag = `"preview:${signature}${variablesEtagSalt(vars.raw)}"`;
4013
+ const capture = c.req.query(PREVIEW_CAPTURE_PARAM) !== void 0;
4014
+ const etag = `"preview:${signature}${variablesEtagSalt(vars.raw)}${capture ? ":capture" : ""}"`;
3952
4015
  const ifNoneMatch = c.req.header("If-None-Match");
3953
4016
  if (ifNoneMatch === etag) {
3954
4017
  return new Response(null, {
@@ -3965,7 +4028,7 @@ ${etag}`;
3965
4028
  }
3966
4029
  let pending = previewBuilds.get(builtKey);
3967
4030
  if (!pending) {
3968
- pending = buildPreview(project, previewVariables, builtKey).finally(
4031
+ pending = buildPreview(project, previewVariables, builtKey, capture).finally(
3969
4032
  () => previewBuilds.delete(builtKey)
3970
4033
  );
3971
4034
  previewBuilds.set(builtKey, pending);
@@ -3997,7 +4060,8 @@ ${etag}`;
3997
4060
  return c.text("not found", 404);
3998
4061
  }
3999
4062
  const compPathHash = createHash4("sha1").update(compPath).digest("hex");
4000
- const etag = `"comp:v2:${compPathHash}:${signature}${variablesEtagSalt(vars.raw)}"`;
4063
+ const capture = c.req.query(PREVIEW_CAPTURE_PARAM) !== void 0;
4064
+ const etag = `"comp:v2:${compPathHash}:${signature}${variablesEtagSalt(vars.raw)}${capture ? ":capture" : ""}"`;
4001
4065
  const ifNoneMatch = c.req.header("If-None-Match");
4002
4066
  if (ifNoneMatch === etag) {
4003
4067
  return new Response(null, {
@@ -4018,7 +4082,7 @@ ${etag}`;
4018
4082
  if (!html) return c.text("not found", 404);
4019
4083
  recordPreviewReferences(project.dir, html);
4020
4084
  html = ensureHfIds3(await transformPreviewHtml(html, adapter, project, compPath));
4021
- html = injectStudioPreviewAugmentations(html, adapter, project.dir, compPath);
4085
+ html = injectStudioPreviewAugmentations(html, adapter, project.dir, compPath, capture);
4022
4086
  if (previewVariables) html = injectPreviewVariables(html, previewVariables);
4023
4087
  html = await injectMediaCodecMap(html, adapter, project.dir, compPath, mediaCodecProbeCache);
4024
4088
  return c.html(html, 200, previewCacheHeaders(etag));
@@ -4075,6 +4139,7 @@ ${etag}`;
4075
4139
  if (err instanceof ProxyCapacityError) {
4076
4140
  return c.text(err.message, 503, { "Retry-After": "5" });
4077
4141
  }
4142
+ if (isProjectRootMissing(err)) throw err;
4078
4143
  const message = err instanceof ProxyTranscodeError ? err.message : "proxy transcode failed";
4079
4144
  return c.text(message, 502);
4080
4145
  }
@@ -4176,7 +4241,7 @@ function registerLintRoutes(api, adapter) {
4176
4241
 
4177
4242
  // src/routes/render.ts
4178
4243
  import { streamSSE } from "hono/streaming";
4179
- import { existsSync as existsSync6, readFileSync as readFileSync10, mkdirSync as mkdirSync5, unlinkSync as unlinkSync3, readdirSync as readdirSync4, statSync as statSync5 } from "fs";
4244
+ import { existsSync as existsSync6, readFileSync as readFileSync10, unlinkSync as unlinkSync3, readdirSync as readdirSync4, statSync as statSync5 } from "fs";
4180
4245
  import { join as join11 } from "path";
4181
4246
  import { VALID_CANVAS_RESOLUTIONS } from "@hyperframes/parsers";
4182
4247
  import { formatRenderOutputTimestamp, parseFps } from "@hyperframes/core";
@@ -4229,6 +4294,7 @@ function registerRenderRoutes(api, adapter) {
4229
4294
  let composition;
4230
4295
  if (typeof body.composition === "string" && body.composition.length > 0) {
4231
4296
  if (!resolveWithinProject(project.dir, body.composition)) {
4297
+ if (folderGone(project.dir)) return projectDirMissing(c);
4232
4298
  return c.json({ error: "composition path must be within the project directory" }, 400);
4233
4299
  }
4234
4300
  composition = body.composition;
@@ -4243,7 +4309,7 @@ function registerRenderRoutes(api, adapter) {
4243
4309
  const now = /* @__PURE__ */ new Date();
4244
4310
  const jobId = `${project.id}_${formatRenderOutputTimestamp(now)}`;
4245
4311
  const rendersDir = adapter.rendersDir(project);
4246
- if (!existsSync6(rendersDir)) mkdirSync5(rendersDir, { recursive: true });
4312
+ mkdirWithinProject(project.dir, rendersDir);
4247
4313
  const ext = FORMAT_EXT[format] ?? ".mp4";
4248
4314
  const outputPath = join11(rendersDir, `${jobId}${ext}`);
4249
4315
  const jobState = adapter.startRender({
@@ -4624,7 +4690,6 @@ import {
4624
4690
  closeSync as closeSync3,
4625
4691
  fstatSync as fstatSync3,
4626
4692
  openSync as openSync3,
4627
- mkdirSync as mkdirSync6,
4628
4693
  readFileSync as readFileSync12,
4629
4694
  readdirSync as readdirSync5,
4630
4695
  renameSync as renameSync3,
@@ -4817,7 +4882,8 @@ function registerThumbnailRoutes(api, adapter) {
4817
4882
  const motion = manifestKey(join12(project.dir, STUDIO_MOTION_PATH));
4818
4883
  sourceMtime = Math.max(sourceMtime, manualEdits.mtimeMs, motion.mtimeMs);
4819
4884
  const projectUrl = `http://${c.req.header("host")}/api/projects/${encodeURIComponent(project.id)}`;
4820
- const previewUrl = compPath === "index.html" ? `${projectUrl}/preview` : `${projectUrl}/preview/comp/${compPath.split("/").map(encodeURIComponent).join("/")}`;
4885
+ const previewPath = compPath === "index.html" ? `${projectUrl}/preview` : `${projectUrl}/preview/comp/${compPath.split("/").map(encodeURIComponent).join("/")}`;
4886
+ const previewUrl = `${previewPath}?${PREVIEW_CAPTURE_PARAM}=1`;
4821
4887
  const cacheDir = join12(project.dir, ".thumbnails");
4822
4888
  const selectorKey = selector ? `_${selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}_${selectorIndex ?? 0}` : "";
4823
4889
  const urlVersionKey = urlVersion ? `_${urlVersion.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 32)}` : "";
@@ -4840,7 +4906,8 @@ function registerThumbnailRoutes(api, adapter) {
4840
4906
  headers: { "Content-Type": contentType, "Cache-Control": "no-cache" }
4841
4907
  });
4842
4908
  }
4843
- if (url.searchParams.get("cached") === "1") return c.body(null, 404);
4909
+ if (url.searchParams.get("cached") === "1")
4910
+ return c.body(null, 204, { "Cache-Control": "no-cache" });
4844
4911
  try {
4845
4912
  const buffer = await thumbnailGenerationCoordinator.acquire(
4846
4913
  cachePath,
@@ -4866,7 +4933,7 @@ function registerThumbnailRoutes(api, adapter) {
4866
4933
  if (afterGeneration?.project.dir !== project.dir || !inputsUnchanged(afterGeneration.signature) || !inputsUnchanged(createProjectSignature(project.dir))) {
4867
4934
  return generated;
4868
4935
  }
4869
- mkdirSync6(cacheDir, { recursive: true });
4936
+ mkdirWithinProject(project.dir, cacheDir);
4870
4937
  writeThumbnailAtomically(cachePath, generated);
4871
4938
  return generated;
4872
4939
  }
@@ -4885,6 +4952,7 @@ function registerThumbnailRoutes(api, adapter) {
4885
4952
  if (err instanceof DOMException && err.name === "AbortError") {
4886
4953
  return new Response(null, { status: 499 });
4887
4954
  }
4955
+ if (isProjectRootMissing(err)) throw err;
4888
4956
  const msg = err instanceof Error ? err.message : String(err);
4889
4957
  return c.json({ error: `Thumbnail generation failed: ${msg}` }, 500);
4890
4958
  }
@@ -5079,6 +5147,7 @@ function registerRegistryRoutes(api, adapter) {
5079
5147
  }
5080
5148
  const project = await adapter.resolveProject(c.req.param("id"));
5081
5149
  if (!project) return c.json({ error: "Project not found" }, 404);
5150
+ if (folderGone(project.dir)) return projectDirMissing(c);
5082
5151
  const body = await c.req.json().catch(() => null);
5083
5152
  if (!body?.blockName) {
5084
5153
  return c.json({ error: "blockName is required" }, 400);
@@ -5087,6 +5156,7 @@ function registerRegistryRoutes(api, adapter) {
5087
5156
  const result = await adapter.installRegistryBlock({ project, blockName: body.blockName });
5088
5157
  return c.json(result);
5089
5158
  } catch (err) {
5159
+ if (isProjectRootMissing(err)) return projectDirMissing(c);
5090
5160
  const message = err instanceof Error ? err.message : "Install failed";
5091
5161
  return c.json({ error: message }, 500);
5092
5162
  }
@@ -5193,8 +5263,8 @@ function registerSelectionRoutes(api, adapter) {
5193
5263
 
5194
5264
  // src/routes/media.ts
5195
5265
  import { streamSSE as streamSSE2 } from "hono/streaming";
5196
- import { existsSync as existsSync8, mkdirSync as mkdirSync7 } from "fs";
5197
- import { basename as basename2, dirname as dirname6, extname as extname2, join as join14 } from "path";
5266
+ import { existsSync as existsSync8 } from "fs";
5267
+ import { basename as basename2, dirname as dirname6, extname as extname2, join as join14, posix } from "path";
5198
5268
  var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([
5199
5269
  ".mp4",
5200
5270
  ".mov",
@@ -5227,21 +5297,31 @@ function slugFileBase(path) {
5227
5297
  const name = basename2(path, extname2(path)).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
5228
5298
  return name || "media";
5229
5299
  }
5230
- function uniqueAssetPath(projectDir, assetPath) {
5300
+ function uniqueAssetPath(projectDir, assetPath, taken) {
5231
5301
  const ext = extname2(assetPath);
5232
5302
  const withoutExt = assetPath.slice(0, -ext.length);
5233
5303
  let candidate = assetPath;
5234
- for (let index = 2; existsSync8(join14(projectDir, candidate)); index++) {
5304
+ for (let index = 2; taken.has(candidate) || existsSync8(join14(projectDir, candidate)); index++) {
5235
5305
  candidate = `${withoutExt}-${index}${ext}`;
5236
5306
  }
5237
5307
  return candidate;
5238
5308
  }
5239
- function defaultOutputPath(projectDir, inputPath) {
5309
+ function defaultOutputPath(projectDir, inputPath, taken) {
5240
5310
  const ext = isImagePath(inputPath) ? ".png" : ".webm";
5241
- return uniqueAssetPath(projectDir, `assets/cutouts/${slugFileBase(inputPath)}-cutout${ext}`);
5311
+ return uniqueAssetPath(
5312
+ projectDir,
5313
+ `assets/cutouts/${slugFileBase(inputPath)}-cutout${ext}`,
5314
+ taken
5315
+ );
5316
+ }
5317
+ function defaultPlatePath(projectDir, inputPath, taken) {
5318
+ return uniqueAssetPath(projectDir, `assets/cutouts/${slugFileBase(inputPath)}-plate.webm`, taken);
5242
5319
  }
5243
- function defaultPlatePath(projectDir, inputPath) {
5244
- return uniqueAssetPath(projectDir, `assets/cutouts/${slugFileBase(inputPath)}-plate.webm`);
5320
+ function outputsInFlight(mediaJobs) {
5321
+ const running = [...mediaJobs.values()].filter((job) => job.status === "processing");
5322
+ return new Set(
5323
+ running.flatMap((job) => [job.outputAssetPath, job.backgroundOutputAssetPath ?? ""])
5324
+ );
5245
5325
  }
5246
5326
  function makeJobId(projectId, mediaJobs) {
5247
5327
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
@@ -5310,15 +5390,16 @@ function registerMediaRoutes(api, adapter, options = {}) {
5310
5390
  if (!inputIsVideo && !inputIsImage) {
5311
5391
  return c.json({ error: "background removal supports video or image assets only" }, 400);
5312
5392
  }
5313
- const requestedOutput = body.outputPath ? normalizeProjectAssetPath(body.outputPath) : "";
5393
+ const requestedOutput = body.outputPath ? posix.normalize(normalizeProjectAssetPath(body.outputPath)) : "";
5314
5394
  if (requestedOutput && containsNullByte(requestedOutput)) {
5315
5395
  return c.json({ error: "forbidden" }, 403);
5316
5396
  }
5317
5397
  if (requestedOutput && !resolveWithinProject(project.dir, requestedOutput)) {
5318
5398
  return c.json({ error: "forbidden" }, 403);
5319
5399
  }
5320
- const outputAssetPath = requestedOutput ? uniqueAssetPath(project.dir, requestedOutput) : defaultOutputPath(project.dir, inputAssetPath);
5321
- const outputPath = resolveWithinProject(project.dir, outputAssetPath);
5400
+ const taken = outputsInFlight(mediaJobs);
5401
+ const outputAssetPath = requestedOutput ? uniqueAssetPath(project.dir, requestedOutput, taken) : defaultOutputPath(project.dir, inputAssetPath, taken);
5402
+ const outputPath = pinWithinProject(project.dir, outputAssetPath);
5322
5403
  if (!outputPath) return c.json({ error: "forbidden" }, 403);
5323
5404
  if (inputIsVideo && !VIDEO_OUTPUT_EXTENSIONS.has(extname2(outputAssetPath).toLowerCase())) {
5324
5405
  return c.json({ error: "video background removal output must be .webm or .mov" }, 400);
@@ -5332,14 +5413,15 @@ function registerMediaRoutes(api, adapter, options = {}) {
5332
5413
  if (!inputIsVideo) {
5333
5414
  return c.json({ error: "background plates are only supported for video inputs" }, 400);
5334
5415
  }
5335
- backgroundOutputAssetPath = defaultPlatePath(project.dir, inputAssetPath);
5336
- backgroundOutputPath = resolveWithinProject(project.dir, backgroundOutputAssetPath) ?? void 0;
5416
+ taken.add(outputAssetPath);
5417
+ backgroundOutputAssetPath = defaultPlatePath(project.dir, inputAssetPath, taken);
5418
+ backgroundOutputPath = pinWithinProject(project.dir, backgroundOutputAssetPath) ?? void 0;
5337
5419
  if (!backgroundOutputPath) {
5338
5420
  return c.json({ error: "forbidden" }, 403);
5339
5421
  }
5340
5422
  }
5341
- mkdirSync7(dirname6(outputPath), { recursive: true });
5342
- if (backgroundOutputPath) mkdirSync7(dirname6(backgroundOutputPath), { recursive: true });
5423
+ mkdirWithinProject(project.dir, dirname6(outputPath));
5424
+ if (backgroundOutputPath) mkdirWithinProject(project.dir, dirname6(backgroundOutputPath));
5343
5425
  const jobId = makeJobId(project.id, mediaJobs);
5344
5426
  const state = adapter.startBackgroundRemoval({
5345
5427
  project,
@@ -5421,20 +5503,20 @@ function registerGlobalAssetRoutes(api) {
5421
5503
  }
5422
5504
 
5423
5505
  // src/history/projectHistory.ts
5424
- import { randomUUID as randomUUID8 } from "crypto";
5425
- import { readdir as readdir2, rm as rm2, rmdir, writeFile } from "fs/promises";
5506
+ import { randomUUID as randomUUID9 } from "crypto";
5507
+ import { readdir as readdir3, rm as rm3, rmdir, writeFile } from "fs/promises";
5426
5508
  import {
5427
5509
  existsSync as existsSync11,
5428
5510
  lstatSync as lstatSync4,
5429
- statSync as statSync9,
5430
- mkdirSync as mkdirSync11,
5431
- readdirSync as readdirSync6,
5432
- readFileSync as readFileSync18,
5511
+ statSync as statSync10,
5512
+ mkdirSync as mkdirSync5,
5513
+ readdirSync as readdirSync7,
5514
+ readFileSync as readFileSync19,
5433
5515
  realpathSync,
5434
5516
  rmSync as rmSync6
5435
5517
  } from "fs";
5436
5518
  import { homedir as homedir2 } from "os";
5437
- import { dirname as dirname9, isAbsolute as isAbsolute2, join as join19, relative as relative5, resolve as resolve8, sep as sep3 } from "path";
5519
+ import { dirname as dirname10, isAbsolute as isAbsolute2, join as join20, relative as relative5, resolve as resolve8, sep as sep3 } from "path";
5438
5520
 
5439
5521
  // src/history/blobStore.ts
5440
5522
  import { createHash as createHash7, randomUUID as randomUUID5 } from "crypto";
@@ -5503,12 +5585,20 @@ async function openBlobStore(dir) {
5503
5585
  };
5504
5586
  }
5505
5587
 
5588
+ // src/history/pruneHistories.ts
5589
+ import { randomUUID as randomUUID8 } from "crypto";
5590
+ import { readdirSync as readdirSync6, readFileSync as readFileSync17, renameSync as renameSync5, statSync as statSync9, writeFileSync as writeFileSync9 } from "fs";
5591
+ import { lstat, readdir as readdir2, rm as rm2 } from "fs/promises";
5592
+ import { tmpdir as tmpdir2 } from "os";
5593
+ import { dirname as dirname8, join as join19 } from "path";
5594
+
5506
5595
  // src/history/historyId.ts
5507
5596
  import { randomUUID as randomUUID6 } from "crypto";
5508
- import { existsSync as existsSync10, mkdirSync as mkdirSync8, readFileSync as readFileSync15, statSync as statSync8, writeFileSync as writeFileSync7 } from "fs";
5597
+ import { existsSync as existsSync10, mkdirSync as mkdirSync2, readFileSync as readFileSync15, statSync as statSync8, writeFileSync as writeFileSync7 } from "fs";
5509
5598
  import { basename as basename3, join as join17, resolve as resolve7 } from "path";
5510
5599
  var ID_PATH = join17(".hyperframes", "history-id");
5511
5600
  var ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
5601
+ var isHistoryId = (name) => ID_SHAPE.test(name);
5512
5602
  function readId(projectDir) {
5513
5603
  try {
5514
5604
  const id = readFileSync15(join17(projectDir, ID_PATH), "utf-8").trim();
@@ -5552,7 +5642,7 @@ function projectHistoryId(projectDir, historyRoot) {
5552
5642
  );
5553
5643
  if (!id || existsSync10(join17(historyRoot, id)) && !isRecordedFolder(join17(historyRoot, id), folder)) {
5554
5644
  id = randomUUID6();
5555
- mkdirSync8(join17(dir, ".hyperframes"), { recursive: true });
5645
+ mkdirWithinProject(dir, join17(dir, ".hyperframes"));
5556
5646
  writeFileSync7(join17(dir, ID_PATH), `${id}
5557
5647
  `);
5558
5648
  }
@@ -5566,14 +5656,14 @@ var HistoryIdError = class extends Error {
5566
5656
  }
5567
5657
  };
5568
5658
  function recordProject(historyDir, dir, folder) {
5569
- const record = { dir, ino: folder.ino, born: folder.birthtimeMs };
5570
- mkdirSync8(historyDir, { recursive: true });
5659
+ const record = { dir, ino: folder.ino, born: folder.birthtimeMs, dev: folder.dev };
5660
+ mkdirSync2(historyDir, { recursive: true });
5571
5661
  replaceFileAtomically(join17(historyDir, "project.json"), JSON.stringify(record), 420);
5572
5662
  }
5573
5663
 
5574
5664
  // src/history/ownerLock.ts
5575
5665
  import { randomUUID as randomUUID7 } from "crypto";
5576
- import { linkSync, mkdirSync as mkdirSync9, readFileSync as readFileSync16, rmSync as rmSync5, writeFileSync as writeFileSync8 } from "fs";
5666
+ import { linkSync, mkdirSync as mkdirSync3, readFileSync as readFileSync16, rmSync as rmSync5, writeFileSync as writeFileSync8 } from "fs";
5577
5667
  import { join as join18 } from "path";
5578
5668
  var HistoryBusyError = class extends Error {
5579
5669
  constructor(pid) {
@@ -5635,7 +5725,7 @@ function evictDeadOwner(file) {
5635
5725
  async function takeHistoryOwnership(home, waitMs) {
5636
5726
  const file = join18(home, "owner.pid");
5637
5727
  const deadline = Date.now() + waitMs;
5638
- mkdirSync9(home, { recursive: true });
5728
+ mkdirSync3(home, { recursive: true });
5639
5729
  for (; ; ) {
5640
5730
  if (claim(file)) {
5641
5731
  let held = true;
@@ -5652,9 +5742,155 @@ async function takeHistoryOwnership(home, waitMs) {
5652
5742
  }
5653
5743
  }
5654
5744
 
5745
+ // src/history/pruneHistories.ts
5746
+ var DAY_MS = 24 * 60 * 60 * 1e3;
5747
+ var KEEP_GONE_PROJECT_HISTORY_MS = 14 * DAY_MS;
5748
+ var PRUNED_PREFIX = ".pruned-";
5749
+ var LAST_PRUNE_FILE = ".last-prune";
5750
+ function listProjectHistories(historyRoot) {
5751
+ return namesIn(historyRoot).filter(isHistoryId).flatMap((id) => {
5752
+ const home = join19(historyRoot, id);
5753
+ const projectDir = readRecord(home)?.dir;
5754
+ if (typeof projectDir !== "string" || readId(projectDir) !== id) return [];
5755
+ return [{ id, projectDir, lastUsedMs: lastUsed(home) }];
5756
+ });
5757
+ }
5758
+ function namesIn(historyRoot) {
5759
+ try {
5760
+ return readdirSync6(historyRoot);
5761
+ } catch (error) {
5762
+ if (error.code === "ENOENT") return [];
5763
+ throw error;
5764
+ }
5765
+ }
5766
+ async function pruneGoneProjectHistories(historyRoot, options = {}) {
5767
+ return (await pruneWithin(historyRoot, options)).pruned;
5768
+ }
5769
+ async function pruneWithin(historyRoot, {
5770
+ now = Date.now(),
5771
+ dryRun = false,
5772
+ tempDir = tmpdir2(),
5773
+ onError = (error) => console.warn("[history] could not prune a history:", error),
5774
+ budgetMs = Infinity,
5775
+ measure = true
5776
+ }) {
5777
+ const started = performance.now();
5778
+ const names = namesIn(historyRoot);
5779
+ const pruned = [];
5780
+ for (const name of names) {
5781
+ if (performance.now() - started >= budgetMs) return { pruned, finished: false };
5782
+ try {
5783
+ const gone = await pruneEntry(historyRoot, name, { now, dryRun, tempDir, measure });
5784
+ if (gone) pruned.push(gone);
5785
+ } catch (error) {
5786
+ onError(error);
5787
+ }
5788
+ }
5789
+ return { pruned, finished: true };
5790
+ }
5791
+ async function pruneEntry(historyRoot, name, run) {
5792
+ if (name.startsWith(PRUNED_PREFIX)) {
5793
+ if (!run.dryRun) await rm2(join19(historyRoot, name), { recursive: true, force: true });
5794
+ return null;
5795
+ }
5796
+ return isHistoryId(name) ? pruneOne(historyRoot, name, run) : null;
5797
+ }
5798
+ async function pruneOne(historyRoot, id, run) {
5799
+ const home = join19(historyRoot, id);
5800
+ if (abandonedProject(home, id, run.now, run.tempDir) === null) return null;
5801
+ const release = await takeUnlessBusy(home);
5802
+ if (!release) return null;
5803
+ let taken;
5804
+ try {
5805
+ taken = recheckAndMoveAway(historyRoot, id, run);
5806
+ } catch (error) {
5807
+ release();
5808
+ throw error;
5809
+ }
5810
+ if (!taken?.trash) release();
5811
+ if (!taken) return null;
5812
+ const bytes = await clearAway(taken.trash, home, run.measure);
5813
+ return { id, projectDir: taken.projectDir, bytes };
5814
+ }
5815
+ async function clearAway(trash, home, measure) {
5816
+ const bytes = measure ? await bytesIn(trash ?? home) : 0;
5817
+ if (trash) await rm2(trash, { recursive: true, force: true });
5818
+ return bytes;
5819
+ }
5820
+ async function takeUnlessBusy(home) {
5821
+ try {
5822
+ return await takeHistoryOwnership(home, 0);
5823
+ } catch (error) {
5824
+ if (error instanceof HistoryBusyError) return null;
5825
+ throw error;
5826
+ }
5827
+ }
5828
+ function recheckAndMoveAway(historyRoot, id, { now, dryRun, tempDir }) {
5829
+ const home = join19(historyRoot, id);
5830
+ const projectDir = abandonedProject(home, id, now, tempDir);
5831
+ if (projectDir === null) return null;
5832
+ if (dryRun) return { projectDir, trash: null };
5833
+ const trash = join19(historyRoot, `${PRUNED_PREFIX}${id}-${randomUUID8()}`);
5834
+ renameSync5(home, trash);
5835
+ return { projectDir, trash };
5836
+ }
5837
+ function abandonedProject(home, id, now, tempDir) {
5838
+ const record = readRecord(home);
5839
+ const dir = record?.dir;
5840
+ if (typeof dir !== "string") return null;
5841
+ if (now - lastUsed(home) < KEEP_GONE_PROJECT_HISTORY_MS && !isSafePath(tempDir, dir)) return null;
5842
+ return projectGone(dir, id, record?.dev, tempDir) ? dir : null;
5843
+ }
5844
+ function projectGone(dir, id, dev, tempDir) {
5845
+ if (!statSync9(join19(dir, ID_PATH), { throwIfNoEntry: false }))
5846
+ return diskStillHere(dir, dev, tempDir);
5847
+ const there = readId(dir);
5848
+ return there !== null && there !== id;
5849
+ }
5850
+ function diskStillHere(dir, dev, tempDir) {
5851
+ let at = dir;
5852
+ let stat3 = statSync9(at, { throwIfNoEntry: false });
5853
+ while (!stat3) {
5854
+ if (dirname8(at) === at) return false;
5855
+ at = dirname8(at);
5856
+ stat3 = statSync9(at, { throwIfNoEntry: false });
5857
+ }
5858
+ if (typeof dev === "number") return stat3.dev === dev;
5859
+ return at === dir || isSafePath(tempDir, at);
5860
+ }
5861
+ function lastUsed(home) {
5862
+ const mtime = (file) => statSync9(join19(home, file), { throwIfNoEntry: false })?.mtimeMs ?? 0;
5863
+ return Math.max(mtime("project.json"), mtime("log.jsonl"));
5864
+ }
5865
+ async function bytesIn(dir) {
5866
+ let total = 0;
5867
+ for (const entry of await readdir2(dir, { withFileTypes: true })) {
5868
+ const path = join19(dir, entry.name);
5869
+ total += entry.isDirectory() ? await bytesIn(path) : (await lstat(path)).size;
5870
+ }
5871
+ return total;
5872
+ }
5873
+ function prunedWithinADay(stamp, now) {
5874
+ try {
5875
+ const last = Number(readFileSync17(stamp, "utf-8"));
5876
+ return last <= now && now - last < DAY_MS;
5877
+ } catch {
5878
+ return false;
5879
+ }
5880
+ }
5881
+ var pruning = /* @__PURE__ */ new Set();
5882
+ function pruneGoneProjectHistoriesDaily(historyRoot, now, { onError, budgetMs } = {}) {
5883
+ const stamp = join19(historyRoot, LAST_PRUNE_FILE);
5884
+ if (pruning.has(historyRoot) || prunedWithinADay(stamp, now)) return;
5885
+ pruning.add(historyRoot);
5886
+ setImmediate(() => {
5887
+ pruneWithin(historyRoot, { now, onError, budgetMs, measure: false }).then(({ finished }) => finished && writeFileSync9(stamp, String(now))).catch((error) => onError?.(error)).finally(() => pruning.delete(historyRoot));
5888
+ });
5889
+ }
5890
+
5655
5891
  // src/history/historyLog.ts
5656
- import { closeSync as closeSync5, constants as constants4, mkdirSync as mkdirSync10, openSync as openSync5, readFileSync as readFileSync17, writeSync as writeSync2 } from "fs";
5657
- import { dirname as dirname8 } from "path";
5892
+ import { closeSync as closeSync5, constants as constants4, mkdirSync as mkdirSync4, openSync as openSync5, readFileSync as readFileSync18, writeSync as writeSync2 } from "fs";
5893
+ import { dirname as dirname9 } from "path";
5658
5894
  var START = "start";
5659
5895
  function parseRecord(line) {
5660
5896
  try {
@@ -5666,7 +5902,7 @@ function parseRecord(line) {
5666
5902
  function readLog(file, onUnreadable) {
5667
5903
  let text2;
5668
5904
  try {
5669
- text2 = readFileSync17(file, "utf-8");
5905
+ text2 = readFileSync18(file, "utf-8");
5670
5906
  } catch {
5671
5907
  return null;
5672
5908
  }
@@ -5706,7 +5942,7 @@ function writeLog(file, log) {
5706
5942
  ...log.entries.map((entry) => ({ type: "entry", entry })),
5707
5943
  ...[...log.pins].map((id) => ({ type: "pin", id, pinned: true }))
5708
5944
  ];
5709
- mkdirSync10(dirname8(file), { recursive: true });
5945
+ mkdirSync4(dirname9(file), { recursive: true });
5710
5946
  replaceFileAtomically(file, records.map((r) => `${JSON.stringify(r)}
5711
5947
  `).join(""), 420);
5712
5948
  }
@@ -5779,7 +6015,7 @@ function referencedHashes(log, current) {
5779
6015
  }
5780
6016
 
5781
6017
  // src/history/projectHistory.ts
5782
- var DEFAULT_HISTORY_ROOT = join19(homedir2(), ".cache", "hyperframes", "history");
6018
+ var DEFAULT_HISTORY_ROOT = join20(homedir2(), ".cache", "hyperframes", "history");
5783
6019
  var MAX_WINDOW_IDLE_MS = 10 * 6e4;
5784
6020
  var UNDO_MODES = ["just-this", "back-to-before", "keep-later-edits"];
5785
6021
  var OUTSIDE = { kind: "outside", name: "Outside" };
@@ -5799,26 +6035,26 @@ var isMissingFolder = (error) => ["ENOENT", "ENOTDIR"].includes(error.code ?? ""
5799
6035
  async function removeEmptyFolders(dir) {
5800
6036
  let entries;
5801
6037
  try {
5802
- entries = await readdir2(dir, { withFileTypes: true });
6038
+ entries = await readdir3(dir, { withFileTypes: true });
5803
6039
  } catch (error) {
5804
6040
  if (isMissingFolder(error)) return;
5805
6041
  throw error;
5806
6042
  }
5807
6043
  for (const entry of entries)
5808
- if (entry.isDirectory()) await removeEmptyFolders(join19(dir, entry.name));
6044
+ if (entry.isDirectory()) await removeEmptyFolders(join20(dir, entry.name));
5809
6045
  await rmdir(dir);
5810
6046
  }
5811
6047
  function fileLeftIn(dir, at, deleted) {
5812
- for (const entry of readdirSync6(dir, { withFileTypes: true })) {
6048
+ for (const entry of readdirSync7(dir, { withFileTypes: true })) {
5813
6049
  const path = `${at}/${entry.name}`;
5814
- const left = entry.isDirectory() ? fileLeftIn(join19(dir, entry.name), path, deleted) : deleted.has(path) ? void 0 : path;
6050
+ const left = entry.isDirectory() ? fileLeftIn(join20(dir, entry.name), path, deleted) : deleted.has(path) ? void 0 : path;
5815
6051
  if (left) return left;
5816
6052
  }
5817
6053
  return void 0;
5818
6054
  }
5819
6055
  function ignoresCase(dir) {
5820
- const self = statSync9(join19(dir, ID_PATH), { throwIfNoEntry: false });
5821
- const other = statSync9(join19(dir, ID_PATH.toUpperCase()), { throwIfNoEntry: false });
6056
+ const self = statSync10(join20(dir, ID_PATH), { throwIfNoEntry: false });
6057
+ const other = statSync10(join20(dir, ID_PATH.toUpperCase()), { throwIfNoEntry: false });
5822
6058
  return self !== void 0 && other?.ino === self.ino && other.dev === self.dev;
5823
6059
  }
5824
6060
  var blocks = (removed, added) => added.startsWith(`${removed}/`) || removed.startsWith(`${added}/`);
@@ -5835,9 +6071,9 @@ var Engine = class {
5835
6071
  this.projectId = projectId;
5836
6072
  this.blobs = blobs;
5837
6073
  this.dir = resolve8(options.projectDir);
5838
- this.home = join19(options.historyRoot, projectId);
6074
+ this.home = join20(options.historyRoot, projectId);
5839
6075
  this.foldsCase = ignoresCase(this.dir);
5840
- const folder = statSync9(this.dir, { throwIfNoEntry: false });
6076
+ const folder = statSync10(this.dir, { throwIfNoEntry: false });
5841
6077
  if (!folder || readId(this.dir) !== projectId || !isRecordedFolder(this.home, folder))
5842
6078
  throw this.replaced();
5843
6079
  this.folder = { dev: folder.dev, ino: folder.ino, birthtimeMs: folder.birthtimeMs };
@@ -5868,7 +6104,7 @@ var Engine = class {
5868
6104
  return this.foldsCase ? path.toLowerCase() : path;
5869
6105
  }
5870
6106
  get logFile() {
5871
- return join19(this.home, "log.jsonl");
6107
+ return join20(this.home, "log.jsonl");
5872
6108
  }
5873
6109
  now() {
5874
6110
  return (this.options.now ?? Date.now)();
@@ -5883,7 +6119,7 @@ var Engine = class {
5883
6119
  }
5884
6120
  /** Another project if the folder or its id changed, or the id is gone: a recreated folder may reuse the inode. */
5885
6121
  whereFolder() {
5886
- const now = statSync9(this.dir, { throwIfNoEntry: false });
6122
+ const now = statSync10(this.dir, { throwIfNoEntry: false });
5887
6123
  if (!now) return "gone";
5888
6124
  const same = now.dev === this.folder.dev && sameFolder(now, this.folder);
5889
6125
  return same && readId(this.dir) === this.projectId ? "here" : "replaced";
@@ -5896,7 +6132,7 @@ var Engine = class {
5896
6132
  persistLog(record) {
5897
6133
  if (record) saveRecord(this.logFile, this.log, record);
5898
6134
  else writeLog(this.logFile, this.log);
5899
- if (!existsSync11(join19(this.home, "project.json")))
6135
+ if (!existsSync11(join20(this.home, "project.json")))
5900
6136
  recordProject(this.home, this.dir, this.folder);
5901
6137
  }
5902
6138
  replaced() {
@@ -5946,7 +6182,7 @@ var Engine = class {
5946
6182
  }
5947
6183
  readStatCache() {
5948
6184
  try {
5949
- const files = JSON.parse(readFileSync18(join19(this.home, "stat.json"), "utf-8"));
6185
+ const files = JSON.parse(readFileSync19(join20(this.home, "stat.json"), "utf-8"));
5950
6186
  return new Map(Object.entries(files));
5951
6187
  } catch {
5952
6188
  return /* @__PURE__ */ new Map();
@@ -5954,8 +6190,8 @@ var Engine = class {
5954
6190
  }
5955
6191
  saveStatCache() {
5956
6192
  const files = JSON.stringify(Object.fromEntries(this.tracked));
5957
- mkdirSync11(this.home, { recursive: true });
5958
- replaceFileAtomically(join19(this.home, "stat.json"), files, 420);
6193
+ mkdirSync5(this.home, { recursive: true });
6194
+ replaceFileAtomically(join20(this.home, "stat.json"), files, 420);
5959
6195
  }
5960
6196
  /**
5961
6197
  * Takes in every write since the last sweep and files each change to its writer. ponytail: always the whole
@@ -5995,7 +6231,7 @@ var Engine = class {
5995
6231
  /** The file's hash once copied in; null when it was removed before the copy (the next sweep records that). */
5996
6232
  async storeIfPresent(path) {
5997
6233
  try {
5998
- return await this.blobs.put(join19(this.dir, path));
6234
+ return await this.blobs.put(join20(this.dir, path));
5999
6235
  } catch (error) {
6000
6236
  if (error.code === "ENOENT") return null;
6001
6237
  throw error;
@@ -6082,7 +6318,7 @@ var Engine = class {
6082
6318
  }
6083
6319
  hits.forEach(
6084
6320
  ({ change }, i) => forgetOverwrittenBytes(
6085
- join19(this.dir, change.path),
6321
+ join20(this.dir, change.path),
6086
6322
  new Set([...walked[i]].map(hashVersion))
6087
6323
  )
6088
6324
  );
@@ -6090,7 +6326,7 @@ var Engine = class {
6090
6326
  }
6091
6327
  /** Walks the server's writes back from `change.after` to the bytes they replaced, stored so an undo restores them. */
6092
6328
  async overwrittenBy(change, told, seen) {
6093
- const absPath = join19(this.dir, change.path);
6329
+ const absPath = join20(this.dir, change.path);
6094
6330
  let bytes;
6095
6331
  for (let hash = change.after; hash; ) {
6096
6332
  if (seen.has(hash)) return void 0;
@@ -6102,12 +6338,12 @@ var Engine = class {
6102
6338
  if (hash === change.before || hash === told) return hash;
6103
6339
  }
6104
6340
  if (bytes === void 0) return void 0;
6105
- const staged = join19(this.home, `overwrote-${randomUUID8()}`);
6341
+ const staged = join20(this.home, `overwrote-${randomUUID9()}`);
6106
6342
  await writeFile(staged, bytes);
6107
6343
  try {
6108
6344
  return await this.blobs.put(staged);
6109
6345
  } finally {
6110
- await rm2(staged, { force: true });
6346
+ await rm3(staged, { force: true });
6111
6347
  }
6112
6348
  }
6113
6349
  cutOut(group, change, cut) {
@@ -6157,7 +6393,7 @@ var Engine = class {
6157
6393
  return this.outside;
6158
6394
  }
6159
6395
  newGroup(who, label) {
6160
- return { id: randomUUID8(), who, label, startedAt: this.now(), changes: /* @__PURE__ */ new Map() };
6396
+ return { id: randomUUID9(), who, label, startedAt: this.now(), changes: /* @__PURE__ */ new Map() };
6161
6397
  }
6162
6398
  async commitOutside() {
6163
6399
  if (this.outside?.changes.size) await this.commitClaim();
@@ -6274,7 +6510,7 @@ var Engine = class {
6274
6510
  const parts = path.split("/");
6275
6511
  for (let depth = 1; depth <= parts.length; depth += 1) {
6276
6512
  const at = parts.slice(0, depth).join("/");
6277
- const stat3 = lstatSync4(join19(this.dir, at), { throwIfNoEntry: false });
6513
+ const stat3 = lstatSync4(join20(this.dir, at), { throwIfNoEntry: false });
6278
6514
  if (!stat3) return void 0;
6279
6515
  if (at === path || !stat3.isDirectory()) return { at, stat: stat3 };
6280
6516
  }
@@ -6286,7 +6522,7 @@ var Engine = class {
6286
6522
  if (!standing) return void 0;
6287
6523
  if (standing.stat.isSymbolicLink()) return standing.at;
6288
6524
  if (standing.at === path && !standing.stat.isDirectory()) return void 0;
6289
- const real = realpathSync.native(join19(this.dir, standing.at));
6525
+ const real = realpathSync.native(join20(this.dir, standing.at));
6290
6526
  const at = relative5(realpathSync.native(this.dir), real).split(sep3).join("/");
6291
6527
  if (standing.at !== path) return deleted.has(at) ? void 0 : at;
6292
6528
  return fileLeftIn(real, at, deleted);
@@ -6311,7 +6547,7 @@ var Engine = class {
6311
6547
  /** Writes one project file (null deletes it), first leaving the running operation's receipt for its echo. */
6312
6548
  async writeProjectFile(path, hash) {
6313
6549
  this.assertWritable();
6314
- const absPath = join19(this.dir, path);
6550
+ const absPath = join20(this.dir, path);
6315
6551
  const { writeToken } = this;
6316
6552
  if (writeToken) {
6317
6553
  const version = hash === null ? DELETED_VERSION : hashVersion(hash);
@@ -6445,14 +6681,14 @@ var Engine = class {
6445
6681
  const dest = resolve8(emptyDir);
6446
6682
  if (isWithin(this.dir, dest))
6447
6683
  throw new Error(`Checkout writes outside the project only: ${dest}`);
6448
- if ((await readdir2(dest).catch(missingIsEmpty)).length > 0)
6684
+ if ((await readdir3(dest).catch(missingIsEmpty)).length > 0)
6449
6685
  throw new Error(`Checkout writes into an empty folder only: ${dest}`);
6450
6686
  const files = manifestAround(this.log, entryId, side);
6451
6687
  try {
6452
- for (const [path, hash] of files) await this.blobs.writeTo(hash, join19(dest, path));
6688
+ for (const [path, hash] of files) await this.blobs.writeTo(hash, join20(dest, path));
6453
6689
  } catch (error) {
6454
- for (const name of await readdir2(dest).catch(() => []))
6455
- await rm2(join19(dest, name), { recursive: true, force: true });
6690
+ for (const name of await readdir3(dest).catch(() => []))
6691
+ await rm3(join20(dest, name), { recursive: true, force: true });
6456
6692
  throw error;
6457
6693
  }
6458
6694
  }),
@@ -6488,7 +6724,7 @@ var Engine = class {
6488
6724
  };
6489
6725
  function isWithin(dir, path) {
6490
6726
  let probe = resolve8(path);
6491
- while (!existsSync11(probe) && dirname9(probe) !== probe) probe = dirname9(probe);
6727
+ while (!existsSync11(probe) && dirname10(probe) !== probe) probe = dirname10(probe);
6492
6728
  const rel = relative5(realpathSync.native(dir), realpathSync.native(probe));
6493
6729
  return rel === "" || rel !== ".." && !rel.startsWith(`..${sep3}`) && !isAbsolute2(rel);
6494
6730
  }
@@ -6507,13 +6743,17 @@ async function openProjectHistory(options) {
6507
6743
  throw new HistoryClosedError(`${options.projectDir} is gone or now another project.`);
6508
6744
  const projectId = projectHistoryId(options.projectDir, options.historyRoot);
6509
6745
  const release = await takeHistoryOwnership(
6510
- join19(options.historyRoot, projectId),
6746
+ join20(options.historyRoot, projectId),
6511
6747
  options.ownerWaitMs ?? 5e3
6512
6748
  );
6513
6749
  try {
6514
- const blobs = await openBlobStore(join19(options.historyRoot, projectId, "blobs"));
6750
+ const blobs = await openBlobStore(join20(options.historyRoot, projectId, "blobs"));
6515
6751
  const engine = new Engine(options, projectId, blobs);
6516
6752
  await engine.queue(() => engine.start());
6753
+ pruneGoneProjectHistoriesDaily(options.historyRoot, engine.now(), {
6754
+ onError: options.onError,
6755
+ budgetMs: options.pruneGoneProjectsBudgetMs
6756
+ });
6517
6757
  const api = engine.api();
6518
6758
  return {
6519
6759
  ...api,
@@ -6664,6 +6904,21 @@ function registerHistoryRoutes(api, adapter) {
6664
6904
  // src/createStudioApi.ts
6665
6905
  function createStudioApi(adapter) {
6666
6906
  const api = new Hono();
6907
+ api.use(async function answerProjectDirMissingAfterErrorHandlers(c, next) {
6908
+ const hostHeaders = new Headers(c.res.headers);
6909
+ await next();
6910
+ if (isProjectRootMissing(c.error)) replaceWithProjectDirMissing(c, hostHeaders);
6911
+ });
6912
+ api.use("/projects/:id/*", async function answerProjectDirMissingForVanishedFolder(c, next) {
6913
+ const hostHeaders = new Headers(c.res.headers);
6914
+ const dirBeforeRoute = await Promise.resolve().then(() => adapter.resolveProject(c.req.param("id"))).then(
6915
+ (project) => project?.dir,
6916
+ () => void 0
6917
+ );
6918
+ await next();
6919
+ if (c.res.status >= 403 && dirBeforeRoute && folderGone(dirBeforeRoute))
6920
+ replaceWithProjectDirMissing(c, hostHeaders);
6921
+ });
6667
6922
  registerProjectRoutes(api, adapter);
6668
6923
  registerFileRoutes(api, adapter);
6669
6924
  registerPreviewRoutes(api, adapter);
@@ -6760,26 +7015,27 @@ function numericAttribute(element, name) {
6760
7015
 
6761
7016
  // src/helpers/previewDocumentStore.ts
6762
7017
  import { createHash as createHash8 } from "crypto";
6763
- import { mkdirSync as mkdirSync12, readFileSync as readFileSync19, readdirSync as readdirSync7, rmSync as rmSync7 } from "fs";
6764
- import { join as join20 } from "path";
6765
- function createPreviewDocumentStore(dir, salt) {
6766
- const fileFor = (key) => join20(dir, `${createHash8("sha256").update(`${salt}
7018
+ import { readFileSync as readFileSync20, readdirSync as readdirSync8, rmSync as rmSync7 } from "fs";
7019
+ import { join as join21 } from "path";
7020
+ function createPreviewDocumentStore(projectDir, salt) {
7021
+ const dir = join21(projectDir, ".hyperframes", "preview");
7022
+ const fileFor = (key) => join21(dir, `${createHash8("sha256").update(`${salt}
6767
7023
  ${key}`).digest("hex").slice(0, 32)}.html`);
6768
7024
  return {
6769
7025
  read(key) {
6770
7026
  try {
6771
- return readFileSync19(fileFor(key), "utf-8");
7027
+ return readFileSync20(fileFor(key), "utf-8");
6772
7028
  } catch {
6773
7029
  return null;
6774
7030
  }
6775
7031
  },
6776
7032
  write(key, html) {
6777
7033
  try {
6778
- mkdirSync12(dir, { recursive: true });
7034
+ mkdirWithinProject(projectDir, dir);
6779
7035
  const file = fileFor(key);
6780
7036
  replaceFileAtomically(file, html, 420);
6781
- for (const name of readdirSync7(dir)) {
6782
- if (join20(dir, name) !== file) rmSync7(join20(dir, name), { force: true });
7037
+ for (const name of readdirSync8(dir)) {
7038
+ if (join21(dir, name) !== file) rmSync7(join21(dir, name), { force: true });
6783
7039
  }
6784
7040
  } catch {
6785
7041
  }
@@ -6889,9 +7145,11 @@ export {
6889
7145
  historyCache,
6890
7146
  identifyFileWrite,
6891
7147
  isSafePath,
7148
+ listProjectHistories,
6892
7149
  openProjectHistory,
6893
7150
  parseSourceDocument,
6894
7151
  patchElementInHtml,
7152
+ pruneGoneProjectHistories,
6895
7153
  removeElementFromHtml,
6896
7154
  requestSubPath,
6897
7155
  settledFileTag,