@hyperframes/studio-server 0.8.79 → 0.8.81

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-QYDRVMJ6.js";
16
+ } from "./chunk-P5EJPIH3.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-X3T65KC2.js";
25
+ } from "./chunk-FTDTO3QP.js";
26
26
  import {
27
27
  ProxyCapacityError,
28
28
  ProxyTranscodeError,
29
29
  resolveProxy
30
- } from "./chunk-45POWYFS.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-4MNTZVVZ.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
  }
@@ -289,22 +239,22 @@ function collectProjectSignatureFiles(projectDir, dir, files) {
289
239
  if (SIGNATURE_EXCLUDED_DIRS.has(entry)) continue;
290
240
  const file = resolve(dir, entry);
291
241
  if (!isPathWithin(projectDir, file)) continue;
292
- let stat2;
242
+ let stat3;
293
243
  try {
294
- stat2 = lstatSync(file);
244
+ stat3 = lstatSync(file);
295
245
  } catch {
296
246
  continue;
297
247
  }
298
- if (stat2.isSymbolicLink()) continue;
299
- if (stat2.isDirectory()) {
248
+ if (stat3.isSymbolicLink()) continue;
249
+ if (stat3.isDirectory()) {
300
250
  collectProjectSignatureFiles(projectDir, file, files);
301
- } else if (stat2.isFile()) {
251
+ } else if (stat3.isFile()) {
302
252
  files.push({
303
253
  file,
304
- mtimeMs: stat2.mtimeMs,
305
- ctimeMs: stat2.ctimeMs,
306
- size: stat2.size,
307
- textContentEligible: isTextContentEligible(file, stat2.size)
254
+ mtimeMs: stat3.mtimeMs,
255
+ ctimeMs: stat3.ctimeMs,
256
+ size: stat3.size,
257
+ textContentEligible: isTextContentEligible(file, stat3.size)
308
258
  });
309
259
  }
310
260
  }
@@ -314,19 +264,19 @@ function collectProjectSignatureManifestFiles(projectDir, files) {
314
264
  for (const manifestPath of STUDIO_SIGNATURE_MANIFEST_PATHS) {
315
265
  const file = resolve(projectDir, manifestPath);
316
266
  if (seen.has(file) || !isPathWithin(projectDir, file)) continue;
317
- let stat2;
267
+ let stat3;
318
268
  try {
319
- stat2 = lstatSync(file);
269
+ stat3 = lstatSync(file);
320
270
  } catch {
321
271
  continue;
322
272
  }
323
- if (stat2.isSymbolicLink() || !stat2.isFile()) continue;
273
+ if (stat3.isSymbolicLink() || !stat3.isFile()) continue;
324
274
  files.push({
325
275
  file,
326
- mtimeMs: stat2.mtimeMs,
327
- ctimeMs: stat2.ctimeMs,
328
- size: stat2.size,
329
- textContentEligible: isTextContentEligible(file, stat2.size)
276
+ mtimeMs: stat3.mtimeMs,
277
+ ctimeMs: stat3.ctimeMs,
278
+ size: stat3.size,
279
+ textContentEligible: isTextContentEligible(file, stat3.size)
330
280
  });
331
281
  seen.add(file);
332
282
  }
@@ -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,7 +582,7 @@ 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";
636
586
  var VIDEO_EXT = /\.(mp4|webm|mov|mkv|avi|m4v|mxf|mts|m2ts|ts)$/i;
637
587
  var AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
638
588
  function validateUploadedMedia(filePath, runner = spawnSync) {
@@ -669,8 +619,8 @@ function validateUploadedMedia(filePath, runner = spawnSync) {
669
619
  }
670
620
  }
671
621
  function validateUploadedMediaBuffer(fileName, buffer, runner = spawnSync) {
672
- const tempDir = mkdtempSync2(join5(tmpdir(), "hyperframes-upload-"));
673
- const tempPath = join5(tempDir, basename2(fileName));
622
+ const tempDir = mkdtempSync2(join4(tmpdir(), "hyperframes-upload-"));
623
+ const tempPath = join4(tempDir, basename(fileName));
674
624
  try {
675
625
  writeFileSync3(tempPath, buffer);
676
626
  return validateUploadedMedia(tempPath, runner);
@@ -680,9 +630,9 @@ function validateUploadedMediaBuffer(fileName, buffer, runner = spawnSync) {
680
630
  }
681
631
 
682
632
  // src/helpers/backupJournal.ts
683
- import { mkdirSync as mkdirSync3, readdirSync as readdirSync3, readFileSync as readFileSync3, unlinkSync, writeFileSync as writeFileSync4 } from "fs";
633
+ import { mkdirSync as mkdirSync3, readdirSync as readdirSync2, readFileSync as readFileSync3, unlinkSync, writeFileSync as writeFileSync4 } from "fs";
684
634
  import { Buffer as Buffer2 } from "buffer";
685
- import { join as join6, relative as relative3 } from "path";
635
+ import { join as join5, relative as relative2 } from "path";
686
636
  var DEFAULT_KEEP_PER_FILE = 10;
687
637
  function backupKeyForPath(path) {
688
638
  return Buffer2.from(path, "utf-8").toString("base64url");
@@ -692,7 +642,7 @@ function timestampPrefix() {
692
642
  }
693
643
  function backupPathForResponse(projectDir, backupPath) {
694
644
  if (!backupPath) return null;
695
- const rel = relative3(projectDir, backupPath);
645
+ const rel = relative2(projectDir, backupPath);
696
646
  if (!rel || rel.startsWith("..")) return null;
697
647
  return rel.split("\\").join("/");
698
648
  }
@@ -700,8 +650,8 @@ function snapshotBeforeWrite(projectDir, absPath, options = {}) {
700
650
  if (!isSafePath(projectDir, absPath)) return { backupPath: null };
701
651
  try {
702
652
  const content = readFileSync3(absPath);
703
- const relativePath = relative3(projectDir, absPath);
704
- const backupDir = join6(projectDir, ".hyperframes", "backup");
653
+ const relativePath = relative2(projectDir, absPath);
654
+ const backupDir = join5(projectDir, ".hyperframes", "backup");
705
655
  mkdirSync3(backupDir, { recursive: true });
706
656
  const backupKey = backupKeyForPath(relativePath);
707
657
  const backupPath = nextBackupPath(backupDir, backupKey);
@@ -717,7 +667,7 @@ function snapshotBeforeWrite(projectDir, absPath, options = {}) {
717
667
  }
718
668
  function nextBackupPath(backupDir, backupKey) {
719
669
  const base = `${timestampPrefix()}-${backupKey}`;
720
- let candidate = join6(backupDir, base);
670
+ let candidate = join5(backupDir, base);
721
671
  let counter = 2;
722
672
  while (true) {
723
673
  try {
@@ -728,7 +678,7 @@ function nextBackupPath(backupDir, backupKey) {
728
678
  }
729
679
  throw error;
730
680
  }
731
- candidate = join6(backupDir, `${base}-${counter}`);
681
+ candidate = join5(backupDir, `${base}-${counter}`);
732
682
  counter += 1;
733
683
  }
734
684
  }
@@ -736,7 +686,7 @@ function pruneBackups(backupDir, backupKey, keepPerFile) {
736
686
  const keep = Math.max(1, Math.floor(keepPerFile));
737
687
  const suffix = `-${backupKey}`;
738
688
  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) => {
689
+ const matches = readdirSync2(backupDir).filter((name) => name.endsWith(suffix) || numberedSuffix.test(name)).map((name) => join5(backupDir, name)).sort((a, b) => {
740
690
  return b.localeCompare(a);
741
691
  });
742
692
  for (const file of matches.slice(keep)) {
@@ -761,9 +711,9 @@ function hashVersion(hex) {
761
711
  function hashOfVersion(version) {
762
712
  return /^"sha256:([0-9a-f]{64})"$/.exec(version)?.[1];
763
713
  }
764
- function settledFileTag(stat2, now = Date.now()) {
765
- if (now - stat2.ctimeMs < 3e3) return null;
766
- return [stat2.ino, stat2.ctimeMs, stat2.size].map((n) => n.toString(36)).join("-");
714
+ function settledFileTag(stat3, now = Date.now()) {
715
+ if (now - stat3.ctimeMs < 3e3) return null;
716
+ return [stat3.ino, stat3.ctimeMs, stat3.size].map((n) => n.toString(36)).join("-");
767
717
  }
768
718
  function createWriteToken(requestToken) {
769
719
  const token = requestToken?.trim();
@@ -937,9 +887,9 @@ import { parseHTML as parseHTML2 } from "linkedom";
937
887
  import { ensureHfIds as ensureHfIds2 } from "@hyperframes/parsers/hf-ids";
938
888
 
939
889
  // src/helpers/compositionInsertion.ts
940
- import { existsSync as existsSync2, readFileSync as readFileSync5, realpathSync as realpathSync2 } from "fs";
890
+ import { existsSync as existsSync2, readFileSync as readFileSync5 } from "fs";
941
891
  import { randomUUID as randomUUID3 } from "crypto";
942
- import { dirname as dirname4, relative as relative4, resolve as resolve2, sep as sep3 } from "path";
892
+ import { dirname as dirname3, relative as relative3, resolve as resolve2, sep as sep2 } from "path";
943
893
  import { parseHTML } from "linkedom";
944
894
  var CompositionInsertionError = class extends Error {
945
895
  constructor(message, status) {
@@ -975,8 +925,8 @@ function canonicalProjectPath(projectDir, candidate) {
975
925
  if (!existsSync2(candidate)) {
976
926
  throw new CompositionInsertionError("Composition source was not found", 404);
977
927
  }
978
- const canonical = realpathSync2(candidate);
979
- if (!isSafePath(realpathSync2(projectDir), canonical)) {
928
+ const canonical = realpath(candidate);
929
+ if (!isSafePath(realpath(projectDir), canonical)) {
980
930
  throw new CompositionInsertionError("Composition source escapes the project", 400);
981
931
  }
982
932
  return canonical;
@@ -988,13 +938,13 @@ function validateSourcePath(sourcePath) {
988
938
  }
989
939
  function canonicalProjectFile(projectDir, sourcePath) {
990
940
  validateSourcePath(sourcePath);
991
- return canonicalProjectPath(projectDir, resolveWithinProject2(projectDir, sourcePath));
941
+ return canonicalProjectPath(projectDir, resolveWithinProject(projectDir, sourcePath));
992
942
  }
993
943
  function canonicalDependency(projectDir, ownerAbs, sourcePath) {
994
944
  validateSourcePath(sourcePath);
995
945
  return canonicalProjectPath(
996
946
  projectDir,
997
- resolveWithinProject2(projectDir, relative4(projectDir, resolve2(dirname4(ownerAbs), sourcePath)))
947
+ resolveWithinProject(projectDir, relative3(projectDir, resolve2(dirname3(ownerAbs), sourcePath)))
998
948
  );
999
949
  }
1000
950
  function validateDependencyGraph(projectDir, targetAbs, sourceAbs) {
@@ -1067,7 +1017,7 @@ function uniqueHostId(root, base) {
1067
1017
  return `${base}_${suffix}`;
1068
1018
  }
1069
1019
  function relativeSourcePath(targetAbs, sourceAbs) {
1070
- return relative4(dirname4(targetAbs), sourceAbs).split(sep3).join("/");
1020
+ return relative3(dirname3(targetAbs), sourceAbs).split(sep2).join("/");
1071
1021
  }
1072
1022
  function insertCompositionIntoSource(input) {
1073
1023
  const targetAbs = canonicalProjectFile(input.projectDir, input.targetPath);
@@ -1199,7 +1149,7 @@ async function loadGsapParser() {
1199
1149
  return import("@hyperframes/parsers/gsap-parser-recast");
1200
1150
  }
1201
1151
  function isDanglingSymlinkInProject(projectDir, lexicalPath) {
1202
- if (!isSafePath(projectDir, dirname5(lexicalPath))) return false;
1152
+ if (!isSafePath(projectDir, dirname4(lexicalPath))) return false;
1203
1153
  let stats;
1204
1154
  try {
1205
1155
  stats = lstatSync3(lexicalPath);
@@ -1207,7 +1157,7 @@ function isDanglingSymlinkInProject(projectDir, lexicalPath) {
1207
1157
  return false;
1208
1158
  }
1209
1159
  if (!stats.isSymbolicLink()) return false;
1210
- const target = resolve3(dirname5(lexicalPath), readlinkSync(lexicalPath));
1160
+ const target = resolve3(dirname4(lexicalPath), readlinkSync(lexicalPath));
1211
1161
  if (!isSafePath(projectDir, target)) return false;
1212
1162
  try {
1213
1163
  statSync3(lexicalPath);
@@ -1231,7 +1181,7 @@ async function resolveProjectPath(c, adapter, route, opts) {
1231
1181
  if (filePath.includes("\0")) {
1232
1182
  return { error: c.json({ error: "forbidden", why: "nul" }, 403) };
1233
1183
  }
1234
- const absPath = (opts?.pin ? pinWithinProject : resolveWithinProject2)(project.dir, filePath);
1184
+ const absPath = (opts?.pin ? pinWithinProject : resolveWithinProject)(project.dir, filePath);
1235
1185
  if (!absPath) {
1236
1186
  if (isDanglingSymlinkInProject(project.dir, resolve3(project.dir, filePath))) {
1237
1187
  return { error: c.json({ error: "not found", why: "dangling_symlink" }, 404) };
@@ -1430,7 +1380,7 @@ async function parseMutationBody(c) {
1430
1380
  return { target: body.target, body };
1431
1381
  }
1432
1382
  function ensureDir(filePath) {
1433
- const dir = dirname5(filePath);
1383
+ const dir = dirname4(filePath);
1434
1384
  if (!existsSync3(dir)) mkdirSync4(dir, { recursive: true });
1435
1385
  }
1436
1386
  function generateCopyPath(projectDir, originalPath) {
@@ -1448,8 +1398,8 @@ function generateCopyPath(projectDir, originalPath) {
1448
1398
  }
1449
1399
  function walkFiles(dir, filter) {
1450
1400
  const results = [];
1451
- for (const entry of readdirSync4(dir, { withFileTypes: true })) {
1452
- const full = join7(dir, entry.name);
1401
+ for (const entry of readdirSync3(dir, { withFileTypes: true })) {
1402
+ const full = join6(dir, entry.name);
1453
1403
  if (entry.isDirectory()) {
1454
1404
  if (entry.name === "node_modules" || entry.name === ".thumbnails" || entry.name === "renders" || entry.name === ".transcode-cache")
1455
1405
  continue;
@@ -2612,7 +2562,7 @@ async function processUploadedFiles(formData, targetDir, projectDir) {
2612
2562
  if (n >= MAX_COPY_INDEX) skipped.push(name);
2613
2563
  continue;
2614
2564
  }
2615
- const relativePath = subDir ? join7(subDir, finalName) : finalName;
2565
+ const relativePath = subDir ? join6(subDir, finalName) : finalName;
2616
2566
  uploaded.push(relativePath);
2617
2567
  if (isAudioFile(finalName)) {
2618
2568
  generateWaveformCache(projectDir, relativePath).catch(() => {
@@ -2776,10 +2726,10 @@ function registerFileRoutes(api, adapter) {
2776
2726
  api.delete("/projects/:id/files/*", async (c) => {
2777
2727
  const res = await resolveProjectFile(c, adapter, { mustExist: true });
2778
2728
  if ("error" in res) return res.error;
2779
- const stat2 = statSync3(res.absPath);
2729
+ const stat3 = statSync3(res.absPath);
2780
2730
  const backup = snapshotBeforeWrite(res.project.dir, res.absPath);
2781
2731
  if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
2782
- if (stat2.isDirectory()) {
2732
+ if (stat3.isDirectory()) {
2783
2733
  rmSync3(res.absPath, { recursive: true });
2784
2734
  } else {
2785
2735
  unlinkSync2(res.absPath);
@@ -3295,7 +3245,7 @@ function registerFileRoutes(api, adapter) {
3295
3245
  if (!body.newPath || body.newPath.includes("\0")) {
3296
3246
  return c.json({ error: "newPath required" }, 400);
3297
3247
  }
3298
- const newAbs = resolveWithinProject2(res.project.dir, body.newPath);
3248
+ const newAbs = resolveWithinProject(res.project.dir, body.newPath);
3299
3249
  if (!newAbs) {
3300
3250
  return c.json({ error: "forbidden" }, 403);
3301
3251
  }
@@ -3314,12 +3264,12 @@ function registerFileRoutes(api, adapter) {
3314
3264
  if (!body.path || body.path.includes("\0")) {
3315
3265
  return c.json({ error: "path required" }, 400);
3316
3266
  }
3317
- const srcAbs = resolveWithinProject2(project.dir, body.path);
3267
+ const srcAbs = resolveWithinProject(project.dir, body.path);
3318
3268
  if (!srcAbs || !existsSync3(srcAbs)) {
3319
3269
  return c.json({ error: "not found" }, 404);
3320
3270
  }
3321
3271
  const copyPath = generateCopyPath(project.dir, body.path);
3322
- const destAbs = resolveWithinProject2(project.dir, copyPath);
3272
+ const destAbs = resolveWithinProject(project.dir, copyPath);
3323
3273
  if (!destAbs) {
3324
3274
  return c.json({ error: "forbidden" }, 403);
3325
3275
  }
@@ -3345,7 +3295,7 @@ function registerFileRoutes(api, adapter) {
3345
3295
  const project = await adapter.resolveProject(c.req.param("id"));
3346
3296
  if (!project) return c.json({ error: "not found" }, 404);
3347
3297
  const subDir = c.req.query("dir") ?? "";
3348
- const targetDir = subDir ? resolveWithinProject2(project.dir, subDir) : project.dir;
3298
+ const targetDir = subDir ? resolveWithinProject(project.dir, subDir) : project.dir;
3349
3299
  if (!targetDir) return c.json({ error: "forbidden" }, 403);
3350
3300
  if (subDir && !existsSync3(targetDir)) mkdirSync4(targetDir, { recursive: true });
3351
3301
  const formData = await c.req.formData();
@@ -3429,7 +3379,7 @@ function registerFileRoutes(api, adapter) {
3429
3379
  // src/routes/preview.ts
3430
3380
  import { createReadStream, existsSync as existsSync5, readFileSync as readFileSync8, statSync as statSync4 } from "fs";
3431
3381
  import { Readable } from "stream";
3432
- import { join as join9, resolve as resolve4 } from "path";
3382
+ import { join as join9, resolve as resolve5 } from "path";
3433
3383
  import { createHash as createHash4 } from "crypto";
3434
3384
  import {
3435
3385
  addScenePartsManifest,
@@ -3441,7 +3391,7 @@ import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
3441
3391
 
3442
3392
  // src/helpers/subComposition.ts
3443
3393
  import { existsSync as existsSync4, readFileSync as readFileSync7 } from "fs";
3444
- import { join as join8 } from "path";
3394
+ import { join as join7 } from "path";
3445
3395
  import { parseHTML as parseHTML3 } from "linkedom";
3446
3396
  import {
3447
3397
  rewriteAssetPaths,
@@ -3451,7 +3401,7 @@ import {
3451
3401
  import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler";
3452
3402
  import { isFullHtmlDocument } from "@hyperframes/core/compiler/html-document";
3453
3403
  function rewriteRelativePaths(root, compPath, projectDir) {
3454
- const assetExists = (path) => existsSync4(join8(projectDir, path));
3404
+ const assetExists = (path) => existsSync4(join7(projectDir, path));
3455
3405
  rewriteAssetPaths(
3456
3406
  root.querySelectorAll("[src], [href]"),
3457
3407
  compPath,
@@ -3548,7 +3498,7 @@ function tagRootCompositionFile(bodyHtml, compPath) {
3548
3498
  return bodyHtml.slice(0, tagEnd) + ` data-composition-file="${compPath}"` + bodyHtml.slice(tagEnd);
3549
3499
  }
3550
3500
  function buildSubCompositionHtml(projectDir, compPath, runtimeUrl, baseHref, rawOverride) {
3551
- const compFile = join8(projectDir, compPath);
3501
+ const compFile = join7(projectDir, compPath);
3552
3502
  if (!existsSync4(compFile)) return null;
3553
3503
  const rawComp = rawOverride ?? readFileSync7(compFile, "utf-8");
3554
3504
  let compHeadContent = "";
@@ -3580,7 +3530,7 @@ function buildSubCompositionHtml(projectDir, compPath, runtimeUrl, baseHref, raw
3580
3530
  }
3581
3531
  rewrittenContent = stripEmbeddedRuntimeScripts(rewrittenContent);
3582
3532
  rewrittenContent = tagRootCompositionFile(rewrittenContent, compPath);
3583
- const indexPath = join8(projectDir, "index.html");
3533
+ const indexPath = join7(projectDir, "index.html");
3584
3534
  let headContent = "";
3585
3535
  if (existsSync4(indexPath)) {
3586
3536
  const indexHtml = readFileSync7(indexPath, "utf-8");
@@ -3622,6 +3572,83 @@ function hasBaseElement(html) {
3622
3572
  // src/routes/preview.ts
3623
3573
  import { ensureHfIds as ensureHfIds3 } from "@hyperframes/parsers/hf-ids";
3624
3574
 
3575
+ // src/helpers/previewReads.ts
3576
+ import { readlinkSync as readlinkSync2 } from "fs";
3577
+ import { dirname as dirname5, join as join8, parse, relative as relative4, resolve as resolve4 } from "path";
3578
+ var ALWAYS_AFFECTS = ["hyperframes.json", ...STUDIO_SIGNATURE_MANIFEST_PATHS];
3579
+ var REFERENCE = /\b(?:src|href|poster|data-composition-src)\s*=\s*(?:"([^"\n]*)"|'([^'\n]*)')|url\(\s*(?:"([^"\n]*)"|'([^'\n]*)'|([^"'\s)]+))/gi;
3580
+ var pathKey = process.platform === "darwin" || process.platform === "win32" ? (path) => resolve4(path).toLowerCase() : (path) => resolve4(path);
3581
+ var readsByProject = /* @__PURE__ */ new Map();
3582
+ var builtProjects = /* @__PURE__ */ new Set();
3583
+ var projectKey = (projectDir) => pathKey(realFilePath(resolve4(projectDir)));
3584
+ var inRealProject = (projectDir, path) => join8(realFilePath(resolve4(projectDir)), relative4(resolve4(projectDir), resolve4(projectDir, path)));
3585
+ var readLink = (path) => {
3586
+ try {
3587
+ return readlinkSync2(path);
3588
+ } catch {
3589
+ return null;
3590
+ }
3591
+ };
3592
+ function linksOnTheWay(path) {
3593
+ const passed = [];
3594
+ let at = parse(path).root;
3595
+ let rest = path.slice(at.length).split(/[\\/]+/);
3596
+ for (let hops = 0; rest.length > 0; ) {
3597
+ const part = rest.shift();
3598
+ if (!part || part === ".") continue;
3599
+ if (part === "..") {
3600
+ at = dirname5(at);
3601
+ continue;
3602
+ }
3603
+ const next = join8(at, part);
3604
+ const target = hops < 40 ? readLink(next) : null;
3605
+ if (target === null) {
3606
+ at = next;
3607
+ continue;
3608
+ }
3609
+ hops++;
3610
+ passed.push(next);
3611
+ const root = parse(target).root;
3612
+ if (root) at = root;
3613
+ rest = [...target.slice(root.length).split(/[\\/]+/), ...rest];
3614
+ }
3615
+ passed.push(at);
3616
+ return passed;
3617
+ }
3618
+ function recordPreviewRead(projectDir, filePath) {
3619
+ const key = projectKey(projectDir);
3620
+ let reads = readsByProject.get(key);
3621
+ if (!reads) readsByProject.set(key, reads = /* @__PURE__ */ new Set());
3622
+ const read = inRealProject(projectDir, filePath);
3623
+ for (const found of [read, ...linksOnTheWay(read)]) {
3624
+ for (let path = pathKey(found); !reads.has(path); path = dirname5(path)) reads.add(path);
3625
+ }
3626
+ }
3627
+ function recordPreviewReferences(projectDir, html) {
3628
+ const named = /* @__PURE__ */ new Set();
3629
+ for (const match of html.matchAll(REFERENCE)) {
3630
+ const url = (match[1] ?? match[2] ?? match[3] ?? match[4] ?? match[5] ?? "").trim();
3631
+ if (!url || /^(?:[a-z][a-z0-9+.-]*:|[/#])/i.test(url)) continue;
3632
+ const path = url.split(/[?#]/)[0] ?? "";
3633
+ let decoded = path;
3634
+ try {
3635
+ decoded = decodeURIComponent(path);
3636
+ } catch {
3637
+ }
3638
+ if (decoded) named.add(decoded);
3639
+ }
3640
+ for (const path of named) recordPreviewRead(projectDir, path);
3641
+ }
3642
+ function recordPreviewBuilt(projectDir) {
3643
+ for (const path of ALWAYS_AFFECTS) recordPreviewRead(projectDir, path);
3644
+ builtProjects.add(projectKey(projectDir));
3645
+ }
3646
+ function affectsPreview(projectDir, changedPath) {
3647
+ const key = projectKey(projectDir);
3648
+ if (!builtProjects.has(key)) return true;
3649
+ return readsByProject.get(key)?.has(pathKey(inRealProject(projectDir, changedPath))) ?? false;
3650
+ }
3651
+
3625
3652
  // src/helpers/variablesPayload.ts
3626
3653
  var VARIABLES_PAYLOAD_ERROR = "variables must be a JSON object of {variableId: value}";
3627
3654
  function isVariablesPayload(value) {
@@ -3779,9 +3806,9 @@ function variablesEtagSalt(raw) {
3779
3806
  return `:vars:${createHash4("sha1").update(raw).digest("hex").slice(0, 12)}`;
3780
3807
  }
3781
3808
  function previewVariablesFromRequest(rawVariables) {
3782
- const parse = parsePreviewVariablesParam(rawVariables);
3783
- if (!parse.ok) return { error: parse.error };
3784
- return { raw: rawVariables, values: parse.values };
3809
+ const parse2 = parsePreviewVariablesParam(rawVariables);
3810
+ if (!parse2.ok) return { error: parse2.error };
3811
+ return { raw: rawVariables, values: parse2.values };
3785
3812
  }
3786
3813
  function injectStudioPreviewAugmentations(html, adapter, projectDir, activeCompositionPath) {
3787
3814
  return injectStudioMotionScript(
@@ -3809,13 +3836,15 @@ async function transformPreviewHtml(html, adapter, project, activeCompositionPat
3809
3836
  }
3810
3837
  function resolveProjectMainHtml(projectDir, projectId) {
3811
3838
  const indexPath = join9(projectDir, "index.html");
3839
+ const blockHtmlPath = join9(projectDir, `${projectId}.html`);
3840
+ recordPreviewRead(projectDir, indexPath);
3841
+ recordPreviewRead(projectDir, blockHtmlPath);
3812
3842
  if (existsSync5(indexPath)) {
3813
3843
  return {
3814
3844
  html: readFileSync8(indexPath, "utf-8"),
3815
3845
  compositionPath: "index.html"
3816
3846
  };
3817
3847
  }
3818
- const blockHtmlPath = join9(projectDir, `${projectId}.html`);
3819
3848
  if (existsSync5(blockHtmlPath)) {
3820
3849
  return {
3821
3850
  html: readFileSync8(blockHtmlPath, "utf-8"),
@@ -3847,13 +3876,18 @@ function registerPreviewRoutes(api, adapter) {
3847
3876
  const diskMain = resolveProjectMainHtml(project.dir, project.id);
3848
3877
  const normalizedDisk = diskMain ? ensureHfIds3(diskMain.html) : null;
3849
3878
  try {
3850
- let bundled = await adapter.bundle(project.dir, { stampHfIds: true });
3879
+ let bundled = await adapter.bundle(project.dir, {
3880
+ stampHfIds: true,
3881
+ onRead: (filePath) => recordPreviewRead(project.dir, filePath)
3882
+ });
3851
3883
  let mainCompositionPath = "index.html";
3852
3884
  if (!bundled) {
3853
3885
  if (!diskMain) return null;
3854
3886
  bundled = stripEmbeddedRuntimeScripts2(normalizedDisk ?? diskMain.html);
3855
3887
  mainCompositionPath = diskMain.compositionPath;
3856
3888
  }
3889
+ recordPreviewReferences(project.dir, bundled);
3890
+ recordPreviewBuilt(project.dir);
3857
3891
  if (!bundled.includes("hyperframe.runtime") && !bundled.includes("hyperframes-preview-runtime")) {
3858
3892
  const runtimeTag = `<script src="${adapter.runtimeUrl}"></script>`;
3859
3893
  bundled = insertBeforeCloseTag2(bundled, "body", `${runtimeTag}
@@ -3956,7 +3990,9 @@ ${etag}`;
3956
3990
  if (vars.error !== void 0) return c.json({ error: vars.error }, 400);
3957
3991
  const previewVariables = vars.values;
3958
3992
  const compPath = requestSubPath(c.req.url, "projects/:id/preview/comp");
3959
- const compFile = resolveWithinProject2(project.dir, compPath);
3993
+ const compFile = resolveWithinProject(project.dir, compPath);
3994
+ if (compFile) recordPreviewRead(project.dir, compFile);
3995
+ recordPreviewRead(project.dir, "index.html");
3960
3996
  if (!compFile || !existsSync5(compFile) || !statSync4(compFile).isFile()) {
3961
3997
  return c.text("not found", 404);
3962
3998
  }
@@ -3980,6 +4016,7 @@ ${etag}`;
3980
4016
  stamped
3981
4017
  );
3982
4018
  if (!html) return c.text("not found", 404);
4019
+ recordPreviewReferences(project.dir, html);
3983
4020
  html = ensureHfIds3(await transformPreviewHtml(html, adapter, project, compPath));
3984
4021
  html = injectStudioPreviewAugmentations(html, adapter, project.dir, compPath);
3985
4022
  if (previewVariables) html = injectPreviewVariables(html, previewVariables);
@@ -3990,13 +4027,14 @@ ${etag}`;
3990
4027
  const project = await adapter.resolveProject(c.req.param("id"));
3991
4028
  if (!project) return c.json({ error: "not found" }, 404);
3992
4029
  const subPath = requestSubPath(c.req.url, "projects/:id/preview");
3993
- const candidate = resolve4(project.dir, subPath);
4030
+ const candidate = resolve5(project.dir, subPath);
3994
4031
  const file = isWithinProjectRoot(project.dir, candidate) ? candidate : null;
3995
4032
  if (!file) {
3996
4033
  return c.text("not found", 404);
3997
4034
  }
3998
- const stat2 = existsSync5(file) ? statSync4(file) : null;
3999
- if (!stat2?.isFile()) {
4035
+ recordPreviewRead(project.dir, file);
4036
+ const stat3 = existsSync5(file) ? statSync4(file) : null;
4037
+ if (!stat3?.isFile()) {
4000
4038
  return c.text("not found", 404);
4001
4039
  }
4002
4040
  const contentType = getMimeType(subPath);
@@ -4007,7 +4045,7 @@ ${etag}`;
4007
4045
  if (!isProxyVariantRequest(proxyParam) || !contentType.startsWith("video/") || !isAutoProxyEnabled(adapter)) {
4008
4046
  return c.text("not found", 404);
4009
4047
  }
4010
- const facts = await probeAssetCodec(file);
4048
+ const facts = await probeAssetCodec(file, void 0, mediaCodecProbeCache);
4011
4049
  const eligibility = decideMediaProxyEligibility(facts);
4012
4050
  if (!eligibility.eligible) {
4013
4051
  return c.text(`media proxy unavailable: ${eligibility.reason}`, 422);
@@ -4018,7 +4056,7 @@ ${etag}`;
4018
4056
  return c.text("media proxy variant does not match asset", 422);
4019
4057
  }
4020
4058
  }
4021
- const tag = settledFileTag(stat2);
4059
+ const tag = settledFileTag(stat3);
4022
4060
  const etag = tag && `"${tag}${proxyEtagSalt(proxyVariant)}"`;
4023
4061
  const cacheHeaders = isText ? { "Cache-Control": "no-store" } : { "Cache-Control": "private, no-cache", ...etag && { ETag: etag } };
4024
4062
  if (!isText && etag) {
@@ -4138,7 +4176,7 @@ function registerLintRoutes(api, adapter) {
4138
4176
 
4139
4177
  // src/routes/render.ts
4140
4178
  import { streamSSE } from "hono/streaming";
4141
- import { existsSync as existsSync6, readFileSync as readFileSync10, mkdirSync as mkdirSync5, unlinkSync as unlinkSync3, readdirSync as readdirSync5, statSync as statSync5 } from "fs";
4179
+ import { existsSync as existsSync6, readFileSync as readFileSync10, mkdirSync as mkdirSync5, unlinkSync as unlinkSync3, readdirSync as readdirSync4, statSync as statSync5 } from "fs";
4142
4180
  import { join as join11 } from "path";
4143
4181
  import { VALID_CANVAS_RESOLUTIONS } from "@hyperframes/parsers";
4144
4182
  import { formatRenderOutputTimestamp, parseFps } from "@hyperframes/core";
@@ -4190,7 +4228,7 @@ function registerRenderRoutes(api, adapter) {
4190
4228
  const outputResolution = VALID_RESOLUTIONS.has(body.resolution ?? "") ? body.resolution : void 0;
4191
4229
  let composition;
4192
4230
  if (typeof body.composition === "string" && body.composition.length > 0) {
4193
- if (!resolveWithinProject2(project.dir, body.composition)) {
4231
+ if (!resolveWithinProject(project.dir, body.composition)) {
4194
4232
  return c.json({ error: "composition path must be within the project directory" }, 400);
4195
4233
  }
4196
4234
  composition = body.composition;
@@ -4323,7 +4361,7 @@ function registerRenderRoutes(api, adapter) {
4323
4361
  const filename = c.req.path.split("/renders/file/")[1];
4324
4362
  if (!filename) return c.json({ error: "missing filename" }, 400);
4325
4363
  const rendersDir = adapter.rendersDir(project);
4326
- const fp = resolveWithinProject2(rendersDir, filename);
4364
+ const fp = resolveWithinProject(rendersDir, filename);
4327
4365
  if (!fp) return c.json({ error: "forbidden" }, 403);
4328
4366
  if (!existsSync6(fp)) return c.json({ error: "not found" }, 404);
4329
4367
  const contentType = renderContentType(fp);
@@ -4342,9 +4380,9 @@ function registerRenderRoutes(api, adapter) {
4342
4380
  if (!project) return c.json({ error: "not found" }, 404);
4343
4381
  const rendersDir = adapter.rendersDir(project);
4344
4382
  if (!existsSync6(rendersDir)) return c.json({ renders: [] });
4345
- const files = readdirSync5(rendersDir).filter((f) => f.endsWith(".mp4") || f.endsWith(".webm") || f.endsWith(".mov")).map((f) => {
4383
+ const files = readdirSync4(rendersDir).filter((f) => f.endsWith(".mp4") || f.endsWith(".webm") || f.endsWith(".mov")).map((f) => {
4346
4384
  const fp = join11(rendersDir, f);
4347
- const stat2 = statSync5(fp);
4385
+ const stat3 = statSync5(fp);
4348
4386
  const rid = f.replace(/\.(mp4|webm|mov)$/, "");
4349
4387
  const metaPath = join11(rendersDir, `${rid}.meta.json`);
4350
4388
  let status = "complete";
@@ -4360,8 +4398,8 @@ function registerRenderRoutes(api, adapter) {
4360
4398
  return {
4361
4399
  id: rid,
4362
4400
  filename: f,
4363
- size: stat2.size,
4364
- createdAt: stat2.mtimeMs,
4401
+ size: stat3.size,
4402
+ createdAt: stat3.mtimeMs,
4365
4403
  status,
4366
4404
  durationMs
4367
4405
  };
@@ -4381,80 +4419,11 @@ function registerRenderRoutes(api, adapter) {
4381
4419
  });
4382
4420
  }
4383
4421
 
4384
- // src/routes/thumbnail.ts
4385
- import {
4386
- closeSync as closeSync3,
4387
- existsSync as existsSync7,
4388
- fstatSync as fstatSync3,
4389
- openSync as openSync3,
4390
- mkdirSync as mkdirSync6,
4391
- readFileSync as readFileSync12,
4392
- readdirSync as readdirSync6,
4393
- renameSync as renameSync3,
4394
- rmSync as rmSync4,
4395
- statSync as statSync6,
4396
- unlinkSync as unlinkSync4,
4397
- writeFileSync as writeFileSync6
4398
- } from "fs";
4399
- import { join as join12 } from "path";
4400
- import { createHash as createHash5, randomUUID as randomUUID4 } from "crypto";
4401
-
4402
- // src/helpers/compositionInputs.ts
4403
- import { readFileSync as readFileSync11 } from "fs";
4404
- var ROOT_COMPOSITION = "index.html";
4405
- var COMPOSITION_SRC = /\bdata-composition-src\s*=\s*(["'])(.*?)\1/g;
4406
- function projectReader(projectDir) {
4407
- const sources = /* @__PURE__ */ new Map();
4408
- return (compPath) => {
4409
- if (!sources.has(compPath)) {
4410
- const file = resolveWithinProject2(projectDir, compPath);
4411
- let source = null;
4412
- try {
4413
- source = file ? readFileSync11(file, "utf-8") : null;
4414
- } catch {
4415
- }
4416
- sources.set(compPath, source);
4417
- }
4418
- return sources.get(compPath) ?? null;
4419
- };
4420
- }
4421
- function normalizeSource(src) {
4422
- if (/^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith("//")) return null;
4423
- const path = src.split(/[?#]/)[0]?.replace(/^\.?\//, "") ?? "";
4424
- return path || null;
4425
- }
4426
- function closureOf(read, compPath) {
4427
- const closure = /* @__PURE__ */ new Set();
4428
- const visit = (path) => {
4429
- if (closure.has(path)) return;
4430
- closure.add(path);
4431
- for (const match of (read(path) ?? "").matchAll(COMPOSITION_SRC)) {
4432
- const mounted = normalizeSource(match[2] ?? "");
4433
- if (mounted) visit(mounted);
4434
- }
4435
- };
4436
- visit(compPath);
4437
- return closure;
4438
- }
4439
- var inputSignatures = /* @__PURE__ */ new Map();
4440
- function compositionInputSignature(projectDir, compPath, projectSignature) {
4441
- const key = `${projectDir}\0${compPath}`;
4442
- const known = inputSignatures.get(key);
4443
- if (known?.projectSignature === projectSignature) return known.inputSignature;
4444
- const read = projectReader(projectDir);
4445
- const inputs = closureOf(read, normalizeSource(compPath) ?? compPath).add(ROOT_COMPOSITION);
4446
- const siblings = [...closureOf(read, ROOT_COMPOSITION)].filter((path) => !inputs.has(path));
4447
- const inputSignature = createProjectSignature(projectDir, new Set(siblings));
4448
- inputSignatures.set(key, { projectSignature, inputSignature });
4449
- return inputSignature;
4450
- }
4451
- function compositionsAffectedBy(projectDir, changedPath) {
4452
- const changed = changedPath.replace(/\\/g, "/");
4453
- const read = projectReader(projectDir);
4454
- const mounted = closureOf(read, ROOT_COMPOSITION);
4455
- if (changed === ROOT_COMPOSITION || !mounted.has(changed)) return null;
4456
- return [...mounted].filter((path) => closureOf(read, path).has(changed));
4457
- }
4422
+ // src/routes/imageThumbnail.ts
4423
+ import { HTTPException } from "hono/http-exception";
4424
+ import { open, stat } from "fs/promises";
4425
+ import { createHash as createHash5 } from "crypto";
4426
+ import sharp from "sharp";
4458
4427
 
4459
4428
  // src/routes/thumbnailGenerationCoordinator.ts
4460
4429
  var ThumbnailGenerationCoordinator = class {
@@ -4473,10 +4442,10 @@ var ThumbnailGenerationCoordinator = class {
4473
4442
  if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
4474
4443
  let entry = this.entries.get(key);
4475
4444
  if (!entry) {
4476
- let resolve8;
4445
+ let resolve9;
4477
4446
  let reject;
4478
4447
  const promise = new Promise((resolvePromise, rejectPromise) => {
4479
- resolve8 = resolvePromise;
4448
+ resolve9 = resolvePromise;
4480
4449
  reject = rejectPromise;
4481
4450
  });
4482
4451
  entry = {
@@ -4486,7 +4455,7 @@ var ThumbnailGenerationCoordinator = class {
4486
4455
  state: "queued",
4487
4456
  work,
4488
4457
  promise,
4489
- resolve: resolve8,
4458
+ resolve: resolve9,
4490
4459
  reject
4491
4460
  };
4492
4461
  this.entries.set(key, entry);
@@ -4500,7 +4469,7 @@ var ThumbnailGenerationCoordinator = class {
4500
4469
  return /* @__PURE__ */ new Set([...this.entries.keys(), ...[...this.activeEntries].map((entry) => entry.key)]);
4501
4470
  }
4502
4471
  lease(entry, signal) {
4503
- return new Promise((resolve8, reject) => {
4472
+ return new Promise((resolve9, reject) => {
4504
4473
  let released = false;
4505
4474
  const release = () => {
4506
4475
  if (released) return;
@@ -4524,7 +4493,7 @@ var ThumbnailGenerationCoordinator = class {
4524
4493
  entry.promise.then(
4525
4494
  (value) => {
4526
4495
  release();
4527
- resolve8(value);
4496
+ resolve9(value);
4528
4497
  },
4529
4498
  (reason) => {
4530
4499
  release();
@@ -4559,6 +4528,171 @@ var ThumbnailGenerationCoordinator = class {
4559
4528
  };
4560
4529
  var thumbnailGenerationCoordinator = new ThumbnailGenerationCoordinator(1);
4561
4530
 
4531
+ // src/routes/imageThumbnail.ts
4532
+ var MAX_SOURCE_BYTES = 64 * 1024 * 1024;
4533
+ var MAX_CACHE_BYTES = 8 * 1024 * 1024;
4534
+ async function generateThumbnail(path, signal) {
4535
+ const file = await open(path, "r");
4536
+ try {
4537
+ const info = await file.stat();
4538
+ if (!info.isFile() || info.size > MAX_SOURCE_BYTES) throw new Error("Unsupported image");
4539
+ const source = Buffer.alloc(info.size);
4540
+ let offset = 0;
4541
+ while (offset < source.length) {
4542
+ signal.throwIfAborted();
4543
+ const { bytesRead } = await file.read(source, offset, source.length - offset, offset);
4544
+ if (!bytesRead) break;
4545
+ offset += bytesRead;
4546
+ }
4547
+ if (source[0] !== 255 || source[1] !== 216) throw new Error("Unsupported image");
4548
+ const image = sharp(source.subarray(0, offset), { limitInputPixels: 4e7 });
4549
+ if ((await image.metadata()).format !== "jpeg") throw new Error("Unsupported image");
4550
+ signal.throwIfAborted();
4551
+ const result = await image.rotate().resize(240, 135, { fit: "inside", withoutEnlargement: true }).jpeg({ quality: 80 }).toBuffer();
4552
+ signal.throwIfAborted();
4553
+ return result;
4554
+ } finally {
4555
+ await file.close();
4556
+ }
4557
+ }
4558
+ async function resolveSource(adapter, id, url) {
4559
+ const project = await adapter.resolveProject(id);
4560
+ if (!project) throw new HTTPException(404);
4561
+ let relative6;
4562
+ try {
4563
+ relative6 = requestSubPath(url, "projects/:id/image-thumbnail");
4564
+ } catch {
4565
+ throw new HTTPException(400);
4566
+ }
4567
+ if (!/\.jpe?g$/i.test(relative6)) throw new HTTPException(415);
4568
+ const path = pinWithinProject(project.dir, relative6);
4569
+ if (!path) throw new HTTPException(404);
4570
+ const info = await stat(path).catch(() => {
4571
+ throw new HTTPException(404);
4572
+ });
4573
+ if (!info.isFile()) throw new HTTPException(404);
4574
+ if (info.size > MAX_SOURCE_BYTES) throw new HTTPException(413);
4575
+ return { path, info };
4576
+ }
4577
+ function registerImageThumbnailRoutes(api, adapter) {
4578
+ const coordinator = new ThumbnailGenerationCoordinator(2);
4579
+ const cache = /* @__PURE__ */ new Map();
4580
+ let cacheBytes = 0;
4581
+ const remember = (key, image) => {
4582
+ cacheBytes -= cache.get(key)?.byteLength ?? 0;
4583
+ cache.delete(key);
4584
+ cache.set(key, image);
4585
+ cacheBytes += image.byteLength;
4586
+ while (cacheBytes > MAX_CACHE_BYTES || cache.size > 128) {
4587
+ const oldest = cache.keys().next().value;
4588
+ if (oldest === void 0) break;
4589
+ cacheBytes -= cache.get(oldest)?.byteLength ?? 0;
4590
+ cache.delete(oldest);
4591
+ }
4592
+ };
4593
+ api.get("/projects/:id/image-thumbnail/*", async (c) => {
4594
+ try {
4595
+ const { path, info } = await resolveSource(adapter, c.req.param("id"), c.req.url);
4596
+ const key = createHash5("sha256").update(JSON.stringify([path, info.dev, info.ino, info.size, info.mtimeMs, info.ctimeMs])).digest("hex");
4597
+ const etag = `"${key}"`;
4598
+ c.header("ETag", etag);
4599
+ c.header("Cache-Control", "private, no-cache");
4600
+ c.header("X-Content-Type-Options", "nosniff");
4601
+ if (c.req.header("If-None-Match") === etag) return c.body(null, 304);
4602
+ let result = cache.get(key);
4603
+ if (result) remember(key, result);
4604
+ else {
4605
+ if (coordinator.protectedKeys().size >= 32) return c.text("thumbnail queue full", 429);
4606
+ result = await coordinator.acquire(key, c.req.raw.signal, async (signal) => {
4607
+ const image = await generateThumbnail(path, signal);
4608
+ remember(key, image);
4609
+ return image;
4610
+ }) ?? void 0;
4611
+ }
4612
+ if (!result) return c.text("image unavailable", 422);
4613
+ c.header("Content-Type", "image/jpeg");
4614
+ return c.body(new Uint8Array(result));
4615
+ } catch (error) {
4616
+ if (error instanceof HTTPException) return error.getResponse();
4617
+ return c.text("image unavailable", 422);
4618
+ }
4619
+ });
4620
+ }
4621
+
4622
+ // src/routes/thumbnail.ts
4623
+ import {
4624
+ closeSync as closeSync3,
4625
+ fstatSync as fstatSync3,
4626
+ openSync as openSync3,
4627
+ mkdirSync as mkdirSync6,
4628
+ readFileSync as readFileSync12,
4629
+ readdirSync as readdirSync5,
4630
+ renameSync as renameSync3,
4631
+ rmSync as rmSync4,
4632
+ statSync as statSync6,
4633
+ unlinkSync as unlinkSync4,
4634
+ writeFileSync as writeFileSync6
4635
+ } from "fs";
4636
+ import { join as join12 } from "path";
4637
+ import { createHash as createHash6, randomUUID as randomUUID4 } from "crypto";
4638
+
4639
+ // src/helpers/compositionInputs.ts
4640
+ import { readFileSync as readFileSync11 } from "fs";
4641
+ var ROOT_COMPOSITION = "index.html";
4642
+ var COMPOSITION_SRC = /\bdata-composition-src\s*=\s*(["'])(.*?)\1/g;
4643
+ function projectReader(projectDir) {
4644
+ const sources = /* @__PURE__ */ new Map();
4645
+ return (compPath) => {
4646
+ if (!sources.has(compPath)) {
4647
+ const file = resolveWithinProject(projectDir, compPath);
4648
+ let source = null;
4649
+ try {
4650
+ source = file ? readFileSync11(file, "utf-8") : null;
4651
+ } catch {
4652
+ }
4653
+ sources.set(compPath, source);
4654
+ }
4655
+ return sources.get(compPath) ?? null;
4656
+ };
4657
+ }
4658
+ function normalizeSource(src) {
4659
+ if (/^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith("//")) return null;
4660
+ const path = src.split(/[?#]/)[0]?.replace(/^\.?\//, "") ?? "";
4661
+ return path || null;
4662
+ }
4663
+ function closureOf(read, compPath) {
4664
+ const closure = /* @__PURE__ */ new Set();
4665
+ const visit = (path) => {
4666
+ if (closure.has(path)) return;
4667
+ closure.add(path);
4668
+ for (const match of (read(path) ?? "").matchAll(COMPOSITION_SRC)) {
4669
+ const mounted = normalizeSource(match[2] ?? "");
4670
+ if (mounted) visit(mounted);
4671
+ }
4672
+ };
4673
+ visit(compPath);
4674
+ return closure;
4675
+ }
4676
+ var inputSignatures = /* @__PURE__ */ new Map();
4677
+ function compositionInputSignature(projectDir, compPath, projectSignature) {
4678
+ const key = `${projectDir}\0${compPath}`;
4679
+ const known = inputSignatures.get(key);
4680
+ if (known?.projectSignature === projectSignature) return known.inputSignature;
4681
+ const read = projectReader(projectDir);
4682
+ const inputs = closureOf(read, normalizeSource(compPath) ?? compPath).add(ROOT_COMPOSITION);
4683
+ const siblings = [...closureOf(read, ROOT_COMPOSITION)].filter((path) => !inputs.has(path));
4684
+ const inputSignature = createProjectSignature(projectDir, new Set(siblings));
4685
+ inputSignatures.set(key, { projectSignature, inputSignature });
4686
+ return inputSignature;
4687
+ }
4688
+ function compositionsAffectedBy(projectDir, changedPath) {
4689
+ const changed = changedPath.replace(/\\/g, "/");
4690
+ const read = projectReader(projectDir);
4691
+ const mounted = closureOf(read, ROOT_COMPOSITION);
4692
+ if (changed === ROOT_COMPOSITION || !mounted.has(changed)) return null;
4693
+ return [...mounted].filter((path) => closureOf(read, path).has(changed));
4694
+ }
4695
+
4562
4696
  // src/routes/thumbnail.ts
4563
4697
  var THUMBNAIL_CACHE_VERSION = "v4";
4564
4698
  var THUMBNAIL_MAX_OUTPUT_WIDTH = 240;
@@ -4567,8 +4701,14 @@ var THUMBNAIL_CACHE_MAX_BYTES = 512 * 1024 * 1024;
4567
4701
  var THUMBNAIL_CACHE_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1e3;
4568
4702
  var prunedCacheDirs = /* @__PURE__ */ new Set();
4569
4703
  function pruneThumbnailCache(cacheDir, protectedPaths, now = Date.now()) {
4570
- if (!existsSync7(cacheDir)) return;
4571
- const files = readdirSync6(cacheDir, { withFileTypes: true }).flatMap((entry) => {
4704
+ let entries;
4705
+ try {
4706
+ entries = readdirSync5(cacheDir, { withFileTypes: true });
4707
+ } catch (err) {
4708
+ if (err.code === "ENOENT") return;
4709
+ throw err;
4710
+ }
4711
+ const files = entries.flatMap((entry) => {
4572
4712
  if (!entry.isFile()) return [];
4573
4713
  const path = join12(cacheDir, entry.name);
4574
4714
  try {
@@ -4606,22 +4746,30 @@ function writeThumbnailAtomically(path, buffer) {
4606
4746
  rmSync4(temporaryPath, { force: true });
4607
4747
  }
4608
4748
  }
4609
- function readCompositionSource(file) {
4749
+ function readFileOnce(file) {
4610
4750
  let fd;
4611
4751
  try {
4612
4752
  fd = openSync3(file, "r");
4613
4753
  } catch (err) {
4614
- if (err.code === "ENOENT") return "missing";
4754
+ const code = err.code;
4755
+ if (code === "ENOENT") return "missing";
4756
+ if (code === "ENOTDIR") return "not-a-file";
4615
4757
  throw err;
4616
4758
  }
4617
4759
  try {
4618
- const stat2 = fstatSync3(fd);
4619
- if (!stat2.isFile()) return "not-a-file";
4620
- return { html: readFileSync12(fd, "utf-8"), mtimeMs: stat2.mtimeMs };
4760
+ const stat3 = fstatSync3(fd);
4761
+ if (!stat3.isFile()) return "not-a-file";
4762
+ return { data: readFileSync12(fd), mtimeMs: stat3.mtimeMs };
4621
4763
  } finally {
4622
4764
  closeSync3(fd);
4623
4765
  }
4624
4766
  }
4767
+ function manifestKey(file) {
4768
+ const manifest = readFileOnce(file);
4769
+ if (typeof manifest === "string") return { key: "", mtimeMs: -Infinity };
4770
+ const hash = createHash6("sha1").update(manifest.data).digest("hex").slice(0, 16);
4771
+ return { key: `_${hash}`, mtimeMs: Math.round(manifest.mtimeMs) };
4772
+ }
4625
4773
  function registerThumbnailRoutes(api, adapter) {
4626
4774
  api.get("/projects/:id/thumbnail/*", async (c) => {
4627
4775
  if (!adapter.generateThumbnail) {
@@ -4632,8 +4780,8 @@ function registerThumbnailRoutes(api, adapter) {
4632
4780
  const { project, signature: projectSignature } = resolved;
4633
4781
  let compPath = requestSubPath(c.req.url, "projects/:id/thumbnail");
4634
4782
  if (compPath && !compPath.includes(".")) compPath += ".html";
4635
- const htmlFile = resolveWithinProject2(project.dir, compPath);
4636
- const source = htmlFile ? readCompositionSource(htmlFile) : "not-a-file";
4783
+ const htmlFile = resolveWithinProject(project.dir, compPath);
4784
+ const source = htmlFile ? readFileOnce(htmlFile) : "not-a-file";
4637
4785
  if (source === "not-a-file") return c.json({ error: "not found" }, 404);
4638
4786
  const inputSignature = compositionInputSignature(project.dir, compPath, projectSignature);
4639
4787
  const url = new URL(c.req.url, `http://${c.req.header("host") || "localhost"}`);
@@ -4655,8 +4803,8 @@ function registerThumbnailRoutes(api, adapter) {
4655
4803
  let sourceMtime = 0;
4656
4804
  let sourceKey = "";
4657
4805
  if (source !== "missing") {
4658
- const { html } = source;
4659
- sourceKey = `_${createHash5("sha1").update(html).digest("hex").slice(0, 16)}`;
4806
+ const html = source.data.toString("utf-8");
4807
+ sourceKey = `_${createHash6("sha1").update(html).digest("hex").slice(0, 16)}`;
4660
4808
  sourceMtime = Math.round(source.mtimeMs);
4661
4809
  if (!vpWidth) {
4662
4810
  const wMatch = html.match(/data-width=["'](\d+)["']/);
@@ -4665,30 +4813,19 @@ function registerThumbnailRoutes(api, adapter) {
4665
4813
  if (hMatch?.[1]) compH = parseInt(hMatch[1]);
4666
4814
  }
4667
4815
  }
4668
- const manualEditsFile = join12(project.dir, STUDIO_MANUAL_EDITS_PATH);
4669
- let manualEditsKey = "";
4670
- if (existsSync7(manualEditsFile)) {
4671
- const manualEditsContent = readFileSync12(manualEditsFile, "utf-8");
4672
- manualEditsKey = `_${createHash5("sha1").update(manualEditsContent).digest("hex").slice(0, 16)}`;
4673
- sourceMtime = Math.max(sourceMtime, Math.round(statSync6(manualEditsFile).mtimeMs));
4674
- }
4675
- const motionFile = join12(project.dir, STUDIO_MOTION_PATH);
4676
- let motionKey = "";
4677
- if (existsSync7(motionFile)) {
4678
- const motionContent = readFileSync12(motionFile, "utf-8");
4679
- motionKey = `_${createHash5("sha1").update(motionContent).digest("hex").slice(0, 16)}`;
4680
- sourceMtime = Math.max(sourceMtime, Math.round(statSync6(motionFile).mtimeMs));
4681
- }
4816
+ const manualEdits = manifestKey(join12(project.dir, STUDIO_MANUAL_EDITS_PATH));
4817
+ const motion = manifestKey(join12(project.dir, STUDIO_MOTION_PATH));
4818
+ sourceMtime = Math.max(sourceMtime, manualEdits.mtimeMs, motion.mtimeMs);
4682
4819
  const projectUrl = `http://${c.req.header("host")}/api/projects/${encodeURIComponent(project.id)}`;
4683
4820
  const previewUrl = compPath === "index.html" ? `${projectUrl}/preview` : `${projectUrl}/preview/comp/${compPath.split("/").map(encodeURIComponent).join("/")}`;
4684
4821
  const cacheDir = join12(project.dir, ".thumbnails");
4685
4822
  const selectorKey = selector ? `_${selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}_${selectorIndex ?? 0}` : "";
4686
4823
  const urlVersionKey = urlVersion ? `_${urlVersion.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 32)}` : "";
4687
- const inputSignatureKey = `_${createHash5("sha1").update(inputSignature).digest("hex").slice(0, 16)}`;
4824
+ const inputSignatureKey = `_${createHash6("sha1").update(inputSignature).digest("hex").slice(0, 16)}`;
4688
4825
  const outputScale = outputMode === "source" ? 1 : Math.min(1, THUMBNAIL_MAX_OUTPUT_WIDTH / compW, THUMBNAIL_MAX_OUTPUT_HEIGHT / compH);
4689
4826
  const outputWidth = Math.max(1, Math.round(compW * outputScale));
4690
4827
  const outputHeight = Math.max(1, Math.round(compH * outputScale));
4691
- const cacheKey = `${THUMBNAIL_CACHE_VERSION}${urlVersionKey}${inputSignatureKey}${manualEditsKey}${motionKey}${sourceKey}_${format}_${outputMode}_${compPath.replace(/\//g, "_")}_${compW}x${compH}_${outputWidth}x${outputHeight}_${sourceMtime}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`;
4828
+ 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"}`;
4692
4829
  const cachePath = join12(cacheDir, cacheKey);
4693
4830
  if (!prunedCacheDirs.has(cacheDir)) {
4694
4831
  prunedCacheDirs.add(cacheDir);
@@ -4697,8 +4834,9 @@ function registerThumbnailRoutes(api, adapter) {
4697
4834
  /* @__PURE__ */ new Set([...thumbnailGenerationCoordinator.protectedKeys(), cachePath])
4698
4835
  );
4699
4836
  }
4700
- if (existsSync7(cachePath)) {
4701
- return new Response(new Uint8Array(readFileSync12(cachePath)), {
4837
+ const cached = readFileOnce(cachePath);
4838
+ if (typeof cached === "object") {
4839
+ return new Response(new Uint8Array(cached.data), {
4702
4840
  headers: { "Content-Type": contentType, "Cache-Control": "no-cache" }
4703
4841
  });
4704
4842
  }
@@ -4728,7 +4866,7 @@ function registerThumbnailRoutes(api, adapter) {
4728
4866
  if (afterGeneration?.project.dir !== project.dir || !inputsUnchanged(afterGeneration.signature) || !inputsUnchanged(createProjectSignature(project.dir))) {
4729
4867
  return generated;
4730
4868
  }
4731
- if (!existsSync7(cacheDir)) mkdirSync6(cacheDir, { recursive: true });
4869
+ mkdirSync6(cacheDir, { recursive: true });
4732
4870
  writeThumbnailAtomically(cachePath, generated);
4733
4871
  return generated;
4734
4872
  }
@@ -4754,15 +4892,15 @@ function registerThumbnailRoutes(api, adapter) {
4754
4892
  }
4755
4893
 
4756
4894
  // src/routes/waveform.ts
4757
- import { existsSync as existsSync8, readFileSync as readFileSync13, statSync as statSync7 } from "fs";
4758
- import { join as join13, resolve as resolve5 } from "path";
4895
+ import { existsSync as existsSync7, readFileSync as readFileSync13, statSync as statSync7 } from "fs";
4896
+ import { join as join13, resolve as resolve6 } from "path";
4759
4897
  import { isWithinProjectRoot as isWithinProjectRoot2 } from "@hyperframes/parsers/asset-resolution";
4760
4898
  function registerWaveformRoutes(api, adapter) {
4761
4899
  api.get("/projects/:id/waveform/*", async (c) => {
4762
4900
  const project = await adapter.resolveProject(c.req.param("id"));
4763
4901
  if (!project) return c.json({ error: "not found" }, 404);
4764
4902
  const assetPath = requestSubPath(c.req.url, "projects/:id/waveform");
4765
- const audioPath = resolve5(project.dir, assetPath);
4903
+ const audioPath = resolve6(project.dir, assetPath);
4766
4904
  if (!isWithinProjectRoot2(project.dir, audioPath)) {
4767
4905
  return c.json({ error: "file not found" }, 404);
4768
4906
  }
@@ -4771,7 +4909,7 @@ function registerWaveformRoutes(api, adapter) {
4771
4909
  const cacheDir = join13(project.dir, ".waveform-cache");
4772
4910
  const cachePath = join13(cacheDir, buildWaveformCacheKey(assetPath, stats));
4773
4911
  try {
4774
- if (isWaveformCacheDirectory(cacheDir) && existsSync8(cachePath)) {
4912
+ if (isWaveformCacheDirectory(cacheDir) && existsSync7(cachePath)) {
4775
4913
  const peaks2 = JSON.parse(readFileSync13(cachePath, "utf-8"));
4776
4914
  return c.json({ peaks: peaks2 });
4777
4915
  }
@@ -4904,12 +5042,12 @@ function registerFontRoutes(api) {
4904
5042
  return c.json({ error: "font file not accessible" }, 404);
4905
5043
  }
4906
5044
  try {
4907
- const stat2 = fstatSync4(fd);
4908
- if (stat2.size > SYSTEM_FONT_SIZE_LIMIT) {
5045
+ const stat3 = fstatSync4(fd);
5046
+ if (stat3.size > SYSTEM_FONT_SIZE_LIMIT) {
4909
5047
  return c.json({ error: "font file too large" }, 413);
4910
5048
  }
4911
- const buffer = Buffer.alloc(stat2.size);
4912
- readSync(fd, buffer, 0, stat2.size, 0);
5049
+ const buffer = Buffer.alloc(stat3.size);
5050
+ readSync(fd, buffer, 0, stat3.size, 0);
4913
5051
  const mimeType = located.format === "otf" ? "font/otf" : located.format === "woff2" ? "font/woff2" : located.format === "woff" ? "font/woff" : located.format === "ttc" ? "font/collection" : "font/ttf";
4914
5052
  const fileName = `${family.replace(/[^a-zA-Z0-9 -]/g, "")}.${located.format}`;
4915
5053
  return new Response(buffer, {
@@ -4937,7 +5075,7 @@ function registerRegistryRoutes(api, adapter) {
4937
5075
  });
4938
5076
  api.post("/projects/:id/registry/install", async (c) => {
4939
5077
  if (!adapter.installRegistryBlock) {
4940
- return c.json({ error: "Registry install not available" }, 501);
5078
+ return c.json({ error: "Installing catalog items needs hyperframes preview" }, 501);
4941
5079
  }
4942
5080
  const project = await adapter.resolveProject(c.req.param("id"));
4943
5081
  if (!project) return c.json({ error: "Project not found" }, 404);
@@ -5055,8 +5193,8 @@ function registerSelectionRoutes(api, adapter) {
5055
5193
 
5056
5194
  // src/routes/media.ts
5057
5195
  import { streamSSE as streamSSE2 } from "hono/streaming";
5058
- import { existsSync as existsSync9, mkdirSync as mkdirSync7 } from "fs";
5059
- import { basename as basename3, dirname as dirname6, extname as extname2, join as join14 } from "path";
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";
5060
5198
  var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([
5061
5199
  ".mp4",
5062
5200
  ".mov",
@@ -5086,14 +5224,14 @@ function containsNullByte(path) {
5086
5224
  return path.includes("\0");
5087
5225
  }
5088
5226
  function slugFileBase(path) {
5089
- const name = basename3(path, extname2(path)).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
5227
+ const name = basename2(path, extname2(path)).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
5090
5228
  return name || "media";
5091
5229
  }
5092
5230
  function uniqueAssetPath(projectDir, assetPath) {
5093
5231
  const ext = extname2(assetPath);
5094
5232
  const withoutExt = assetPath.slice(0, -ext.length);
5095
5233
  let candidate = assetPath;
5096
- for (let index = 2; existsSync9(join14(projectDir, candidate)); index++) {
5234
+ for (let index = 2; existsSync8(join14(projectDir, candidate)); index++) {
5097
5235
  candidate = `${withoutExt}-${index}${ext}`;
5098
5236
  }
5099
5237
  return candidate;
@@ -5142,9 +5280,9 @@ function registerMediaRoutes(api, adapter, options = {}) {
5142
5280
  if (/^(?:https?:|data:|blob:)/i.test(assetPath)) {
5143
5281
  return c.json({ error: "media metadata requires a project-local asset" }, 400);
5144
5282
  }
5145
- const filePath = resolveWithinProject2(project.dir, assetPath);
5283
+ const filePath = resolveWithinProject(project.dir, assetPath);
5146
5284
  if (!filePath) return c.json({ error: "forbidden" }, 403);
5147
- if (!existsSync9(filePath)) return c.json({ error: "media not found" }, 404);
5285
+ if (!existsSync8(filePath)) return c.json({ error: "media not found" }, 404);
5148
5286
  return c.json({ path: assetPath, metadata: await readMediaMetadata(filePath) });
5149
5287
  });
5150
5288
  api.post(
@@ -5164,9 +5302,9 @@ function registerMediaRoutes(api, adapter, options = {}) {
5164
5302
  if (/^(?:https?:|data:|blob:)/i.test(inputAssetPath)) {
5165
5303
  return c.json({ error: "background removal requires a project-local media asset" }, 400);
5166
5304
  }
5167
- const inputPath = resolveWithinProject2(project.dir, inputAssetPath);
5305
+ const inputPath = resolveWithinProject(project.dir, inputAssetPath);
5168
5306
  if (!inputPath) return c.json({ error: "forbidden" }, 403);
5169
- if (!existsSync9(inputPath)) return c.json({ error: "input media not found" }, 404);
5307
+ if (!existsSync8(inputPath)) return c.json({ error: "input media not found" }, 404);
5170
5308
  const inputIsVideo = isVideoPath(inputAssetPath);
5171
5309
  const inputIsImage = isImagePath(inputAssetPath);
5172
5310
  if (!inputIsVideo && !inputIsImage) {
@@ -5176,11 +5314,11 @@ function registerMediaRoutes(api, adapter, options = {}) {
5176
5314
  if (requestedOutput && containsNullByte(requestedOutput)) {
5177
5315
  return c.json({ error: "forbidden" }, 403);
5178
5316
  }
5179
- if (requestedOutput && !resolveWithinProject2(project.dir, requestedOutput)) {
5317
+ if (requestedOutput && !resolveWithinProject(project.dir, requestedOutput)) {
5180
5318
  return c.json({ error: "forbidden" }, 403);
5181
5319
  }
5182
5320
  const outputAssetPath = requestedOutput ? uniqueAssetPath(project.dir, requestedOutput) : defaultOutputPath(project.dir, inputAssetPath);
5183
- const outputPath = resolveWithinProject2(project.dir, outputAssetPath);
5321
+ const outputPath = resolveWithinProject(project.dir, outputAssetPath);
5184
5322
  if (!outputPath) return c.json({ error: "forbidden" }, 403);
5185
5323
  if (inputIsVideo && !VIDEO_OUTPUT_EXTENSIONS.has(extname2(outputAssetPath).toLowerCase())) {
5186
5324
  return c.json({ error: "video background removal output must be .webm or .mov" }, 400);
@@ -5195,7 +5333,7 @@ function registerMediaRoutes(api, adapter, options = {}) {
5195
5333
  return c.json({ error: "background plates are only supported for video inputs" }, 400);
5196
5334
  }
5197
5335
  backgroundOutputAssetPath = defaultPlatePath(project.dir, inputAssetPath);
5198
- backgroundOutputPath = resolveWithinProject2(project.dir, backgroundOutputAssetPath) ?? void 0;
5336
+ backgroundOutputPath = resolveWithinProject(project.dir, backgroundOutputAssetPath) ?? void 0;
5199
5337
  if (!backgroundOutputPath) {
5200
5338
  return c.json({ error: "forbidden" }, 403);
5201
5339
  }
@@ -5258,12 +5396,12 @@ function registerMediaRoutes(api, adapter, options = {}) {
5258
5396
  }
5259
5397
 
5260
5398
  // src/routes/globalAssets.ts
5261
- import { existsSync as existsSync10, readFileSync as readFileSync14 } from "fs";
5399
+ import { existsSync as existsSync9, readFileSync as readFileSync14 } from "fs";
5262
5400
  import { homedir } from "os";
5263
5401
  import { join as join15 } from "path";
5264
5402
  function readGlobalAssets(home = homedir()) {
5265
5403
  const manifestPath = join15(home, ".media", "manifest.jsonl");
5266
- if (!existsSync10(manifestPath)) return [];
5404
+ if (!existsSync9(manifestPath)) return [];
5267
5405
  const out = [];
5268
5406
  for (const line of readFileSync14(manifestPath, "utf8").split("\n")) {
5269
5407
  if (!line.trim()) continue;
@@ -5286,26 +5424,26 @@ function registerGlobalAssetRoutes(api) {
5286
5424
  import { randomUUID as randomUUID8 } from "crypto";
5287
5425
  import { readdir as readdir2, rm as rm2, rmdir, writeFile } from "fs/promises";
5288
5426
  import {
5289
- existsSync as existsSync12,
5427
+ existsSync as existsSync11,
5290
5428
  lstatSync as lstatSync4,
5291
5429
  statSync as statSync9,
5292
5430
  mkdirSync as mkdirSync11,
5293
- readdirSync as readdirSync7,
5431
+ readdirSync as readdirSync6,
5294
5432
  readFileSync as readFileSync18,
5295
- realpathSync as realpathSync3,
5433
+ realpathSync,
5296
5434
  rmSync as rmSync6
5297
5435
  } from "fs";
5298
5436
  import { homedir as homedir2 } from "os";
5299
- import { dirname as dirname9, isAbsolute as isAbsolute3, join as join19, relative as relative5, resolve as resolve7, sep as sep4 } from "path";
5437
+ import { dirname as dirname9, isAbsolute as isAbsolute2, join as join19, relative as relative5, resolve as resolve8, sep as sep3 } from "path";
5300
5438
 
5301
5439
  // src/history/blobStore.ts
5302
- import { createHash as createHash6, randomUUID as randomUUID5 } from "crypto";
5440
+ import { createHash as createHash7, randomUUID as randomUUID5 } from "crypto";
5303
5441
  import { constants as constants3, createReadStream as createReadStream2, renameSync as renameSync4 } from "fs";
5304
- import { copyFile, mkdir, readdir, readFile as readFile2, rename, rm, stat } from "fs/promises";
5442
+ import { copyFile, mkdir, readdir, readFile as readFile2, rename, rm, stat as stat2 } from "fs/promises";
5305
5443
  import { dirname as dirname7, join as join16 } from "path";
5306
5444
  var BLOB_HASH = /^[0-9a-f]{64}$/;
5307
5445
  async function hashFile(path) {
5308
- const hash = createHash6("sha256");
5446
+ const hash = createHash7("sha256");
5309
5447
  for await (const chunk of createReadStream2(path)) hash.update(chunk);
5310
5448
  return hash.digest("hex");
5311
5449
  }
@@ -5326,7 +5464,7 @@ async function openBlobStore(dir) {
5326
5464
  await mkdir(dir, { recursive: true });
5327
5465
  for (const shard of await readdir(dir))
5328
5466
  for (const hash of await readdir(join16(dir, shard)).catch(() => []))
5329
- sizes.set(hash, (await stat(join16(dir, shard, hash))).size);
5467
+ sizes.set(hash, (await stat2(join16(dir, shard, hash))).size);
5330
5468
  const pathOf = (hash) => {
5331
5469
  if (!BLOB_HASH.test(hash)) throw new Error("That is not a history blob.");
5332
5470
  return join16(dir, hash.slice(0, 2), hash);
@@ -5343,7 +5481,7 @@ async function openBlobStore(dir) {
5343
5481
  return hash;
5344
5482
  }
5345
5483
  await mkdir(dirname7(pathOf(hash)), { recursive: true });
5346
- const size = (await stat(temp)).size;
5484
+ const size = (await stat2(temp)).size;
5347
5485
  await rename(temp, pathOf(hash));
5348
5486
  sizes.set(hash, size);
5349
5487
  total += size;
@@ -5367,8 +5505,8 @@ async function openBlobStore(dir) {
5367
5505
 
5368
5506
  // src/history/historyId.ts
5369
5507
  import { randomUUID as randomUUID6 } from "crypto";
5370
- import { existsSync as existsSync11, mkdirSync as mkdirSync8, readFileSync as readFileSync15, statSync as statSync8, writeFileSync as writeFileSync7 } from "fs";
5371
- import { join as join17, resolve as resolve6 } from "path";
5508
+ import { existsSync as existsSync10, mkdirSync as mkdirSync8, readFileSync as readFileSync15, statSync as statSync8, writeFileSync as writeFileSync7 } from "fs";
5509
+ import { basename as basename3, join as join17, resolve as resolve7 } from "path";
5372
5510
  var ID_PATH = join17(".hyperframes", "history-id");
5373
5511
  var ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
5374
5512
  function readId(projectDir) {
@@ -5381,18 +5519,38 @@ function readId(projectDir) {
5381
5519
  }
5382
5520
  var sameFolder = (a, b) => a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
5383
5521
  function isRecordedFolder(historyDir, folder) {
5522
+ const was = readRecord(historyDir);
5523
+ if (typeof was?.ino === "number")
5524
+ return sameFolder({ ino: was.ino, birthtimeMs: was.born ?? NaN }, folder);
5525
+ const unreadableSo0878KeptTheId = typeof was?.dir !== "string";
5526
+ if (unreadableSo0878KeptTheId) return true;
5527
+ return !isCopyOf0878Folder(was.dir, basename3(historyDir), folder);
5528
+ }
5529
+ function readRecord(historyDir) {
5530
+ try {
5531
+ return JSON.parse(readFileSync15(join17(historyDir, "project.json"), "utf-8"));
5532
+ } catch {
5533
+ return null;
5534
+ }
5535
+ }
5536
+ function isCopyOf0878Folder(recordedDir, id, folder) {
5537
+ let there;
5384
5538
  try {
5385
- const was = JSON.parse(readFileSync15(join17(historyDir, "project.json"), "utf-8"));
5386
- return sameFolder({ ino: was.ino, birthtimeMs: was.born }, folder);
5539
+ there = statSync8(recordedDir, { throwIfNoEntry: false });
5387
5540
  } catch {
5388
5541
  return false;
5389
5542
  }
5543
+ return !!there && !sameFolder(there, folder) && readId(recordedDir) === id;
5390
5544
  }
5391
5545
  function projectHistoryId(projectDir, historyRoot) {
5392
- const dir = resolve6(projectDir);
5546
+ const dir = resolve7(projectDir);
5393
5547
  const folder = statSync8(dir);
5394
5548
  let id = readId(dir);
5395
- if (!id || existsSync11(join17(historyRoot, id)) && !isRecordedFolder(join17(historyRoot, id), folder)) {
5549
+ if (!id && existsSync10(join17(dir, ID_PATH)))
5550
+ throw new HistoryIdError(
5551
+ `${join17(dir, ID_PATH)} holds no history id this version can read; move it aside to start anew.`
5552
+ );
5553
+ if (!id || existsSync10(join17(historyRoot, id)) && !isRecordedFolder(join17(historyRoot, id), folder)) {
5396
5554
  id = randomUUID6();
5397
5555
  mkdirSync8(join17(dir, ".hyperframes"), { recursive: true });
5398
5556
  writeFileSync7(join17(dir, ID_PATH), `${id}
@@ -5401,6 +5559,12 @@ function projectHistoryId(projectDir, historyRoot) {
5401
5559
  recordProject(join17(historyRoot, id), dir, folder);
5402
5560
  return id;
5403
5561
  }
5562
+ var HistoryIdError = class extends Error {
5563
+ constructor(message) {
5564
+ super(message);
5565
+ this.name = "HistoryIdError";
5566
+ }
5567
+ };
5404
5568
  function recordProject(historyDir, dir, folder) {
5405
5569
  const record = { dir, ino: folder.ino, born: folder.birthtimeMs };
5406
5570
  mkdirSync8(historyDir, { recursive: true });
@@ -5645,7 +5809,7 @@ async function removeEmptyFolders(dir) {
5645
5809
  await rmdir(dir);
5646
5810
  }
5647
5811
  function fileLeftIn(dir, at, deleted) {
5648
- for (const entry of readdirSync7(dir, { withFileTypes: true })) {
5812
+ for (const entry of readdirSync6(dir, { withFileTypes: true })) {
5649
5813
  const path = `${at}/${entry.name}`;
5650
5814
  const left = entry.isDirectory() ? fileLeftIn(join19(dir, entry.name), path, deleted) : deleted.has(path) ? void 0 : path;
5651
5815
  if (left) return left;
@@ -5670,7 +5834,7 @@ var Engine = class {
5670
5834
  this.options = options;
5671
5835
  this.projectId = projectId;
5672
5836
  this.blobs = blobs;
5673
- this.dir = resolve7(options.projectDir);
5837
+ this.dir = resolve8(options.projectDir);
5674
5838
  this.home = join19(options.historyRoot, projectId);
5675
5839
  this.foldsCase = ignoresCase(this.dir);
5676
5840
  const folder = statSync9(this.dir, { throwIfNoEntry: false });
@@ -5732,7 +5896,7 @@ var Engine = class {
5732
5896
  persistLog(record) {
5733
5897
  if (record) saveRecord(this.logFile, this.log, record);
5734
5898
  else writeLog(this.logFile, this.log);
5735
- if (!existsSync12(join19(this.home, "project.json")))
5899
+ if (!existsSync11(join19(this.home, "project.json")))
5736
5900
  recordProject(this.home, this.dir, this.folder);
5737
5901
  }
5738
5902
  replaced() {
@@ -5837,17 +6001,17 @@ var Engine = class {
5837
6001
  throw error;
5838
6002
  }
5839
6003
  }
5840
- async observe(path, stat2, at) {
6004
+ async observe(path, stat3, at) {
5841
6005
  const known = this.tracked.get(path) ?? { hash: null, stat: null };
5842
- if (stat2 && known.stat === stat2) return false;
6006
+ if (stat3 && known.stat === stat3) return false;
5843
6007
  const hash = await this.storeIfPresent(path);
5844
6008
  if (hash === null || this.whereFolder() !== "here") return false;
5845
- this.tracked.set(path, { hash, stat: stat2 });
6009
+ this.tracked.set(path, { hash, stat: stat3 });
5846
6010
  if (known.hash !== hash) await this.record(path, known.hash, hash, at);
5847
- return known.hash !== hash || known.stat !== stat2;
6011
+ return known.hash !== hash || known.stat !== stat3;
5848
6012
  }
5849
6013
  async record(path, before, after, at) {
5850
- const endedBeforeThisWrite = this.windows.filter((open) => !takesWrite(open, at));
6014
+ const endedBeforeThisWrite = this.windows.filter((open2) => !takesWrite(open2, at));
5851
6015
  if (endedBeforeThisWrite.length) await this.commitOutside();
5852
6016
  for (const window2 of endedBeforeThisWrite) await this.endWindow(window2);
5853
6017
  const window = this.windows.at(-1);
@@ -5880,7 +6044,7 @@ var Engine = class {
5880
6044
  if (window.changes.size) await this.commitCut(window, [...window.changes.keys()]);
5881
6045
  }
5882
6046
  pendingElsewhere() {
5883
- return Boolean(this.outside?.changes.size) || this.windows.some((open) => open.changes.size > 0);
6047
+ return Boolean(this.outside?.changes.size) || this.windows.some((open2) => open2.changes.size > 0);
5884
6048
  }
5885
6049
  /** The held claim `taken` continues: same key, each file from its own last change, no held file changed since. */
5886
6050
  heldFor(key, taken) {
@@ -5894,7 +6058,7 @@ var Engine = class {
5894
6058
  return group && taken.every((change) => after(change.path, change.before) === change.before) && [...group.changes.values()].every(untouched) ? group : null;
5895
6059
  }
5896
6060
  logPath(path) {
5897
- return relative5(this.dir, resolve7(this.dir, path)).split(sep4).join("/");
6061
+ return relative5(this.dir, resolve8(this.dir, path)).split(sep3).join("/");
5898
6062
  }
5899
6063
  /** Takes `paths`' uncommitted changes from others, cut at what `who` overwrote. */
5900
6064
  async takeClaimed(who, paths, overwrote) {
@@ -5905,7 +6069,7 @@ var Engine = class {
5905
6069
  hashOfVersion(version)
5906
6070
  ])
5907
6071
  );
5908
- const others = this.windows.filter((open) => !sameWho(open.who, who));
6072
+ const others = this.windows.filter((open2) => !sameWho(open2.who, who));
5909
6073
  const groups = this.outside ? [this.outside, ...others] : others;
5910
6074
  const hits = groups.flatMap(
5911
6075
  (group) => [...group.changes.values()].filter((change) => wanted.has(change.path)).map((change) => ({ group, change }))
@@ -5960,6 +6124,7 @@ var Engine = class {
5960
6124
  part.changes.set(path, window.changes.get(path));
5961
6125
  window.changes.delete(path);
5962
6126
  }
6127
+ (window.parts ??= /* @__PURE__ */ new Set()).add(part.id);
5963
6128
  window.entry = await this.commit(part);
5964
6129
  }
5965
6130
  holdClaim(group, key, idleMs = this.options.quietMs ?? 2e3) {
@@ -6051,7 +6216,7 @@ var Engine = class {
6051
6216
  }
6052
6217
  /** A watcher saw a write: one sweep per burst takes it in (a deleted folder is reported by its name alone). */
6053
6218
  noteChange(path) {
6054
- if (this.notedTimer || !affectsProjectSignature(this.dir, resolve7(this.dir, path))) return;
6219
+ if (this.notedTimer || !affectsProjectSignature(this.dir, resolve8(this.dir, path))) return;
6055
6220
  this.notedTimer = setTimeout(() => {
6056
6221
  this.notedTimer = null;
6057
6222
  this.background(() => this.sweep());
@@ -6093,7 +6258,7 @@ var Engine = class {
6093
6258
  if (!this.windows.includes(window)) return window.entry ?? null;
6094
6259
  clearTimeout(window.idleTimer);
6095
6260
  await this.commitClaim();
6096
- this.windows = this.windows.filter((open) => open !== window);
6261
+ this.windows = this.windows.filter((open2) => open2 !== window);
6097
6262
  window.entry = await this.commit(window) ?? window.entry ?? null;
6098
6263
  return window.entry;
6099
6264
  }
@@ -6109,9 +6274,9 @@ var Engine = class {
6109
6274
  const parts = path.split("/");
6110
6275
  for (let depth = 1; depth <= parts.length; depth += 1) {
6111
6276
  const at = parts.slice(0, depth).join("/");
6112
- const stat2 = lstatSync4(join19(this.dir, at), { throwIfNoEntry: false });
6113
- if (!stat2) return void 0;
6114
- if (at === path || !stat2.isDirectory()) return { at, stat: stat2 };
6277
+ const stat3 = lstatSync4(join19(this.dir, at), { throwIfNoEntry: false });
6278
+ if (!stat3) return void 0;
6279
+ if (at === path || !stat3.isDirectory()) return { at, stat: stat3 };
6115
6280
  }
6116
6281
  return void 0;
6117
6282
  }
@@ -6121,8 +6286,8 @@ var Engine = class {
6121
6286
  if (!standing) return void 0;
6122
6287
  if (standing.stat.isSymbolicLink()) return standing.at;
6123
6288
  if (standing.at === path && !standing.stat.isDirectory()) return void 0;
6124
- const real = realpathSync3.native(join19(this.dir, standing.at));
6125
- const at = relative5(realpathSync3.native(this.dir), real).split(sep4).join("/");
6289
+ const real = realpathSync.native(join19(this.dir, standing.at));
6290
+ const at = relative5(realpathSync.native(this.dir), real).split(sep3).join("/");
6126
6291
  if (standing.at !== path) return deleted.has(at) ? void 0 : at;
6127
6292
  return fileLeftIn(real, at, deleted);
6128
6293
  }
@@ -6139,7 +6304,7 @@ var Engine = class {
6139
6304
  for (const [path, hash] of changes) await this.writeProjectFile(path, hash);
6140
6305
  await this.sweep();
6141
6306
  } finally {
6142
- this.windows = this.windows.filter((open) => open !== group);
6307
+ this.windows = this.windows.filter((open2) => open2 !== group);
6143
6308
  }
6144
6309
  return this.commit(group, extra);
6145
6310
  }
@@ -6199,7 +6364,13 @@ var Engine = class {
6199
6364
  (group) => group?.changes.size && mine(group.who)
6200
6365
  );
6201
6366
  if (pending) return direction === "back" ? this.pendingEntry(pending) : void 0;
6202
- return stepTarget(this.log.entries, direction, (entry) => mine(entry.who));
6367
+ const everyone = this.options.undoScope === "everyone";
6368
+ const ofOpenTurn = (entry) => this.windows.some((open2) => open2.parts?.has(entry.id));
6369
+ return stepTarget(
6370
+ this.log.entries,
6371
+ direction,
6372
+ (entry) => mine(entry.who) || everyone && !ofOpenTurn(entry)
6373
+ );
6203
6374
  }
6204
6375
  /** A pending group as the entry it becomes once committed (a window's part gets a fresh id). */
6205
6376
  pendingEntry(group) {
@@ -6270,8 +6441,8 @@ var Engine = class {
6270
6441
  },
6271
6442
  checkout: (entryId, side, emptyDir) => this.queue(async () => {
6272
6443
  this.entry(entryId);
6273
- if (!existsSync12(this.dir)) throw new Error(`The project folder is gone: ${this.dir}`);
6274
- const dest = resolve7(emptyDir);
6444
+ if (!existsSync11(this.dir)) throw new Error(`The project folder is gone: ${this.dir}`);
6445
+ const dest = resolve8(emptyDir);
6275
6446
  if (isWithin(this.dir, dest))
6276
6447
  throw new Error(`Checkout writes outside the project only: ${dest}`);
6277
6448
  if ((await readdir2(dest).catch(missingIsEmpty)).length > 0)
@@ -6316,10 +6487,10 @@ var Engine = class {
6316
6487
  }
6317
6488
  };
6318
6489
  function isWithin(dir, path) {
6319
- let probe = resolve7(path);
6320
- while (!existsSync12(probe) && dirname9(probe) !== probe) probe = dirname9(probe);
6321
- const rel = relative5(realpathSync3.native(dir), realpathSync3.native(probe));
6322
- return rel === "" || rel !== ".." && !rel.startsWith(`..${sep4}`) && !isAbsolute3(rel);
6490
+ let probe = resolve8(path);
6491
+ while (!existsSync11(probe) && dirname9(probe) !== probe) probe = dirname9(probe);
6492
+ const rel = relative5(realpathSync.native(dir), realpathSync.native(probe));
6493
+ return rel === "" || rel !== ".." && !rel.startsWith(`..${sep3}`) && !isAbsolute2(rel);
6323
6494
  }
6324
6495
  function missingIsEmpty(error) {
6325
6496
  if (error.code === "ENOENT") return [];
@@ -6332,7 +6503,7 @@ var HistoryClosedError = class extends Error {
6332
6503
  }
6333
6504
  };
6334
6505
  async function openProjectHistory(options) {
6335
- if (!existsSync12(options.projectDir))
6506
+ if (!existsSync11(options.projectDir))
6336
6507
  throw new HistoryClosedError(`${options.projectDir} is gone or now another project.`);
6337
6508
  const projectId = projectHistoryId(options.projectDir, options.historyRoot);
6338
6509
  const release = await takeHistoryOwnership(
@@ -6499,6 +6670,7 @@ function createStudioApi(adapter) {
6499
6670
  registerLintRoutes(api, adapter);
6500
6671
  registerRenderRoutes(api, adapter);
6501
6672
  registerThumbnailRoutes(api, adapter);
6673
+ registerImageThumbnailRoutes(api, adapter);
6502
6674
  registerSelectionRoutes(api, adapter);
6503
6675
  registerMediaRoutes(api, adapter);
6504
6676
  registerWaveformRoutes(api, adapter);
@@ -6510,14 +6682,14 @@ function createStudioApi(adapter) {
6510
6682
  }
6511
6683
 
6512
6684
  // src/history/historyCache.ts
6513
- function historyCache(open) {
6685
+ function historyCache(open2) {
6514
6686
  const histories = /* @__PURE__ */ new Map();
6515
6687
  const get = async (projectDir) => {
6516
6688
  let cached = histories.get(projectDir);
6517
- if (!cached) histories.set(projectDir, cached = open(projectDir));
6689
+ if (!cached) histories.set(projectDir, cached = open2(projectDir));
6518
6690
  const opened = await cached;
6519
6691
  if (!opened?.replacedAtPath()) return opened;
6520
- const reopen = () => open(projectDir);
6692
+ const reopen = () => open2(projectDir);
6521
6693
  if (histories.get(projectDir) === cached)
6522
6694
  histories.set(projectDir, opened.close().then(reopen, reopen));
6523
6695
  return get(projectDir);
@@ -6534,6 +6706,7 @@ function historyCache(open) {
6534
6706
  }
6535
6707
 
6536
6708
  // src/helpers/duplicateElement.ts
6709
+ import { sameInstant } from "@hyperframes/core/clip-facts";
6537
6710
  import { ensureHfIds as ensureHfIds4 } from "@hyperframes/parsers/hf-ids";
6538
6711
  function duplicateElementInHtml(source, target, newId, at) {
6539
6712
  const { document, wrappedFragment } = parseSourceDocument(source);
@@ -6568,9 +6741,10 @@ function rippleElements(document, element, at, duration, track) {
6568
6741
  continue;
6569
6742
  }
6570
6743
  const start = numericAttribute(candidate, "data-start");
6571
- if (start !== null && start >= at) {
6572
- candidate.setAttribute("data-start", String(start + duration));
6573
- }
6744
+ if (start === null) continue;
6745
+ const metInsertionPoint = sameInstant(start, at);
6746
+ if (start < at && !metInsertionPoint) continue;
6747
+ candidate.setAttribute("data-start", String((metInsertionPoint ? at : start) + duration));
6574
6748
  }
6575
6749
  }
6576
6750
  function stripStableIds(element) {
@@ -6585,11 +6759,11 @@ function numericAttribute(element, name) {
6585
6759
  }
6586
6760
 
6587
6761
  // src/helpers/previewDocumentStore.ts
6588
- import { createHash as createHash7 } from "crypto";
6589
- import { mkdirSync as mkdirSync12, readFileSync as readFileSync19, readdirSync as readdirSync8, rmSync as rmSync7 } from "fs";
6762
+ import { createHash as createHash8 } from "crypto";
6763
+ import { mkdirSync as mkdirSync12, readFileSync as readFileSync19, readdirSync as readdirSync7, rmSync as rmSync7 } from "fs";
6590
6764
  import { join as join20 } from "path";
6591
6765
  function createPreviewDocumentStore(dir, salt) {
6592
- const fileFor = (key) => join20(dir, `${createHash7("sha256").update(`${salt}
6766
+ const fileFor = (key) => join20(dir, `${createHash8("sha256").update(`${salt}
6593
6767
  ${key}`).digest("hex").slice(0, 32)}.html`);
6594
6768
  return {
6595
6769
  read(key) {
@@ -6604,7 +6778,7 @@ ${key}`).digest("hex").slice(0, 32)}.html`);
6604
6778
  mkdirSync12(dir, { recursive: true });
6605
6779
  const file = fileFor(key);
6606
6780
  replaceFileAtomically(file, html, 420);
6607
- for (const name of readdirSync8(dir)) {
6781
+ for (const name of readdirSync7(dir)) {
6608
6782
  if (join20(dir, name) !== file) rmSync7(join20(dir, name), { force: true });
6609
6783
  }
6610
6784
  } catch {
@@ -6686,12 +6860,14 @@ export {
6686
6860
  START as HISTORY_START,
6687
6861
  HistoryBusyError,
6688
6862
  HistoryClosedError,
6863
+ HistoryIdError,
6689
6864
  MAX_WINDOW_IDLE_MS,
6690
6865
  MIME_TYPES,
6691
6866
  PREVIEW_BUNDLE_OPTIONS,
6692
6867
  STUDIO_MANUAL_EDITS_PATH,
6693
6868
  STUDIO_MOTION_PATH,
6694
6869
  UNDO_MODES,
6870
+ affectsPreview,
6695
6871
  affectsProjectSignature,
6696
6872
  applyFileMutations,
6697
6873
  buildSubCompositionHtml,