@hyperframes/studio-server 0.8.78 → 0.8.80

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,12 +22,12 @@ import {
22
22
  isAutoProxyEnabled,
23
23
  proxyEtagSalt,
24
24
  resolvePreviewMediaCodecProbeCache
25
- } from "./chunk-X3T65KC2.js";
25
+ } from "./chunk-YGANSFUU.js";
26
26
  import {
27
27
  ProxyCapacityError,
28
28
  ProxyTranscodeError,
29
29
  resolveProxy
30
- } from "./chunk-45POWYFS.js";
30
+ } from "./chunk-56JREE33.js";
31
31
  import {
32
32
  PROXY_VARIANT_CONFIG,
33
33
  decideMediaProxyEligibility,
@@ -36,7 +36,7 @@ import {
36
36
  probeMediaMetadata,
37
37
  recordProxyRequest,
38
38
  resolveProxyVariantRequest
39
- } from "./chunk-4MNTZVVZ.js";
39
+ } from "./chunk-KOB22VPG.js";
40
40
  import {
41
41
  STUDIO_MANUAL_EDITS_PATH,
42
42
  createStudioManualEditsRenderBodyScript,
@@ -55,9 +55,25 @@ import { readFile } from "fs/promises";
55
55
  import { join as join3 } from "path";
56
56
 
57
57
  // src/helpers/safePath.ts
58
- import { join } from "path";
59
- import { readdirSync } from "fs";
60
- import { isSafePath, resolveWithinProject } from "@hyperframes/core";
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
+ }
61
77
  var IGNORE_DIRS = /* @__PURE__ */ new Set([
62
78
  ".thumbnails",
63
79
  ".transcode-cache",
@@ -107,7 +123,7 @@ import {
107
123
  readFileSync,
108
124
  writeFileSync
109
125
  } from "fs";
110
- import { dirname, join as join2 } from "path";
126
+ import { dirname as dirname2, join as join2 } from "path";
111
127
 
112
128
  // src/helpers/atomicFile.ts
113
129
  import * as fs from "fs";
@@ -157,7 +173,7 @@ function stampProjectHfIds(projectDir) {
157
173
  next[file] = hash;
158
174
  }
159
175
  try {
160
- mkdirSync(dirname(recordPath), { recursive: true });
176
+ mkdirSync(dirname2(recordPath), { recursive: true });
161
177
  writeFileSync(recordPath, JSON.stringify(next));
162
178
  } catch {
163
179
  }
@@ -211,7 +227,7 @@ function stampFileHfIds(filePath) {
211
227
  // src/helpers/projectSignature.ts
212
228
  import { createHash as createHash2 } from "crypto";
213
229
  import { lstatSync, readFileSync as readFileSync2, readdirSync as readdirSync2 } from "fs";
214
- import { extname, isAbsolute, relative, resolve, sep } from "path";
230
+ import { extname, isAbsolute as isAbsolute2, relative as relative2, resolve, sep as sep2 } from "path";
215
231
  var SIGNATURE_TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
216
232
  ".cjs",
217
233
  ".css",
@@ -246,18 +262,18 @@ var STUDIO_SIGNATURE_MANIFEST_PATHS = [
246
262
  ".hyperframes/studio-motion.json"
247
263
  ];
248
264
  function affectsProjectSignature(projectDir, changedPath) {
249
- const relativePath = relative(resolve(projectDir), resolve(changedPath));
250
- if (relativePath === "" || relativePath.startsWith("..") || isAbsolute(relativePath)) {
265
+ const relativePath = relative2(resolve(projectDir), resolve(changedPath));
266
+ if (relativePath === "" || relativePath.startsWith("..") || isAbsolute2(relativePath)) {
251
267
  return false;
252
268
  }
253
- const segments = relativePath.split(sep);
269
+ const segments = relativePath.split(sep2);
254
270
  if (STUDIO_SIGNATURE_MANIFEST_PATHS.includes(segments.join("/"))) return true;
255
271
  return !segments.some((segment) => SIGNATURE_EXCLUDED_DIRS.has(segment));
256
272
  }
257
273
  var projectSignatureCache = /* @__PURE__ */ new Map();
258
274
  function isPathWithin(parentDir, childPath) {
259
- const childRelativePath = relative(parentDir, childPath);
260
- return childRelativePath === "" || !childRelativePath.startsWith("..") && !isAbsolute(childRelativePath);
275
+ const childRelativePath = relative2(parentDir, childPath);
276
+ return childRelativePath === "" || !childRelativePath.startsWith("..") && !isAbsolute2(childRelativePath);
261
277
  }
262
278
  function isTextContentEligible(file, size) {
263
279
  return SIGNATURE_TEXT_EXTENSIONS.has(extname(file).toLowerCase()) && size <= MAX_SIGNATURE_TEXT_BYTES;
@@ -273,22 +289,22 @@ function collectProjectSignatureFiles(projectDir, dir, files) {
273
289
  if (SIGNATURE_EXCLUDED_DIRS.has(entry)) continue;
274
290
  const file = resolve(dir, entry);
275
291
  if (!isPathWithin(projectDir, file)) continue;
276
- let stat2;
292
+ let stat3;
277
293
  try {
278
- stat2 = lstatSync(file);
294
+ stat3 = lstatSync(file);
279
295
  } catch {
280
296
  continue;
281
297
  }
282
- if (stat2.isSymbolicLink()) continue;
283
- if (stat2.isDirectory()) {
298
+ if (stat3.isSymbolicLink()) continue;
299
+ if (stat3.isDirectory()) {
284
300
  collectProjectSignatureFiles(projectDir, file, files);
285
- } else if (stat2.isFile()) {
301
+ } else if (stat3.isFile()) {
286
302
  files.push({
287
303
  file,
288
- mtimeMs: stat2.mtimeMs,
289
- ctimeMs: stat2.ctimeMs,
290
- size: stat2.size,
291
- textContentEligible: isTextContentEligible(file, stat2.size)
304
+ mtimeMs: stat3.mtimeMs,
305
+ ctimeMs: stat3.ctimeMs,
306
+ size: stat3.size,
307
+ textContentEligible: isTextContentEligible(file, stat3.size)
292
308
  });
293
309
  }
294
310
  }
@@ -298,19 +314,19 @@ function collectProjectSignatureManifestFiles(projectDir, files) {
298
314
  for (const manifestPath of STUDIO_SIGNATURE_MANIFEST_PATHS) {
299
315
  const file = resolve(projectDir, manifestPath);
300
316
  if (seen.has(file) || !isPathWithin(projectDir, file)) continue;
301
- let stat2;
317
+ let stat3;
302
318
  try {
303
- stat2 = lstatSync(file);
319
+ stat3 = lstatSync(file);
304
320
  } catch {
305
321
  continue;
306
322
  }
307
- if (stat2.isSymbolicLink() || !stat2.isFile()) continue;
323
+ if (stat3.isSymbolicLink() || !stat3.isFile()) continue;
308
324
  files.push({
309
325
  file,
310
- mtimeMs: stat2.mtimeMs,
311
- ctimeMs: stat2.ctimeMs,
312
- size: stat2.size,
313
- textContentEligible: isTextContentEligible(file, stat2.size)
326
+ mtimeMs: stat3.mtimeMs,
327
+ ctimeMs: stat3.ctimeMs,
328
+ size: stat3.size,
329
+ textContentEligible: isTextContentEligible(file, stat3.size)
314
330
  });
315
331
  seen.add(file);
316
332
  }
@@ -318,7 +334,7 @@ function collectProjectSignatureManifestFiles(projectDir, files) {
318
334
  function createProjectFingerprint(projectDir, files) {
319
335
  const hash = createHash2("sha256");
320
336
  for (const entry of files) {
321
- hash.update(relative(projectDir, entry.file));
337
+ hash.update(relative2(projectDir, entry.file));
322
338
  hash.update("\0");
323
339
  hash.update(String(entry.size));
324
340
  hash.update("\0");
@@ -340,7 +356,7 @@ function collectProjectFiles(normalizedProjectDir) {
340
356
  function listProjectFiles(projectDir) {
341
357
  const normalizedProjectDir = resolve(projectDir);
342
358
  return collectProjectFiles(normalizedProjectDir).map((entry) => ({
343
- path: relative(normalizedProjectDir, entry.file).split(sep).join("/"),
359
+ path: relative2(normalizedProjectDir, entry.file).split(sep2).join("/"),
344
360
  size: entry.size,
345
361
  mtimeMs: entry.mtimeMs,
346
362
  ctimeMs: entry.ctimeMs
@@ -358,7 +374,7 @@ function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set(
358
374
  const normalizedProjectDir = resolve(projectDir);
359
375
  const collected = collectProjectFiles(normalizedProjectDir);
360
376
  const files = collected.filter(
361
- (entry) => !excluding.has(relative(normalizedProjectDir, entry.file).split(sep).join("/"))
377
+ (entry) => !excluding.has(relative2(normalizedProjectDir, entry.file).split(sep2).join("/"))
362
378
  );
363
379
  files.sort((a, b) => a.file.localeCompare(b.file));
364
380
  const fingerprint = createProjectFingerprint(normalizedProjectDir, files);
@@ -367,7 +383,7 @@ function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set(
367
383
  if (cached?.fingerprint === fingerprint) return cached.signature;
368
384
  const hash = createHash2("sha256");
369
385
  for (const entry of files) {
370
- const relativePath = relative(normalizedProjectDir, entry.file);
386
+ const relativePath = relative2(normalizedProjectDir, entry.file);
371
387
  hash.update(relativePath);
372
388
  hash.update("\0");
373
389
  hash.update(String(entry.size));
@@ -450,7 +466,7 @@ import {
450
466
  renameSync as renameSync2,
451
467
  readdirSync as readdirSync4
452
468
  } from "fs";
453
- import { resolve as resolve3, dirname as dirname4, join as join7 } from "path";
469
+ import { resolve as resolve3, dirname as dirname5, join as join7 } from "path";
454
470
 
455
471
  // src/helpers/mime.ts
456
472
  var MIME_TYPES = {
@@ -510,7 +526,7 @@ import {
510
526
  rmSync,
511
527
  statSync
512
528
  } from "fs";
513
- import { dirname as dirname2, join as join4 } from "path";
529
+ import { dirname as dirname3, join as join4 } from "path";
514
530
  import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
515
531
  var SAMPLE_RATE = 4e3;
516
532
  var PEAK_COUNT = 4e3;
@@ -590,7 +606,7 @@ function isWaveformCacheDirectory(cacheDir) {
590
606
  return lstatSync2(cacheDir, { throwIfNoEntry: false })?.isDirectory() ?? false;
591
607
  }
592
608
  function writeWaveformCache(cachePath, peaks) {
593
- const cacheDir = dirname2(cachePath);
609
+ const cacheDir = dirname3(cachePath);
594
610
  try {
595
611
  mkdirSync2(cacheDir, { mode: 448 });
596
612
  } catch (error) {
@@ -616,7 +632,7 @@ function writeWaveformCache(cachePath, peaks) {
616
632
  import { spawnSync } from "child_process";
617
633
  import { mkdtempSync as mkdtempSync2, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
618
634
  import { tmpdir } from "os";
619
- import { basename, join as join5 } from "path";
635
+ import { basename as basename2, join as join5 } from "path";
620
636
  var VIDEO_EXT = /\.(mp4|webm|mov|mkv|avi|m4v|mxf|mts|m2ts|ts)$/i;
621
637
  var AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
622
638
  function validateUploadedMedia(filePath, runner = spawnSync) {
@@ -654,7 +670,7 @@ function validateUploadedMedia(filePath, runner = spawnSync) {
654
670
  }
655
671
  function validateUploadedMediaBuffer(fileName, buffer, runner = spawnSync) {
656
672
  const tempDir = mkdtempSync2(join5(tmpdir(), "hyperframes-upload-"));
657
- const tempPath = join5(tempDir, basename(fileName));
673
+ const tempPath = join5(tempDir, basename2(fileName));
658
674
  try {
659
675
  writeFileSync3(tempPath, buffer);
660
676
  return validateUploadedMedia(tempPath, runner);
@@ -666,7 +682,7 @@ function validateUploadedMediaBuffer(fileName, buffer, runner = spawnSync) {
666
682
  // src/helpers/backupJournal.ts
667
683
  import { mkdirSync as mkdirSync3, readdirSync as readdirSync3, readFileSync as readFileSync3, unlinkSync, writeFileSync as writeFileSync4 } from "fs";
668
684
  import { Buffer as Buffer2 } from "buffer";
669
- import { join as join6, relative as relative2 } from "path";
685
+ import { join as join6, relative as relative3 } from "path";
670
686
  var DEFAULT_KEEP_PER_FILE = 10;
671
687
  function backupKeyForPath(path) {
672
688
  return Buffer2.from(path, "utf-8").toString("base64url");
@@ -676,7 +692,7 @@ function timestampPrefix() {
676
692
  }
677
693
  function backupPathForResponse(projectDir, backupPath) {
678
694
  if (!backupPath) return null;
679
- const rel = relative2(projectDir, backupPath);
695
+ const rel = relative3(projectDir, backupPath);
680
696
  if (!rel || rel.startsWith("..")) return null;
681
697
  return rel.split("\\").join("/");
682
698
  }
@@ -684,7 +700,7 @@ function snapshotBeforeWrite(projectDir, absPath, options = {}) {
684
700
  if (!isSafePath(projectDir, absPath)) return { backupPath: null };
685
701
  try {
686
702
  const content = readFileSync3(absPath);
687
- const relativePath = relative2(projectDir, absPath);
703
+ const relativePath = relative3(projectDir, absPath);
688
704
  const backupDir = join6(projectDir, ".hyperframes", "backup");
689
705
  mkdirSync3(backupDir, { recursive: true });
690
706
  const backupKey = backupKeyForPath(relativePath);
@@ -745,30 +761,46 @@ function hashVersion(hex) {
745
761
  function hashOfVersion(version) {
746
762
  return /^"sha256:([0-9a-f]{64})"$/.exec(version)?.[1];
747
763
  }
748
- function settledFileTag(stat2, now = Date.now()) {
749
- if (now - stat2.ctimeMs < 3e3) return null;
750
- return [stat2.ino, stat2.ctimeMs, stat2.size].map((n) => n.toString(36)).join("-");
764
+ function settledFileTag(stat3, now = Date.now()) {
765
+ if (now - stat3.ctimeMs < 3e3) return null;
766
+ return [stat3.ino, stat3.ctimeMs, stat3.size].map((n) => n.toString(36)).join("-");
751
767
  }
752
768
  function createWriteToken(requestToken) {
753
769
  const token = requestToken?.trim();
754
770
  return token && token.length <= 200 ? token : randomUUID2();
755
771
  }
756
- function recordFileWriteReceipt(absPath, receipt) {
772
+ function recordFileWriteReceipt(filePath, receipt) {
773
+ const absPath = realFilePath(filePath);
757
774
  const now = Date.now();
758
- const current = (receipts.get(absPath) ?? []).filter(
759
- (entry) => now - entry.recordedAt < RECEIPT_TTL_MS
760
- );
761
- current.push({ ...receipt, recordedAt: now });
762
- receipts.set(absPath, current);
775
+ for (const [path, list] of receipts) {
776
+ const live = list.filter((entry) => now - entry.recordedAt < RECEIPT_TTL_MS);
777
+ if (live.length > 0) receipts.set(path, live);
778
+ else receipts.delete(path);
779
+ }
780
+ receipts.set(absPath, [...receipts.get(absPath) ?? [], { ...receipt, recordedAt: now }]);
763
781
  }
764
- function clearFileWriteReceipt(absPath, version, writeToken) {
782
+ function clearFileWriteReceipt(filePath, version, writeToken) {
783
+ const absPath = realFilePath(filePath);
765
784
  const current = (receipts.get(absPath) ?? []).filter(
766
785
  (entry) => entry.version !== version || entry.writeToken !== writeToken
767
786
  );
768
787
  if (current.length > 0) receipts.set(absPath, current);
769
788
  else receipts.delete(absPath);
770
789
  }
771
- function identifyFileWrite(absPath, expectedVersion) {
790
+ function identifyFileWrite(filePath, expectedVersion) {
791
+ const receipt = newestReceipt(realFilePath(filePath), expectedVersion);
792
+ if (!receipt) return null;
793
+ const { path, version, writeToken } = receipt;
794
+ return { path, version, writeToken };
795
+ }
796
+ function bytesOverwrittenBy(filePath, version) {
797
+ return newestReceipt(realFilePath(filePath), version)?.overwrote;
798
+ }
799
+ function forgetOverwrittenBytes(filePath, versions) {
800
+ for (const receipt of receipts.get(realFilePath(filePath)) ?? [])
801
+ if (versions.has(receipt.version)) delete receipt.overwrote;
802
+ }
803
+ function newestReceipt(absPath, expectedVersion) {
772
804
  const now = Date.now();
773
805
  const current = (receipts.get(absPath) ?? []).filter(
774
806
  (entry) => now - entry.recordedAt < RECEIPT_TTL_MS
@@ -779,15 +811,13 @@ function identifyFileWrite(absPath, expectedVersion) {
779
811
  for (let i = current.length - 1; i >= 0 && !receipt; i -= 1) {
780
812
  if (current[i]?.version === expectedVersion) receipt = current[i];
781
813
  }
782
- if (!receipt) return null;
783
- const { path, version, writeToken } = receipt;
784
- return { path, version, writeToken };
814
+ return receipt;
785
815
  }
786
816
  var consumeFileWriteReceipt = identifyFileWrite;
787
817
 
788
818
  // src/helpers/applyFileMutations.ts
789
819
  import { readFileSync as readFileSync4, statSync as statSync2 } from "fs";
790
- function applyFileMutations(projectDir, mutations, requestToken, writeFile = (path, content) => replaceFileAtomically(path, content, statSync2(path).mode)) {
820
+ function applyFileMutations(projectDir, mutations, requestToken, writeFile2 = (path, content) => replaceFileAtomically(path, content, statSync2(path).mode)) {
791
821
  const prepared = mutations.map((mutation) => ({
792
822
  ...mutation,
793
823
  before: mutation.before ?? readFileSync4(mutation.absPath, "utf-8")
@@ -795,10 +825,10 @@ function applyFileMutations(projectDir, mutations, requestToken, writeFile = (pa
795
825
  const results = [];
796
826
  const attempted = [];
797
827
  for (const mutation of prepared)
798
- assertExpectedVersion(mutation.expectedVersion, readFileSync4(mutation.absPath, "utf-8"));
828
+ assertExpectedVersion(mutation, readFileSync4(mutation.absPath, "utf-8"));
799
829
  try {
800
830
  for (const mutation of prepared) {
801
- results.push(applyOneMutation(projectDir, mutation, requestToken, writeFile, attempted));
831
+ results.push(applyOneMutation(projectDir, mutation, requestToken, writeFile2, attempted));
802
832
  }
803
833
  return results;
804
834
  } catch (error) {
@@ -806,7 +836,7 @@ function applyFileMutations(projectDir, mutations, requestToken, writeFile = (pa
806
836
  for (const mutation of attempted.reverse()) {
807
837
  try {
808
838
  if (!mutation.written || readFileSync4(mutation.absPath, "utf-8") === mutation.after)
809
- writeFile(mutation.absPath, mutation.before, "utf-8");
839
+ writeFile2(mutation.absPath, mutation.before, "utf-8");
810
840
  clearFileWriteReceipt(mutation.absPath, mutation.version, mutation.writeToken);
811
841
  } catch (rollbackError) {
812
842
  rollbackErrors.push(rollbackError);
@@ -821,9 +851,9 @@ function applyFileMutations(projectDir, mutations, requestToken, writeFile = (pa
821
851
  throw error;
822
852
  }
823
853
  }
824
- function applyOneMutation(projectDir, mutation, requestToken, writeFile, attempted) {
854
+ function applyOneMutation(projectDir, mutation, requestToken, writeFile2, attempted) {
825
855
  const current = readFileSync4(mutation.absPath, "utf-8");
826
- assertExpectedVersion(mutation.expectedVersion, current);
856
+ assertExpectedVersion(mutation, current);
827
857
  if (mutation.after === mutation.before) {
828
858
  return {
829
859
  ...mutation,
@@ -836,17 +866,19 @@ function applyOneMutation(projectDir, mutation, requestToken, writeFile, attempt
836
866
  }
837
867
  const backup = snapshotBeforeWrite(projectDir, mutation.absPath);
838
868
  if (backup.error) throw new Error(`backup failed: ${backup.error}`);
869
+ assertExpectedVersion(mutation, readFileSync4(mutation.absPath, "utf-8"));
839
870
  const before = current;
840
871
  const version = fileContentVersion(mutation.after);
841
872
  const writeToken = createWriteToken(requestToken);
842
873
  const attempt = { ...mutation, before, version, writeToken, written: false };
843
874
  attempted.push(attempt);
844
- writeFile(mutation.absPath, mutation.after, "utf-8");
875
+ writeFile2(mutation.absPath, mutation.after, "utf-8");
845
876
  attempt.written = true;
846
877
  recordFileWriteReceipt(mutation.absPath, {
847
878
  path: mutation.sourceFile,
848
879
  version,
849
- writeToken
880
+ writeToken,
881
+ overwrote: before
850
882
  });
851
883
  return {
852
884
  ...mutation,
@@ -857,10 +889,16 @@ function applyOneMutation(projectDir, mutation, requestToken, writeFile, attempt
857
889
  writeToken
858
890
  };
859
891
  }
860
- function assertExpectedVersion(expectedVersion, current) {
861
- if (expectedVersion !== void 0 && fileContentVersion(current) !== expectedVersion) {
862
- throw new Error("file changed since the timeline was read");
892
+ var FileChangedError = class extends Error {
893
+ constructor(sourceFile) {
894
+ super("file changed since the timeline was read");
895
+ this.sourceFile = sourceFile;
863
896
  }
897
+ sourceFile;
898
+ };
899
+ function assertExpectedVersion(mutation, current) {
900
+ const stale = mutation.expectedVersion === void 0 ? current !== mutation.before : fileContentVersion(current) !== mutation.expectedVersion;
901
+ if (stale) throw new FileChangedError(mutation.sourceFile);
864
902
  }
865
903
 
866
904
  // src/routes/files.ts
@@ -899,9 +937,9 @@ import { parseHTML as parseHTML2 } from "linkedom";
899
937
  import { ensureHfIds as ensureHfIds2 } from "@hyperframes/parsers/hf-ids";
900
938
 
901
939
  // src/helpers/compositionInsertion.ts
902
- import { existsSync as existsSync2, readFileSync as readFileSync5, realpathSync } from "fs";
940
+ import { existsSync as existsSync2, readFileSync as readFileSync5, realpathSync as realpathSync2 } from "fs";
903
941
  import { randomUUID as randomUUID3 } from "crypto";
904
- import { dirname as dirname3, relative as relative3, resolve as resolve2, sep as sep2 } from "path";
942
+ import { dirname as dirname4, relative as relative4, resolve as resolve2, sep as sep3 } from "path";
905
943
  import { parseHTML } from "linkedom";
906
944
  var CompositionInsertionError = class extends Error {
907
945
  constructor(message, status) {
@@ -937,8 +975,8 @@ function canonicalProjectPath(projectDir, candidate) {
937
975
  if (!existsSync2(candidate)) {
938
976
  throw new CompositionInsertionError("Composition source was not found", 404);
939
977
  }
940
- const canonical = realpathSync(candidate);
941
- if (!isSafePath(realpathSync(projectDir), canonical)) {
978
+ const canonical = realpathSync2(candidate);
979
+ if (!isSafePath(realpathSync2(projectDir), canonical)) {
942
980
  throw new CompositionInsertionError("Composition source escapes the project", 400);
943
981
  }
944
982
  return canonical;
@@ -950,13 +988,13 @@ function validateSourcePath(sourcePath) {
950
988
  }
951
989
  function canonicalProjectFile(projectDir, sourcePath) {
952
990
  validateSourcePath(sourcePath);
953
- return canonicalProjectPath(projectDir, resolveWithinProject(projectDir, sourcePath));
991
+ return canonicalProjectPath(projectDir, resolveWithinProject2(projectDir, sourcePath));
954
992
  }
955
993
  function canonicalDependency(projectDir, ownerAbs, sourcePath) {
956
994
  validateSourcePath(sourcePath);
957
995
  return canonicalProjectPath(
958
996
  projectDir,
959
- resolveWithinProject(projectDir, relative3(projectDir, resolve2(dirname3(ownerAbs), sourcePath)))
997
+ resolveWithinProject2(projectDir, relative4(projectDir, resolve2(dirname4(ownerAbs), sourcePath)))
960
998
  );
961
999
  }
962
1000
  function validateDependencyGraph(projectDir, targetAbs, sourceAbs) {
@@ -1029,7 +1067,7 @@ function uniqueHostId(root, base) {
1029
1067
  return `${base}_${suffix}`;
1030
1068
  }
1031
1069
  function relativeSourcePath(targetAbs, sourceAbs) {
1032
- return relative3(dirname3(targetAbs), sourceAbs).split(sep2).join("/");
1070
+ return relative4(dirname4(targetAbs), sourceAbs).split(sep3).join("/");
1033
1071
  }
1034
1072
  function insertCompositionIntoSource(input) {
1035
1073
  const targetAbs = canonicalProjectFile(input.projectDir, input.targetPath);
@@ -1161,7 +1199,7 @@ async function loadGsapParser() {
1161
1199
  return import("@hyperframes/parsers/gsap-parser-recast");
1162
1200
  }
1163
1201
  function isDanglingSymlinkInProject(projectDir, lexicalPath) {
1164
- if (!isSafePath(projectDir, dirname4(lexicalPath))) return false;
1202
+ if (!isSafePath(projectDir, dirname5(lexicalPath))) return false;
1165
1203
  let stats;
1166
1204
  try {
1167
1205
  stats = lstatSync3(lexicalPath);
@@ -1169,7 +1207,7 @@ function isDanglingSymlinkInProject(projectDir, lexicalPath) {
1169
1207
  return false;
1170
1208
  }
1171
1209
  if (!stats.isSymbolicLink()) return false;
1172
- const target = resolve3(dirname4(lexicalPath), readlinkSync(lexicalPath));
1210
+ const target = resolve3(dirname5(lexicalPath), readlinkSync(lexicalPath));
1173
1211
  if (!isSafePath(projectDir, target)) return false;
1174
1212
  try {
1175
1213
  statSync3(lexicalPath);
@@ -1193,7 +1231,7 @@ async function resolveProjectPath(c, adapter, route, opts) {
1193
1231
  if (filePath.includes("\0")) {
1194
1232
  return { error: c.json({ error: "forbidden", why: "nul" }, 403) };
1195
1233
  }
1196
- const absPath = resolveWithinProject(project.dir, filePath);
1234
+ const absPath = (opts?.pin ? pinWithinProject : resolveWithinProject2)(project.dir, filePath);
1197
1235
  if (!absPath) {
1198
1236
  if (isDanglingSymlinkInProject(project.dir, resolve3(project.dir, filePath))) {
1199
1237
  return { error: c.json({ error: "not found", why: "dangling_symlink" }, 404) };
@@ -1209,7 +1247,7 @@ function resolveProjectFile(c, adapter, opts) {
1209
1247
  return resolveProjectPath(c, adapter, "files", opts);
1210
1248
  }
1211
1249
  function resolveFileMutationContext(c, adapter, operation) {
1212
- return resolveProjectPath(c, adapter, `file-mutations/${operation}`);
1250
+ return resolveProjectPath(c, adapter, `file-mutations/${operation}`, { pin: true });
1213
1251
  }
1214
1252
  function isOptionalInteger(value) {
1215
1253
  return value === void 0 || Number.isInteger(value);
@@ -1258,11 +1296,23 @@ function foldElementPatches(originalContent, patches) {
1258
1296
  }
1259
1297
  return { content, matched };
1260
1298
  }
1261
- function commitElementPatchBatches(projectDir, batches, writeFile = (path, content) => replaceFileAtomically(path, content, statSync3(path).mode), requestToken) {
1299
+ var PATCH_CONFLICT_ATTEMPTS = 3;
1300
+ function commitElementPatchBatches(projectDir, batches, writeFile2 = (path, content) => replaceFileAtomically(path, content, statSync3(path).mode), requestToken) {
1301
+ for (let attempt = 1; ; attempt += 1) {
1302
+ try {
1303
+ return foldAndCommitElementPatchBatches(projectDir, batches, writeFile2, requestToken);
1304
+ } catch (error) {
1305
+ if (!(error instanceof FileChangedError)) throw error;
1306
+ if (attempt === PATCH_CONFLICT_ATTEMPTS)
1307
+ return { error: "conflict", sourceFile: error.sourceFile };
1308
+ }
1309
+ }
1310
+ }
1311
+ function foldAndCommitElementPatchBatches(projectDir, batches, writeFile2, requestToken) {
1262
1312
  const resolvedPaths = /* @__PURE__ */ new Set();
1263
1313
  const prepared = [];
1264
1314
  for (const batch of batches) {
1265
- const absPath = resolveWithinProject(projectDir, batch.sourceFile);
1315
+ const absPath = pinWithinProject(projectDir, batch.sourceFile);
1266
1316
  if (!absPath) return { error: "forbidden", sourceFile: batch.sourceFile };
1267
1317
  if (resolvedPaths.has(absPath)) return { error: "duplicate", sourceFile: batch.sourceFile };
1268
1318
  resolvedPaths.add(absPath);
@@ -1303,7 +1353,7 @@ function commitElementPatchBatches(projectDir, batches, writeFile = (path, conte
1303
1353
  after
1304
1354
  })),
1305
1355
  requestToken,
1306
- writeFile
1356
+ writeFile2
1307
1357
  );
1308
1358
  const files = applied.map((file, index) => ({
1309
1359
  sourceFile: file.sourceFile,
@@ -1319,23 +1369,24 @@ function commitElementPatchBatchesWithReceipts(c, projectDir, batches) {
1319
1369
  return commitElementPatchBatches(
1320
1370
  projectDir,
1321
1371
  batches,
1322
- writeFileSync5,
1372
+ void 0,
1323
1373
  c.req.header("X-Hyperframes-Write-Token")
1324
1374
  );
1325
1375
  }
1326
- function recordMutationReceipt(c, filePath, absPath, html) {
1376
+ function writeFileWithReceipt(c, filePath, absPath, html) {
1377
+ const overwrote = readFileSync6(absPath);
1378
+ replaceFileAtomically(absPath, html, statSync3(absPath).mode);
1327
1379
  const version = fileContentVersion(html);
1328
1380
  const writeToken = createWriteToken(c.req.header("X-Hyperframes-Write-Token"));
1329
- recordFileWriteReceipt(absPath, { path: filePath, version, writeToken });
1381
+ recordFileWriteReceipt(absPath, { path: filePath, version, writeToken, overwrote });
1330
1382
  return { version, writeToken };
1331
1383
  }
1332
- function writeFileWithReceipt(c, filePath, absPath, html) {
1333
- replaceFileAtomically(absPath, html, statSync3(absPath).mode);
1334
- return recordMutationReceipt(c, filePath, absPath, html);
1335
- }
1336
- function writeMutationResult(c, projectDir, filePath, absPath, html) {
1384
+ function writeMutationResult(c, projectDir, filePath, absPath, html, original) {
1337
1385
  const backup = snapshotBeforeWrite(projectDir, absPath);
1338
1386
  if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
1387
+ if (readFileSync6(absPath, "utf-8") !== original) {
1388
+ return c.json({ error: "file changed", conflict: true, path: filePath }, 409);
1389
+ }
1339
1390
  const { version } = writeFileWithReceipt(c, filePath, absPath, html);
1340
1391
  return { backupPath: backupPathForResponse(projectDir, backup.backupPath), version };
1341
1392
  }
@@ -1343,7 +1394,7 @@ function writeIfChanged(c, projectDir, filePath, absPath, original, next) {
1343
1394
  if (next === original) {
1344
1395
  return c.json({ ok: true, changed: false, content: original, path: filePath });
1345
1396
  }
1346
- const mutationResult = writeMutationResult(c, projectDir, filePath, absPath, next);
1397
+ const mutationResult = writeMutationResult(c, projectDir, filePath, absPath, next, original);
1347
1398
  if (mutationResult instanceof Response) return mutationResult;
1348
1399
  const { backupPath } = mutationResult;
1349
1400
  return c.json({
@@ -1365,6 +1416,8 @@ function rejectUnsafeMutationValues(c, unsafeFields) {
1365
1416
  );
1366
1417
  }
1367
1418
  function elementPatchBatchCommitErrorResponse(c, error, sourceFile) {
1419
+ if (error === "conflict")
1420
+ return c.json({ error: "file changed", conflict: true, sourceFile }, 409);
1368
1421
  if (error === "not-found") return c.json({ error, sourceFile }, 404);
1369
1422
  if (error === "forbidden") return c.json({ error, sourceFile }, 403);
1370
1423
  return c.json({ error: "duplicate source file", sourceFile }, 400);
@@ -1377,7 +1430,7 @@ async function parseMutationBody(c) {
1377
1430
  return { target: body.target, body };
1378
1431
  }
1379
1432
  function ensureDir(filePath) {
1380
- const dir = dirname4(filePath);
1433
+ const dir = dirname5(filePath);
1381
1434
  if (!existsSync3(dir)) mkdirSync4(dir, { recursive: true });
1382
1435
  }
1383
1436
  function generateCopyPath(projectDir, originalPath) {
@@ -1765,7 +1818,8 @@ async function applyGsapMutations(c, res, mutations) {
1765
1818
  res.project.dir,
1766
1819
  res.filePath,
1767
1820
  res.absPath,
1768
- newHtml
1821
+ newHtml,
1822
+ beforeHtml
1769
1823
  );
1770
1824
  if (mutationResult instanceof Response) return mutationResult;
1771
1825
  backupPath = mutationResult.backupPath;
@@ -2598,7 +2652,7 @@ function registerFileRoutes(api, adapter) {
2598
2652
  }
2599
2653
  });
2600
2654
  api.put("/projects/:id/files/*", async (c) => {
2601
- const res = await resolveProjectFile(c, adapter);
2655
+ const res = await resolveProjectFile(c, adapter, { pin: true });
2602
2656
  if ("error" in res) return res.error;
2603
2657
  const body = Buffer.from(await c.req.arrayBuffer());
2604
2658
  const expectedVersion = c.req.header("If-Match")?.trim() ?? null;
@@ -2623,6 +2677,7 @@ function registerFileRoutes(api, adapter) {
2623
2677
  );
2624
2678
  }
2625
2679
  let backup = { backupPath: null };
2680
+ let overwrote;
2626
2681
  if (createOnly) {
2627
2682
  ensureDir(res.absPath);
2628
2683
  let fd;
@@ -2668,6 +2723,7 @@ function registerFileRoutes(api, adapter) {
2668
2723
  }
2669
2724
  try {
2670
2725
  const currentContent = readFileSync6(fd);
2726
+ overwrote = currentContent;
2671
2727
  const currentVersion = fileContentVersion(currentContent);
2672
2728
  if (expectedVersion !== currentVersion) {
2673
2729
  return c.json(
@@ -2692,7 +2748,7 @@ function registerFileRoutes(api, adapter) {
2692
2748
  }
2693
2749
  const version = fileContentVersion(body);
2694
2750
  const writeToken = createWriteToken(c.req.header("X-Hyperframes-Write-Token"));
2695
- recordFileWriteReceipt(res.absPath, { path: res.filePath, version, writeToken });
2751
+ recordFileWriteReceipt(res.absPath, { path: res.filePath, version, writeToken, overwrote });
2696
2752
  c.header("ETag", version);
2697
2753
  return c.json({
2698
2754
  ok: true,
@@ -2720,10 +2776,10 @@ function registerFileRoutes(api, adapter) {
2720
2776
  api.delete("/projects/:id/files/*", async (c) => {
2721
2777
  const res = await resolveProjectFile(c, adapter, { mustExist: true });
2722
2778
  if ("error" in res) return res.error;
2723
- const stat2 = statSync3(res.absPath);
2779
+ const stat3 = statSync3(res.absPath);
2724
2780
  const backup = snapshotBeforeWrite(res.project.dir, res.absPath);
2725
2781
  if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
2726
- if (stat2.isDirectory()) {
2782
+ if (stat3.isDirectory()) {
2727
2783
  rmSync3(res.absPath, { recursive: true });
2728
2784
  } else {
2729
2785
  unlinkSync2(res.absPath);
@@ -2771,6 +2827,11 @@ function registerFileRoutes(api, adapter) {
2771
2827
  }
2772
2828
  const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath);
2773
2829
  if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
2830
+ const current = readFileSync6(ctx.absPath, "utf-8");
2831
+ if (current !== before) {
2832
+ const currentVersion2 = fileContentVersion(current);
2833
+ return c.json({ error: "file conflict", currentVersion: currentVersion2, currentContent: current }, 409);
2834
+ }
2774
2835
  const { version, writeToken } = writeFileWithReceipt(
2775
2836
  c,
2776
2837
  ctx.filePath,
@@ -2847,7 +2908,7 @@ function registerFileRoutes(api, adapter) {
2847
2908
  const seen = /* @__PURE__ */ new Set();
2848
2909
  const prepared = [];
2849
2910
  for (const file of files) {
2850
- const absPath = resolveWithinProject(project.dir, file.path);
2911
+ const absPath = pinWithinProject(project.dir, file.path);
2851
2912
  if (!absPath) return c.json({ error: `forbidden path: ${file.path}` }, 403);
2852
2913
  if (seen.has(absPath)) return c.json({ error: `duplicate path: ${file.path}` }, 400);
2853
2914
  seen.add(absPath);
@@ -2915,7 +2976,8 @@ function registerFileRoutes(api, adapter) {
2915
2976
  recordFileWriteReceipt(file.absPath, {
2916
2977
  path: file.path,
2917
2978
  version: fileContentVersion(file.after),
2918
- writeToken
2979
+ writeToken,
2980
+ overwrote: file.before
2919
2981
  });
2920
2982
  }
2921
2983
  } catch (error) {
@@ -2997,7 +3059,8 @@ function registerFileRoutes(api, adapter) {
2997
3059
  ctx.project.dir,
2998
3060
  ctx.filePath,
2999
3061
  ctx.absPath,
3000
- result.html
3062
+ result.html,
3063
+ originalContent
3001
3064
  );
3002
3065
  if (mutationResult instanceof Response) return mutationResult;
3003
3066
  const { version, backupPath } = mutationResult;
@@ -3024,48 +3087,54 @@ function registerFileRoutes(api, adapter) {
3024
3087
  if (unsafeFields.length > 0) {
3025
3088
  return rejectUnsafeMutationValues(c, unsafeFields);
3026
3089
  }
3027
- let originalContent;
3028
- try {
3029
- originalContent = readFileSync6(ctx.absPath, "utf-8");
3030
- } catch {
3031
- return c.json({ error: "not found" }, 404);
3032
- }
3033
- const { html: patched, matched } = patchElementInHtml(
3034
- originalContent,
3035
- parsed.target,
3036
- parsed.body.operations
3037
- );
3038
- if (patched === originalContent) {
3039
- const version2 = fileContentVersion(originalContent);
3040
- c.header("ETag", version2);
3090
+ for (let attempt = 1; ; attempt += 1) {
3091
+ let originalContent;
3092
+ try {
3093
+ originalContent = readFileSync6(ctx.absPath, "utf-8");
3094
+ } catch {
3095
+ return c.json({ error: "not found" }, 404);
3096
+ }
3097
+ const { html: patched, matched } = patchElementInHtml(
3098
+ originalContent,
3099
+ parsed.target,
3100
+ parsed.body.operations
3101
+ );
3102
+ if (patched === originalContent) {
3103
+ const version2 = fileContentVersion(originalContent);
3104
+ c.header("ETag", version2);
3105
+ return c.json({
3106
+ ok: true,
3107
+ changed: false,
3108
+ matched,
3109
+ content: originalContent,
3110
+ path: ctx.filePath,
3111
+ version: version2
3112
+ });
3113
+ }
3114
+ const mutationResult = writeMutationResult(
3115
+ c,
3116
+ ctx.project.dir,
3117
+ ctx.filePath,
3118
+ ctx.absPath,
3119
+ patched,
3120
+ originalContent
3121
+ );
3122
+ if (mutationResult instanceof Response) {
3123
+ if (mutationResult.status === 409 && attempt < PATCH_CONFLICT_ATTEMPTS) continue;
3124
+ return mutationResult;
3125
+ }
3126
+ const { backupPath, version } = mutationResult;
3127
+ c.header("ETag", version);
3041
3128
  return c.json({
3042
3129
  ok: true,
3043
- changed: false,
3130
+ changed: true,
3044
3131
  matched,
3045
- content: originalContent,
3132
+ content: patched,
3046
3133
  path: ctx.filePath,
3047
- version: version2
3134
+ version,
3135
+ backupPath
3048
3136
  });
3049
3137
  }
3050
- const mutationResult = writeMutationResult(
3051
- c,
3052
- ctx.project.dir,
3053
- ctx.filePath,
3054
- ctx.absPath,
3055
- patched
3056
- );
3057
- if (mutationResult instanceof Response) return mutationResult;
3058
- const { backupPath, version } = mutationResult;
3059
- c.header("ETag", version);
3060
- return c.json({
3061
- ok: true,
3062
- changed: true,
3063
- matched,
3064
- content: patched,
3065
- path: ctx.filePath,
3066
- version,
3067
- backupPath
3068
- });
3069
3138
  });
3070
3139
  api.post("/projects/:id/file-mutations/patch-element-batches", async (c) => {
3071
3140
  const project = await adapter.resolveProject(c.req.param("id"));
@@ -3155,7 +3224,8 @@ function registerFileRoutes(api, adapter) {
3155
3224
  ctx.project.dir,
3156
3225
  ctx.filePath,
3157
3226
  ctx.absPath,
3158
- result.html
3227
+ result.html,
3228
+ originalContent
3159
3229
  );
3160
3230
  if (mutationResult instanceof Response) return mutationResult;
3161
3231
  const { backupPath } = mutationResult;
@@ -3225,7 +3295,7 @@ function registerFileRoutes(api, adapter) {
3225
3295
  if (!body.newPath || body.newPath.includes("\0")) {
3226
3296
  return c.json({ error: "newPath required" }, 400);
3227
3297
  }
3228
- const newAbs = resolveWithinProject(res.project.dir, body.newPath);
3298
+ const newAbs = resolveWithinProject2(res.project.dir, body.newPath);
3229
3299
  if (!newAbs) {
3230
3300
  return c.json({ error: "forbidden" }, 403);
3231
3301
  }
@@ -3244,17 +3314,24 @@ function registerFileRoutes(api, adapter) {
3244
3314
  if (!body.path || body.path.includes("\0")) {
3245
3315
  return c.json({ error: "path required" }, 400);
3246
3316
  }
3247
- const srcAbs = resolveWithinProject(project.dir, body.path);
3317
+ const srcAbs = resolveWithinProject2(project.dir, body.path);
3248
3318
  if (!srcAbs || !existsSync3(srcAbs)) {
3249
3319
  return c.json({ error: "not found" }, 404);
3250
3320
  }
3251
3321
  const copyPath = generateCopyPath(project.dir, body.path);
3252
- const destAbs = resolveWithinProject(project.dir, copyPath);
3322
+ const destAbs = resolveWithinProject2(project.dir, copyPath);
3253
3323
  if (!destAbs) {
3254
3324
  return c.json({ error: "forbidden" }, 403);
3255
3325
  }
3256
3326
  ensureDir(destAbs);
3257
- writeFileSync5(destAbs, readFileSync6(srcAbs));
3327
+ try {
3328
+ writeFileSync5(destAbs, readFileSync6(srcAbs), { flag: "wx" });
3329
+ } catch (error) {
3330
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "EEXIST") {
3331
+ throw error;
3332
+ }
3333
+ return c.json({ error: "already exists" }, 409);
3334
+ }
3258
3335
  return c.json({ ok: true, path: copyPath }, 201);
3259
3336
  });
3260
3337
  const MAX_UPLOAD_BYTES = 500 * 1024 * 1024;
@@ -3268,7 +3345,7 @@ function registerFileRoutes(api, adapter) {
3268
3345
  const project = await adapter.resolveProject(c.req.param("id"));
3269
3346
  if (!project) return c.json({ error: "not found" }, 404);
3270
3347
  const subDir = c.req.query("dir") ?? "";
3271
- const targetDir = subDir ? resolveWithinProject(project.dir, subDir) : project.dir;
3348
+ const targetDir = subDir ? resolveWithinProject2(project.dir, subDir) : project.dir;
3272
3349
  if (!targetDir) return c.json({ error: "forbidden" }, 403);
3273
3350
  if (subDir && !existsSync3(targetDir)) mkdirSync4(targetDir, { recursive: true });
3274
3351
  const formData = await c.req.formData();
@@ -3304,7 +3381,8 @@ function registerFileRoutes(api, adapter) {
3304
3381
  });
3305
3382
  api.post("/projects/:id/gsap-mutations/*", async (c) => {
3306
3383
  const res = await resolveProjectPath(c, adapter, "gsap-mutations", {
3307
- mustExist: true
3384
+ mustExist: true,
3385
+ pin: true
3308
3386
  });
3309
3387
  if ("error" in res) return res.error;
3310
3388
  const body = await c.req.json().catch(() => null);
@@ -3314,7 +3392,10 @@ function registerFileRoutes(api, adapter) {
3314
3392
  return applyGsapMutations(c, res, [body]);
3315
3393
  });
3316
3394
  api.post("/projects/:id/gsap-mutations-batch/*", async (c) => {
3317
- const res = await resolveProjectPath(c, adapter, "gsap-mutations-batch", { mustExist: true });
3395
+ const res = await resolveProjectPath(c, adapter, "gsap-mutations-batch", {
3396
+ mustExist: true,
3397
+ pin: true
3398
+ });
3318
3399
  if ("error" in res) return res.error;
3319
3400
  const body = await c.req.json().catch(() => null);
3320
3401
  if (!body || !Array.isArray(body.mutations) || body.mutations.length === 0) {
@@ -3327,7 +3408,10 @@ function registerFileRoutes(api, adapter) {
3327
3408
  return applyGsapMutations(c, res, body.mutations);
3328
3409
  });
3329
3410
  api.post("/projects/:id/gsap-mutation-rollback/*", async (c) => {
3330
- const res = await resolveProjectPath(c, adapter, "gsap-mutation-rollback", { mustExist: true });
3411
+ const res = await resolveProjectPath(c, adapter, "gsap-mutation-rollback", {
3412
+ mustExist: true,
3413
+ pin: true
3414
+ });
3331
3415
  if ("error" in res) return res.error;
3332
3416
  const body = await c.req.json().catch(() => null);
3333
3417
  if (!body || typeof body.expected !== "string" || typeof body.restore !== "string") {
@@ -3345,9 +3429,10 @@ function registerFileRoutes(api, adapter) {
3345
3429
  // src/routes/preview.ts
3346
3430
  import { createReadStream, existsSync as existsSync5, readFileSync as readFileSync8, statSync as statSync4 } from "fs";
3347
3431
  import { Readable } from "stream";
3348
- import { join as join9, resolve as resolve4 } from "path";
3432
+ import { join as join10, resolve as resolve5 } from "path";
3349
3433
  import { createHash as createHash4 } from "crypto";
3350
3434
  import {
3435
+ addScenePartsManifest,
3351
3436
  injectScriptsIntoHtml,
3352
3437
  insertBeforeCloseTag as insertBeforeCloseTag2,
3353
3438
  stripEmbeddedRuntimeScripts as stripEmbeddedRuntimeScripts2
@@ -3364,9 +3449,7 @@ import {
3364
3449
  rewriteInlineStyleAssetUrls
3365
3450
  } from "@hyperframes/core";
3366
3451
  import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler";
3367
- function isFullHtmlDocument(html) {
3368
- return /^\s*(?:<!doctype\s|<html[\s>])/i.test(html);
3369
- }
3452
+ import { isFullHtmlDocument } from "@hyperframes/core/compiler/html-document";
3370
3453
  function rewriteRelativePaths(root, compPath, projectDir) {
3371
3454
  const assetExists = (path) => existsSync4(join8(projectDir, path));
3372
3455
  rewriteAssetPaths(
@@ -3539,6 +3622,83 @@ function hasBaseElement(html) {
3539
3622
  // src/routes/preview.ts
3540
3623
  import { ensureHfIds as ensureHfIds3 } from "@hyperframes/parsers/hf-ids";
3541
3624
 
3625
+ // src/helpers/previewReads.ts
3626
+ import { readlinkSync as readlinkSync2 } from "fs";
3627
+ import { dirname as dirname6, join as join9, parse, relative as relative5, resolve as resolve4 } from "path";
3628
+ var ALWAYS_AFFECTS = ["hyperframes.json", ...STUDIO_SIGNATURE_MANIFEST_PATHS];
3629
+ var REFERENCE = /\b(?:src|href|poster|data-composition-src)\s*=\s*(?:"([^"\n]*)"|'([^'\n]*)')|url\(\s*(?:"([^"\n]*)"|'([^'\n]*)'|([^"'\s)]+))/gi;
3630
+ var pathKey = process.platform === "darwin" || process.platform === "win32" ? (path) => resolve4(path).toLowerCase() : (path) => resolve4(path);
3631
+ var readsByProject = /* @__PURE__ */ new Map();
3632
+ var builtProjects = /* @__PURE__ */ new Set();
3633
+ var projectKey = (projectDir) => pathKey(realFilePath(resolve4(projectDir)));
3634
+ var inRealProject = (projectDir, path) => join9(realFilePath(resolve4(projectDir)), relative5(resolve4(projectDir), resolve4(projectDir, path)));
3635
+ var readLink = (path) => {
3636
+ try {
3637
+ return readlinkSync2(path);
3638
+ } catch {
3639
+ return null;
3640
+ }
3641
+ };
3642
+ function linksOnTheWay(path) {
3643
+ const passed = [];
3644
+ let at = parse(path).root;
3645
+ let rest = path.slice(at.length).split(/[\\/]+/);
3646
+ for (let hops = 0; rest.length > 0; ) {
3647
+ const part = rest.shift();
3648
+ if (!part || part === ".") continue;
3649
+ if (part === "..") {
3650
+ at = dirname6(at);
3651
+ continue;
3652
+ }
3653
+ const next = join9(at, part);
3654
+ const target = hops < 40 ? readLink(next) : null;
3655
+ if (target === null) {
3656
+ at = next;
3657
+ continue;
3658
+ }
3659
+ hops++;
3660
+ passed.push(next);
3661
+ const root = parse(target).root;
3662
+ if (root) at = root;
3663
+ rest = [...target.slice(root.length).split(/[\\/]+/), ...rest];
3664
+ }
3665
+ passed.push(at);
3666
+ return passed;
3667
+ }
3668
+ function recordPreviewRead(projectDir, filePath) {
3669
+ const key = projectKey(projectDir);
3670
+ let reads = readsByProject.get(key);
3671
+ if (!reads) readsByProject.set(key, reads = /* @__PURE__ */ new Set());
3672
+ const read = inRealProject(projectDir, filePath);
3673
+ for (const found of [read, ...linksOnTheWay(read)]) {
3674
+ for (let path = pathKey(found); !reads.has(path); path = dirname6(path)) reads.add(path);
3675
+ }
3676
+ }
3677
+ function recordPreviewReferences(projectDir, html) {
3678
+ const named = /* @__PURE__ */ new Set();
3679
+ for (const match of html.matchAll(REFERENCE)) {
3680
+ const url = (match[1] ?? match[2] ?? match[3] ?? match[4] ?? match[5] ?? "").trim();
3681
+ if (!url || /^(?:[a-z][a-z0-9+.-]*:|[/#])/i.test(url)) continue;
3682
+ const path = url.split(/[?#]/)[0] ?? "";
3683
+ let decoded = path;
3684
+ try {
3685
+ decoded = decodeURIComponent(path);
3686
+ } catch {
3687
+ }
3688
+ if (decoded) named.add(decoded);
3689
+ }
3690
+ for (const path of named) recordPreviewRead(projectDir, path);
3691
+ }
3692
+ function recordPreviewBuilt(projectDir) {
3693
+ for (const path of ALWAYS_AFFECTS) recordPreviewRead(projectDir, path);
3694
+ builtProjects.add(projectKey(projectDir));
3695
+ }
3696
+ function affectsPreview(projectDir, changedPath) {
3697
+ const key = projectKey(projectDir);
3698
+ if (!builtProjects.has(key)) return true;
3699
+ return readsByProject.get(key)?.has(pathKey(inRealProject(projectDir, changedPath))) ?? false;
3700
+ }
3701
+
3542
3702
  // src/helpers/variablesPayload.ts
3543
3703
  var VARIABLES_PAYLOAD_ERROR = "variables must be a JSON object of {variableId: value}";
3544
3704
  function isVariablesPayload(value) {
@@ -3580,7 +3740,7 @@ function injectProjectSignature(html, signature) {
3580
3740
  ${html}`;
3581
3741
  }
3582
3742
  function readStudioMotionManifestContent(projectDir) {
3583
- const manifestPath = join9(projectDir, STUDIO_MOTION_PATH);
3743
+ const manifestPath = join10(projectDir, STUDIO_MOTION_PATH);
3584
3744
  if (!existsSync5(manifestPath)) return "";
3585
3745
  try {
3586
3746
  return readFileSync8(manifestPath, "utf-8");
@@ -3696,9 +3856,9 @@ function variablesEtagSalt(raw) {
3696
3856
  return `:vars:${createHash4("sha1").update(raw).digest("hex").slice(0, 12)}`;
3697
3857
  }
3698
3858
  function previewVariablesFromRequest(rawVariables) {
3699
- const parse = parsePreviewVariablesParam(rawVariables);
3700
- if (!parse.ok) return { error: parse.error };
3701
- return { raw: rawVariables, values: parse.values };
3859
+ const parse2 = parsePreviewVariablesParam(rawVariables);
3860
+ if (!parse2.ok) return { error: parse2.error };
3861
+ return { raw: rawVariables, values: parse2.values };
3702
3862
  }
3703
3863
  function injectStudioPreviewAugmentations(html, adapter, projectDir, activeCompositionPath) {
3704
3864
  return injectStudioMotionScript(
@@ -3725,14 +3885,16 @@ async function transformPreviewHtml(html, adapter, project, activeCompositionPat
3725
3885
  }
3726
3886
  }
3727
3887
  function resolveProjectMainHtml(projectDir, projectId) {
3728
- const indexPath = join9(projectDir, "index.html");
3888
+ const indexPath = join10(projectDir, "index.html");
3889
+ const blockHtmlPath = join10(projectDir, `${projectId}.html`);
3890
+ recordPreviewRead(projectDir, indexPath);
3891
+ recordPreviewRead(projectDir, blockHtmlPath);
3729
3892
  if (existsSync5(indexPath)) {
3730
3893
  return {
3731
3894
  html: readFileSync8(indexPath, "utf-8"),
3732
3895
  compositionPath: "index.html"
3733
3896
  };
3734
3897
  }
3735
- const blockHtmlPath = join9(projectDir, `${projectId}.html`);
3736
3898
  if (existsSync5(blockHtmlPath)) {
3737
3899
  return {
3738
3900
  html: readFileSync8(blockHtmlPath, "utf-8"),
@@ -3744,7 +3906,8 @@ function resolveProjectMainHtml(projectDir, projectId) {
3744
3906
  var PREVIEW_BUNDLE_OPTIONS = {
3745
3907
  runtime: "placeholder",
3746
3908
  inlineAssets: false,
3747
- staticGuard: false
3909
+ staticGuard: false,
3910
+ sceneParts: true
3748
3911
  };
3749
3912
  function registerPreviewRoutes(api, adapter) {
3750
3913
  const previewCacheHeaders = (etag) => ({
@@ -3763,13 +3926,18 @@ function registerPreviewRoutes(api, adapter) {
3763
3926
  const diskMain = resolveProjectMainHtml(project.dir, project.id);
3764
3927
  const normalizedDisk = diskMain ? ensureHfIds3(diskMain.html) : null;
3765
3928
  try {
3766
- let bundled = await adapter.bundle(project.dir, { stampHfIds: true });
3929
+ let bundled = await adapter.bundle(project.dir, {
3930
+ stampHfIds: true,
3931
+ onRead: (filePath) => recordPreviewRead(project.dir, filePath)
3932
+ });
3767
3933
  let mainCompositionPath = "index.html";
3768
3934
  if (!bundled) {
3769
3935
  if (!diskMain) return null;
3770
3936
  bundled = stripEmbeddedRuntimeScripts2(normalizedDisk ?? diskMain.html);
3771
3937
  mainCompositionPath = diskMain.compositionPath;
3772
3938
  }
3939
+ recordPreviewReferences(project.dir, bundled);
3940
+ recordPreviewBuilt(project.dir);
3773
3941
  if (!bundled.includes("hyperframe.runtime") && !bundled.includes("hyperframes-preview-runtime")) {
3774
3942
  const runtimeTag = `<script src="${adapter.runtimeUrl}"></script>`;
3775
3943
  bundled = insertBeforeCloseTag2(bundled, "body", `${runtimeTag}
@@ -3794,6 +3962,7 @@ ${runtimeTag}`;
3794
3962
  mainCompositionPath,
3795
3963
  mediaCodecProbeCache
3796
3964
  );
3965
+ bundled = addScenePartsManifest(bundled, [`meta[name="${PROJECT_SIGNATURE_META}"]`]);
3797
3966
  rememberPreview(builtKey, bundled);
3798
3967
  adapter.previewDocuments?.write(builtKey, bundled);
3799
3968
  return bundled;
@@ -3871,7 +4040,9 @@ ${etag}`;
3871
4040
  if (vars.error !== void 0) return c.json({ error: vars.error }, 400);
3872
4041
  const previewVariables = vars.values;
3873
4042
  const compPath = requestSubPath(c.req.url, "projects/:id/preview/comp");
3874
- const compFile = resolveWithinProject(project.dir, compPath);
4043
+ const compFile = resolveWithinProject2(project.dir, compPath);
4044
+ if (compFile) recordPreviewRead(project.dir, compFile);
4045
+ recordPreviewRead(project.dir, "index.html");
3875
4046
  if (!compFile || !existsSync5(compFile) || !statSync4(compFile).isFile()) {
3876
4047
  return c.text("not found", 404);
3877
4048
  }
@@ -3895,6 +4066,7 @@ ${etag}`;
3895
4066
  stamped
3896
4067
  );
3897
4068
  if (!html) return c.text("not found", 404);
4069
+ recordPreviewReferences(project.dir, html);
3898
4070
  html = ensureHfIds3(await transformPreviewHtml(html, adapter, project, compPath));
3899
4071
  html = injectStudioPreviewAugmentations(html, adapter, project.dir, compPath);
3900
4072
  if (previewVariables) html = injectPreviewVariables(html, previewVariables);
@@ -3905,13 +4077,14 @@ ${etag}`;
3905
4077
  const project = await adapter.resolveProject(c.req.param("id"));
3906
4078
  if (!project) return c.json({ error: "not found" }, 404);
3907
4079
  const subPath = requestSubPath(c.req.url, "projects/:id/preview");
3908
- const candidate = resolve4(project.dir, subPath);
4080
+ const candidate = resolve5(project.dir, subPath);
3909
4081
  const file = isWithinProjectRoot(project.dir, candidate) ? candidate : null;
3910
4082
  if (!file) {
3911
4083
  return c.text("not found", 404);
3912
4084
  }
3913
- const stat2 = existsSync5(file) ? statSync4(file) : null;
3914
- if (!stat2?.isFile()) {
4085
+ recordPreviewRead(project.dir, file);
4086
+ const stat3 = existsSync5(file) ? statSync4(file) : null;
4087
+ if (!stat3?.isFile()) {
3915
4088
  return c.text("not found", 404);
3916
4089
  }
3917
4090
  const contentType = getMimeType(subPath);
@@ -3922,7 +4095,7 @@ ${etag}`;
3922
4095
  if (!isProxyVariantRequest(proxyParam) || !contentType.startsWith("video/") || !isAutoProxyEnabled(adapter)) {
3923
4096
  return c.text("not found", 404);
3924
4097
  }
3925
- const facts = await probeAssetCodec(file);
4098
+ const facts = await probeAssetCodec(file, void 0, mediaCodecProbeCache);
3926
4099
  const eligibility = decideMediaProxyEligibility(facts);
3927
4100
  if (!eligibility.eligible) {
3928
4101
  return c.text(`media proxy unavailable: ${eligibility.reason}`, 422);
@@ -3933,7 +4106,7 @@ ${etag}`;
3933
4106
  return c.text("media proxy variant does not match asset", 422);
3934
4107
  }
3935
4108
  }
3936
- const tag = settledFileTag(stat2);
4109
+ const tag = settledFileTag(stat3);
3937
4110
  const etag = tag && `"${tag}${proxyEtagSalt(proxyVariant)}"`;
3938
4111
  const cacheHeaders = isText ? { "Cache-Control": "no-store" } : { "Cache-Control": "private, no-cache", ...etag && { ETag: etag } };
3939
4112
  if (!isText && etag) {
@@ -4002,7 +4175,7 @@ ${etag}`;
4002
4175
 
4003
4176
  // src/routes/lint.ts
4004
4177
  import { readFileSync as readFileSync9 } from "fs";
4005
- import { join as join10 } from "path";
4178
+ import { join as join11 } from "path";
4006
4179
  async function collectProjectFindings(adapter, projectDir) {
4007
4180
  const findings = [];
4008
4181
  const coveredFiles = /* @__PURE__ */ new Set();
@@ -4020,7 +4193,7 @@ async function lintUncoveredHtml(adapter, projectDir, htmlFiles, coveredFiles) {
4020
4193
  const findings = [];
4021
4194
  for (const file of htmlFiles) {
4022
4195
  if (coveredFiles.has(file)) continue;
4023
- const content = readFileSync9(join10(projectDir, file), "utf-8");
4196
+ const content = readFileSync9(join11(projectDir, file), "utf-8");
4024
4197
  const result = await adapter.lint(content, { filePath: file, isSubComposition: true });
4025
4198
  for (const finding of result?.findings ?? []) {
4026
4199
  findings.push({ ...finding, file });
@@ -4054,7 +4227,7 @@ function registerLintRoutes(api, adapter) {
4054
4227
  // src/routes/render.ts
4055
4228
  import { streamSSE } from "hono/streaming";
4056
4229
  import { existsSync as existsSync6, readFileSync as readFileSync10, mkdirSync as mkdirSync5, unlinkSync as unlinkSync3, readdirSync as readdirSync5, statSync as statSync5 } from "fs";
4057
- import { join as join11 } from "path";
4230
+ import { join as join12 } from "path";
4058
4231
  import { VALID_CANVAS_RESOLUTIONS } from "@hyperframes/parsers";
4059
4232
  import { formatRenderOutputTimestamp, parseFps } from "@hyperframes/core";
4060
4233
  var VALID_RESOLUTIONS = new Set(VALID_CANVAS_RESOLUTIONS);
@@ -4105,7 +4278,7 @@ function registerRenderRoutes(api, adapter) {
4105
4278
  const outputResolution = VALID_RESOLUTIONS.has(body.resolution ?? "") ? body.resolution : void 0;
4106
4279
  let composition;
4107
4280
  if (typeof body.composition === "string" && body.composition.length > 0) {
4108
- if (!resolveWithinProject(project.dir, body.composition)) {
4281
+ if (!resolveWithinProject2(project.dir, body.composition)) {
4109
4282
  return c.json({ error: "composition path must be within the project directory" }, 400);
4110
4283
  }
4111
4284
  composition = body.composition;
@@ -4122,7 +4295,7 @@ function registerRenderRoutes(api, adapter) {
4122
4295
  const rendersDir = adapter.rendersDir(project);
4123
4296
  if (!existsSync6(rendersDir)) mkdirSync5(rendersDir, { recursive: true });
4124
4297
  const ext = FORMAT_EXT[format] ?? ".mp4";
4125
- const outputPath = join11(rendersDir, `${jobId}${ext}`);
4298
+ const outputPath = join12(rendersDir, `${jobId}${ext}`);
4126
4299
  const jobState = adapter.startRender({
4127
4300
  project,
4128
4301
  outputPath,
@@ -4223,7 +4396,7 @@ function registerRenderRoutes(api, adapter) {
4223
4396
  if (state.id === jobId && state.outputPath) {
4224
4397
  const dir = state.outputPath.replace(/\/[^/]+$/, "");
4225
4398
  for (const ext of [".mp4", ".webm", ".mov", ".meta.json"]) {
4226
- const fp = join11(dir, `${jobId}${ext}`);
4399
+ const fp = join12(dir, `${jobId}${ext}`);
4227
4400
  if (existsSync6(fp)) unlinkSync3(fp);
4228
4401
  }
4229
4402
  break;
@@ -4238,7 +4411,7 @@ function registerRenderRoutes(api, adapter) {
4238
4411
  const filename = c.req.path.split("/renders/file/")[1];
4239
4412
  if (!filename) return c.json({ error: "missing filename" }, 400);
4240
4413
  const rendersDir = adapter.rendersDir(project);
4241
- const fp = resolveWithinProject(rendersDir, filename);
4414
+ const fp = resolveWithinProject2(rendersDir, filename);
4242
4415
  if (!fp) return c.json({ error: "forbidden" }, 403);
4243
4416
  if (!existsSync6(fp)) return c.json({ error: "not found" }, 404);
4244
4417
  const contentType = renderContentType(fp);
@@ -4258,10 +4431,10 @@ function registerRenderRoutes(api, adapter) {
4258
4431
  const rendersDir = adapter.rendersDir(project);
4259
4432
  if (!existsSync6(rendersDir)) return c.json({ renders: [] });
4260
4433
  const files = readdirSync5(rendersDir).filter((f) => f.endsWith(".mp4") || f.endsWith(".webm") || f.endsWith(".mov")).map((f) => {
4261
- const fp = join11(rendersDir, f);
4262
- const stat2 = statSync5(fp);
4434
+ const fp = join12(rendersDir, f);
4435
+ const stat3 = statSync5(fp);
4263
4436
  const rid = f.replace(/\.(mp4|webm|mov)$/, "");
4264
- const metaPath = join11(rendersDir, `${rid}.meta.json`);
4437
+ const metaPath = join12(rendersDir, `${rid}.meta.json`);
4265
4438
  let status = "complete";
4266
4439
  let durationMs;
4267
4440
  if (existsSync6(metaPath)) {
@@ -4275,8 +4448,8 @@ function registerRenderRoutes(api, adapter) {
4275
4448
  return {
4276
4449
  id: rid,
4277
4450
  filename: f,
4278
- size: stat2.size,
4279
- createdAt: stat2.mtimeMs,
4451
+ size: stat3.size,
4452
+ createdAt: stat3.mtimeMs,
4280
4453
  status,
4281
4454
  durationMs
4282
4455
  };
@@ -4287,7 +4460,7 @@ function registerRenderRoutes(api, adapter) {
4287
4460
  id: file.id,
4288
4461
  status: file.status,
4289
4462
  progress: 100,
4290
- outputPath: join11(rendersDir, file.filename),
4463
+ outputPath: join12(rendersDir, file.filename),
4291
4464
  createdAt: file.createdAt
4292
4465
  });
4293
4466
  }
@@ -4296,80 +4469,11 @@ function registerRenderRoutes(api, adapter) {
4296
4469
  });
4297
4470
  }
4298
4471
 
4299
- // src/routes/thumbnail.ts
4300
- import {
4301
- closeSync as closeSync3,
4302
- existsSync as existsSync7,
4303
- fstatSync as fstatSync3,
4304
- openSync as openSync3,
4305
- mkdirSync as mkdirSync6,
4306
- readFileSync as readFileSync12,
4307
- readdirSync as readdirSync6,
4308
- renameSync as renameSync3,
4309
- rmSync as rmSync4,
4310
- statSync as statSync6,
4311
- unlinkSync as unlinkSync4,
4312
- writeFileSync as writeFileSync6
4313
- } from "fs";
4314
- import { join as join12 } from "path";
4315
- import { createHash as createHash5, randomUUID as randomUUID4 } from "crypto";
4316
-
4317
- // src/helpers/compositionInputs.ts
4318
- import { readFileSync as readFileSync11 } from "fs";
4319
- var ROOT_COMPOSITION = "index.html";
4320
- var COMPOSITION_SRC = /\bdata-composition-src\s*=\s*(["'])(.*?)\1/g;
4321
- function projectReader(projectDir) {
4322
- const sources = /* @__PURE__ */ new Map();
4323
- return (compPath) => {
4324
- if (!sources.has(compPath)) {
4325
- const file = resolveWithinProject(projectDir, compPath);
4326
- let source = null;
4327
- try {
4328
- source = file ? readFileSync11(file, "utf-8") : null;
4329
- } catch {
4330
- }
4331
- sources.set(compPath, source);
4332
- }
4333
- return sources.get(compPath) ?? null;
4334
- };
4335
- }
4336
- function normalizeSource(src) {
4337
- if (/^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith("//")) return null;
4338
- const path = src.split(/[?#]/)[0]?.replace(/^\.?\//, "") ?? "";
4339
- return path || null;
4340
- }
4341
- function closureOf(read, compPath) {
4342
- const closure = /* @__PURE__ */ new Set();
4343
- const visit = (path) => {
4344
- if (closure.has(path)) return;
4345
- closure.add(path);
4346
- for (const match of (read(path) ?? "").matchAll(COMPOSITION_SRC)) {
4347
- const mounted = normalizeSource(match[2] ?? "");
4348
- if (mounted) visit(mounted);
4349
- }
4350
- };
4351
- visit(compPath);
4352
- return closure;
4353
- }
4354
- var inputSignatures = /* @__PURE__ */ new Map();
4355
- function compositionInputSignature(projectDir, compPath, projectSignature) {
4356
- const key = `${projectDir}\0${compPath}`;
4357
- const known = inputSignatures.get(key);
4358
- if (known?.projectSignature === projectSignature) return known.inputSignature;
4359
- const read = projectReader(projectDir);
4360
- const inputs = closureOf(read, normalizeSource(compPath) ?? compPath).add(ROOT_COMPOSITION);
4361
- const siblings = [...closureOf(read, ROOT_COMPOSITION)].filter((path) => !inputs.has(path));
4362
- const inputSignature = createProjectSignature(projectDir, new Set(siblings));
4363
- inputSignatures.set(key, { projectSignature, inputSignature });
4364
- return inputSignature;
4365
- }
4366
- function compositionsAffectedBy(projectDir, changedPath) {
4367
- const changed = changedPath.replace(/\\/g, "/");
4368
- const read = projectReader(projectDir);
4369
- const mounted = closureOf(read, ROOT_COMPOSITION);
4370
- if (changed === ROOT_COMPOSITION || !mounted.has(changed)) return null;
4371
- return [...mounted].filter((path) => closureOf(read, path).has(changed));
4372
- }
4472
+ // src/routes/imageThumbnail.ts
4473
+ import { HTTPException } from "hono/http-exception";
4474
+ import { open, stat } from "fs/promises";
4475
+ import { createHash as createHash5 } from "crypto";
4476
+ import sharp from "sharp";
4373
4477
 
4374
4478
  // src/routes/thumbnailGenerationCoordinator.ts
4375
4479
  var ThumbnailGenerationCoordinator = class {
@@ -4388,10 +4492,10 @@ var ThumbnailGenerationCoordinator = class {
4388
4492
  if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
4389
4493
  let entry = this.entries.get(key);
4390
4494
  if (!entry) {
4391
- let resolve8;
4495
+ let resolve9;
4392
4496
  let reject;
4393
4497
  const promise = new Promise((resolvePromise, rejectPromise) => {
4394
- resolve8 = resolvePromise;
4498
+ resolve9 = resolvePromise;
4395
4499
  reject = rejectPromise;
4396
4500
  });
4397
4501
  entry = {
@@ -4401,7 +4505,7 @@ var ThumbnailGenerationCoordinator = class {
4401
4505
  state: "queued",
4402
4506
  work,
4403
4507
  promise,
4404
- resolve: resolve8,
4508
+ resolve: resolve9,
4405
4509
  reject
4406
4510
  };
4407
4511
  this.entries.set(key, entry);
@@ -4415,7 +4519,7 @@ var ThumbnailGenerationCoordinator = class {
4415
4519
  return /* @__PURE__ */ new Set([...this.entries.keys(), ...[...this.activeEntries].map((entry) => entry.key)]);
4416
4520
  }
4417
4521
  lease(entry, signal) {
4418
- return new Promise((resolve8, reject) => {
4522
+ return new Promise((resolve9, reject) => {
4419
4523
  let released = false;
4420
4524
  const release = () => {
4421
4525
  if (released) return;
@@ -4439,7 +4543,7 @@ var ThumbnailGenerationCoordinator = class {
4439
4543
  entry.promise.then(
4440
4544
  (value) => {
4441
4545
  release();
4442
- resolve8(value);
4546
+ resolve9(value);
4443
4547
  },
4444
4548
  (reason) => {
4445
4549
  release();
@@ -4474,6 +4578,171 @@ var ThumbnailGenerationCoordinator = class {
4474
4578
  };
4475
4579
  var thumbnailGenerationCoordinator = new ThumbnailGenerationCoordinator(1);
4476
4580
 
4581
+ // src/routes/imageThumbnail.ts
4582
+ var MAX_SOURCE_BYTES = 64 * 1024 * 1024;
4583
+ var MAX_CACHE_BYTES = 8 * 1024 * 1024;
4584
+ async function generateThumbnail(path, signal) {
4585
+ const file = await open(path, "r");
4586
+ try {
4587
+ const info = await file.stat();
4588
+ if (!info.isFile() || info.size > MAX_SOURCE_BYTES) throw new Error("Unsupported image");
4589
+ const source = Buffer.alloc(info.size);
4590
+ let offset = 0;
4591
+ while (offset < source.length) {
4592
+ signal.throwIfAborted();
4593
+ const { bytesRead } = await file.read(source, offset, source.length - offset, offset);
4594
+ if (!bytesRead) break;
4595
+ offset += bytesRead;
4596
+ }
4597
+ if (source[0] !== 255 || source[1] !== 216) throw new Error("Unsupported image");
4598
+ const image = sharp(source.subarray(0, offset), { limitInputPixels: 4e7 });
4599
+ if ((await image.metadata()).format !== "jpeg") throw new Error("Unsupported image");
4600
+ signal.throwIfAborted();
4601
+ const result = await image.rotate().resize(240, 135, { fit: "inside", withoutEnlargement: true }).jpeg({ quality: 80 }).toBuffer();
4602
+ signal.throwIfAborted();
4603
+ return result;
4604
+ } finally {
4605
+ await file.close();
4606
+ }
4607
+ }
4608
+ async function resolveSource(adapter, id, url) {
4609
+ const project = await adapter.resolveProject(id);
4610
+ if (!project) throw new HTTPException(404);
4611
+ let relative7;
4612
+ try {
4613
+ relative7 = requestSubPath(url, "projects/:id/image-thumbnail");
4614
+ } catch {
4615
+ throw new HTTPException(400);
4616
+ }
4617
+ if (!/\.jpe?g$/i.test(relative7)) throw new HTTPException(415);
4618
+ const path = pinWithinProject(project.dir, relative7);
4619
+ if (!path) throw new HTTPException(404);
4620
+ const info = await stat(path).catch(() => {
4621
+ throw new HTTPException(404);
4622
+ });
4623
+ if (!info.isFile()) throw new HTTPException(404);
4624
+ if (info.size > MAX_SOURCE_BYTES) throw new HTTPException(413);
4625
+ return { path, info };
4626
+ }
4627
+ function registerImageThumbnailRoutes(api, adapter) {
4628
+ const coordinator = new ThumbnailGenerationCoordinator(2);
4629
+ const cache = /* @__PURE__ */ new Map();
4630
+ let cacheBytes = 0;
4631
+ const remember = (key, image) => {
4632
+ cacheBytes -= cache.get(key)?.byteLength ?? 0;
4633
+ cache.delete(key);
4634
+ cache.set(key, image);
4635
+ cacheBytes += image.byteLength;
4636
+ while (cacheBytes > MAX_CACHE_BYTES || cache.size > 128) {
4637
+ const oldest = cache.keys().next().value;
4638
+ if (oldest === void 0) break;
4639
+ cacheBytes -= cache.get(oldest)?.byteLength ?? 0;
4640
+ cache.delete(oldest);
4641
+ }
4642
+ };
4643
+ api.get("/projects/:id/image-thumbnail/*", async (c) => {
4644
+ try {
4645
+ const { path, info } = await resolveSource(adapter, c.req.param("id"), c.req.url);
4646
+ const key = createHash5("sha256").update(JSON.stringify([path, info.dev, info.ino, info.size, info.mtimeMs, info.ctimeMs])).digest("hex");
4647
+ const etag = `"${key}"`;
4648
+ c.header("ETag", etag);
4649
+ c.header("Cache-Control", "private, no-cache");
4650
+ c.header("X-Content-Type-Options", "nosniff");
4651
+ if (c.req.header("If-None-Match") === etag) return c.body(null, 304);
4652
+ let result = cache.get(key);
4653
+ if (result) remember(key, result);
4654
+ else {
4655
+ if (coordinator.protectedKeys().size >= 32) return c.text("thumbnail queue full", 429);
4656
+ result = await coordinator.acquire(key, c.req.raw.signal, async (signal) => {
4657
+ const image = await generateThumbnail(path, signal);
4658
+ remember(key, image);
4659
+ return image;
4660
+ }) ?? void 0;
4661
+ }
4662
+ if (!result) return c.text("image unavailable", 422);
4663
+ c.header("Content-Type", "image/jpeg");
4664
+ return c.body(new Uint8Array(result));
4665
+ } catch (error) {
4666
+ if (error instanceof HTTPException) return error.getResponse();
4667
+ return c.text("image unavailable", 422);
4668
+ }
4669
+ });
4670
+ }
4671
+
4672
+ // src/routes/thumbnail.ts
4673
+ import {
4674
+ closeSync as closeSync3,
4675
+ fstatSync as fstatSync3,
4676
+ openSync as openSync3,
4677
+ mkdirSync as mkdirSync6,
4678
+ readFileSync as readFileSync12,
4679
+ readdirSync as readdirSync6,
4680
+ renameSync as renameSync3,
4681
+ rmSync as rmSync4,
4682
+ statSync as statSync6,
4683
+ unlinkSync as unlinkSync4,
4684
+ writeFileSync as writeFileSync6
4685
+ } from "fs";
4686
+ import { join as join13 } from "path";
4687
+ import { createHash as createHash6, randomUUID as randomUUID4 } from "crypto";
4688
+
4689
+ // src/helpers/compositionInputs.ts
4690
+ import { readFileSync as readFileSync11 } from "fs";
4691
+ var ROOT_COMPOSITION = "index.html";
4692
+ var COMPOSITION_SRC = /\bdata-composition-src\s*=\s*(["'])(.*?)\1/g;
4693
+ function projectReader(projectDir) {
4694
+ const sources = /* @__PURE__ */ new Map();
4695
+ return (compPath) => {
4696
+ if (!sources.has(compPath)) {
4697
+ const file = resolveWithinProject2(projectDir, compPath);
4698
+ let source = null;
4699
+ try {
4700
+ source = file ? readFileSync11(file, "utf-8") : null;
4701
+ } catch {
4702
+ }
4703
+ sources.set(compPath, source);
4704
+ }
4705
+ return sources.get(compPath) ?? null;
4706
+ };
4707
+ }
4708
+ function normalizeSource(src) {
4709
+ if (/^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith("//")) return null;
4710
+ const path = src.split(/[?#]/)[0]?.replace(/^\.?\//, "") ?? "";
4711
+ return path || null;
4712
+ }
4713
+ function closureOf(read, compPath) {
4714
+ const closure = /* @__PURE__ */ new Set();
4715
+ const visit = (path) => {
4716
+ if (closure.has(path)) return;
4717
+ closure.add(path);
4718
+ for (const match of (read(path) ?? "").matchAll(COMPOSITION_SRC)) {
4719
+ const mounted = normalizeSource(match[2] ?? "");
4720
+ if (mounted) visit(mounted);
4721
+ }
4722
+ };
4723
+ visit(compPath);
4724
+ return closure;
4725
+ }
4726
+ var inputSignatures = /* @__PURE__ */ new Map();
4727
+ function compositionInputSignature(projectDir, compPath, projectSignature) {
4728
+ const key = `${projectDir}\0${compPath}`;
4729
+ const known = inputSignatures.get(key);
4730
+ if (known?.projectSignature === projectSignature) return known.inputSignature;
4731
+ const read = projectReader(projectDir);
4732
+ const inputs = closureOf(read, normalizeSource(compPath) ?? compPath).add(ROOT_COMPOSITION);
4733
+ const siblings = [...closureOf(read, ROOT_COMPOSITION)].filter((path) => !inputs.has(path));
4734
+ const inputSignature = createProjectSignature(projectDir, new Set(siblings));
4735
+ inputSignatures.set(key, { projectSignature, inputSignature });
4736
+ return inputSignature;
4737
+ }
4738
+ function compositionsAffectedBy(projectDir, changedPath) {
4739
+ const changed = changedPath.replace(/\\/g, "/");
4740
+ const read = projectReader(projectDir);
4741
+ const mounted = closureOf(read, ROOT_COMPOSITION);
4742
+ if (changed === ROOT_COMPOSITION || !mounted.has(changed)) return null;
4743
+ return [...mounted].filter((path) => closureOf(read, path).has(changed));
4744
+ }
4745
+
4477
4746
  // src/routes/thumbnail.ts
4478
4747
  var THUMBNAIL_CACHE_VERSION = "v4";
4479
4748
  var THUMBNAIL_MAX_OUTPUT_WIDTH = 240;
@@ -4482,10 +4751,16 @@ var THUMBNAIL_CACHE_MAX_BYTES = 512 * 1024 * 1024;
4482
4751
  var THUMBNAIL_CACHE_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1e3;
4483
4752
  var prunedCacheDirs = /* @__PURE__ */ new Set();
4484
4753
  function pruneThumbnailCache(cacheDir, protectedPaths, now = Date.now()) {
4485
- if (!existsSync7(cacheDir)) return;
4486
- const files = readdirSync6(cacheDir, { withFileTypes: true }).flatMap((entry) => {
4754
+ let entries;
4755
+ try {
4756
+ entries = readdirSync6(cacheDir, { withFileTypes: true });
4757
+ } catch (err) {
4758
+ if (err.code === "ENOENT") return;
4759
+ throw err;
4760
+ }
4761
+ const files = entries.flatMap((entry) => {
4487
4762
  if (!entry.isFile()) return [];
4488
- const path = join12(cacheDir, entry.name);
4763
+ const path = join13(cacheDir, entry.name);
4489
4764
  try {
4490
4765
  const stats = statSync6(path);
4491
4766
  return [{ path, bytes: stats.size, mtimeMs: stats.mtimeMs }];
@@ -4521,22 +4796,30 @@ function writeThumbnailAtomically(path, buffer) {
4521
4796
  rmSync4(temporaryPath, { force: true });
4522
4797
  }
4523
4798
  }
4524
- function readCompositionSource(file) {
4799
+ function readFileOnce(file) {
4525
4800
  let fd;
4526
4801
  try {
4527
4802
  fd = openSync3(file, "r");
4528
4803
  } catch (err) {
4529
- if (err.code === "ENOENT") return "missing";
4804
+ const code = err.code;
4805
+ if (code === "ENOENT") return "missing";
4806
+ if (code === "ENOTDIR") return "not-a-file";
4530
4807
  throw err;
4531
4808
  }
4532
4809
  try {
4533
- const stat2 = fstatSync3(fd);
4534
- if (!stat2.isFile()) return "not-a-file";
4535
- return { html: readFileSync12(fd, "utf-8"), mtimeMs: stat2.mtimeMs };
4810
+ const stat3 = fstatSync3(fd);
4811
+ if (!stat3.isFile()) return "not-a-file";
4812
+ return { data: readFileSync12(fd), mtimeMs: stat3.mtimeMs };
4536
4813
  } finally {
4537
4814
  closeSync3(fd);
4538
4815
  }
4539
4816
  }
4817
+ function manifestKey(file) {
4818
+ const manifest = readFileOnce(file);
4819
+ if (typeof manifest === "string") return { key: "", mtimeMs: -Infinity };
4820
+ const hash = createHash6("sha1").update(manifest.data).digest("hex").slice(0, 16);
4821
+ return { key: `_${hash}`, mtimeMs: Math.round(manifest.mtimeMs) };
4822
+ }
4540
4823
  function registerThumbnailRoutes(api, adapter) {
4541
4824
  api.get("/projects/:id/thumbnail/*", async (c) => {
4542
4825
  if (!adapter.generateThumbnail) {
@@ -4547,8 +4830,8 @@ function registerThumbnailRoutes(api, adapter) {
4547
4830
  const { project, signature: projectSignature } = resolved;
4548
4831
  let compPath = requestSubPath(c.req.url, "projects/:id/thumbnail");
4549
4832
  if (compPath && !compPath.includes(".")) compPath += ".html";
4550
- const htmlFile = resolveWithinProject(project.dir, compPath);
4551
- const source = htmlFile ? readCompositionSource(htmlFile) : "not-a-file";
4833
+ const htmlFile = resolveWithinProject2(project.dir, compPath);
4834
+ const source = htmlFile ? readFileOnce(htmlFile) : "not-a-file";
4552
4835
  if (source === "not-a-file") return c.json({ error: "not found" }, 404);
4553
4836
  const inputSignature = compositionInputSignature(project.dir, compPath, projectSignature);
4554
4837
  const url = new URL(c.req.url, `http://${c.req.header("host") || "localhost"}`);
@@ -4570,8 +4853,8 @@ function registerThumbnailRoutes(api, adapter) {
4570
4853
  let sourceMtime = 0;
4571
4854
  let sourceKey = "";
4572
4855
  if (source !== "missing") {
4573
- const { html } = source;
4574
- sourceKey = `_${createHash5("sha1").update(html).digest("hex").slice(0, 16)}`;
4856
+ const html = source.data.toString("utf-8");
4857
+ sourceKey = `_${createHash6("sha1").update(html).digest("hex").slice(0, 16)}`;
4575
4858
  sourceMtime = Math.round(source.mtimeMs);
4576
4859
  if (!vpWidth) {
4577
4860
  const wMatch = html.match(/data-width=["'](\d+)["']/);
@@ -4580,31 +4863,20 @@ function registerThumbnailRoutes(api, adapter) {
4580
4863
  if (hMatch?.[1]) compH = parseInt(hMatch[1]);
4581
4864
  }
4582
4865
  }
4583
- const manualEditsFile = join12(project.dir, STUDIO_MANUAL_EDITS_PATH);
4584
- let manualEditsKey = "";
4585
- if (existsSync7(manualEditsFile)) {
4586
- const manualEditsContent = readFileSync12(manualEditsFile, "utf-8");
4587
- manualEditsKey = `_${createHash5("sha1").update(manualEditsContent).digest("hex").slice(0, 16)}`;
4588
- sourceMtime = Math.max(sourceMtime, Math.round(statSync6(manualEditsFile).mtimeMs));
4589
- }
4590
- const motionFile = join12(project.dir, STUDIO_MOTION_PATH);
4591
- let motionKey = "";
4592
- if (existsSync7(motionFile)) {
4593
- const motionContent = readFileSync12(motionFile, "utf-8");
4594
- motionKey = `_${createHash5("sha1").update(motionContent).digest("hex").slice(0, 16)}`;
4595
- sourceMtime = Math.max(sourceMtime, Math.round(statSync6(motionFile).mtimeMs));
4596
- }
4866
+ const manualEdits = manifestKey(join13(project.dir, STUDIO_MANUAL_EDITS_PATH));
4867
+ const motion = manifestKey(join13(project.dir, STUDIO_MOTION_PATH));
4868
+ sourceMtime = Math.max(sourceMtime, manualEdits.mtimeMs, motion.mtimeMs);
4597
4869
  const projectUrl = `http://${c.req.header("host")}/api/projects/${encodeURIComponent(project.id)}`;
4598
4870
  const previewUrl = compPath === "index.html" ? `${projectUrl}/preview` : `${projectUrl}/preview/comp/${compPath.split("/").map(encodeURIComponent).join("/")}`;
4599
- const cacheDir = join12(project.dir, ".thumbnails");
4871
+ const cacheDir = join13(project.dir, ".thumbnails");
4600
4872
  const selectorKey = selector ? `_${selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}_${selectorIndex ?? 0}` : "";
4601
4873
  const urlVersionKey = urlVersion ? `_${urlVersion.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 32)}` : "";
4602
- const inputSignatureKey = `_${createHash5("sha1").update(inputSignature).digest("hex").slice(0, 16)}`;
4874
+ const inputSignatureKey = `_${createHash6("sha1").update(inputSignature).digest("hex").slice(0, 16)}`;
4603
4875
  const outputScale = outputMode === "source" ? 1 : Math.min(1, THUMBNAIL_MAX_OUTPUT_WIDTH / compW, THUMBNAIL_MAX_OUTPUT_HEIGHT / compH);
4604
4876
  const outputWidth = Math.max(1, Math.round(compW * outputScale));
4605
4877
  const outputHeight = Math.max(1, Math.round(compH * outputScale));
4606
- 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"}`;
4607
- const cachePath = join12(cacheDir, cacheKey);
4878
+ const cacheKey = `${THUMBNAIL_CACHE_VERSION}${urlVersionKey}${inputSignatureKey}${manualEdits.key}${motion.key}${sourceKey}_${format}_${outputMode}_${compPath.replace(/\//g, "_")}_${compW}x${compH}_${outputWidth}x${outputHeight}_${sourceMtime}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`;
4879
+ const cachePath = join13(cacheDir, cacheKey);
4608
4880
  if (!prunedCacheDirs.has(cacheDir)) {
4609
4881
  prunedCacheDirs.add(cacheDir);
4610
4882
  pruneThumbnailCache(
@@ -4612,8 +4884,9 @@ function registerThumbnailRoutes(api, adapter) {
4612
4884
  /* @__PURE__ */ new Set([...thumbnailGenerationCoordinator.protectedKeys(), cachePath])
4613
4885
  );
4614
4886
  }
4615
- if (existsSync7(cachePath)) {
4616
- return new Response(new Uint8Array(readFileSync12(cachePath)), {
4887
+ const cached = readFileOnce(cachePath);
4888
+ if (typeof cached === "object") {
4889
+ return new Response(new Uint8Array(cached.data), {
4617
4890
  headers: { "Content-Type": contentType, "Cache-Control": "no-cache" }
4618
4891
  });
4619
4892
  }
@@ -4643,7 +4916,7 @@ function registerThumbnailRoutes(api, adapter) {
4643
4916
  if (afterGeneration?.project.dir !== project.dir || !inputsUnchanged(afterGeneration.signature) || !inputsUnchanged(createProjectSignature(project.dir))) {
4644
4917
  return generated;
4645
4918
  }
4646
- if (!existsSync7(cacheDir)) mkdirSync6(cacheDir, { recursive: true });
4919
+ mkdirSync6(cacheDir, { recursive: true });
4647
4920
  writeThumbnailAtomically(cachePath, generated);
4648
4921
  return generated;
4649
4922
  }
@@ -4669,24 +4942,24 @@ function registerThumbnailRoutes(api, adapter) {
4669
4942
  }
4670
4943
 
4671
4944
  // src/routes/waveform.ts
4672
- import { existsSync as existsSync8, readFileSync as readFileSync13, statSync as statSync7 } from "fs";
4673
- import { join as join13, resolve as resolve5 } from "path";
4945
+ import { existsSync as existsSync7, readFileSync as readFileSync13, statSync as statSync7 } from "fs";
4946
+ import { join as join14, resolve as resolve6 } from "path";
4674
4947
  import { isWithinProjectRoot as isWithinProjectRoot2 } from "@hyperframes/parsers/asset-resolution";
4675
4948
  function registerWaveformRoutes(api, adapter) {
4676
4949
  api.get("/projects/:id/waveform/*", async (c) => {
4677
4950
  const project = await adapter.resolveProject(c.req.param("id"));
4678
4951
  if (!project) return c.json({ error: "not found" }, 404);
4679
4952
  const assetPath = requestSubPath(c.req.url, "projects/:id/waveform");
4680
- const audioPath = resolve5(project.dir, assetPath);
4953
+ const audioPath = resolve6(project.dir, assetPath);
4681
4954
  if (!isWithinProjectRoot2(project.dir, audioPath)) {
4682
4955
  return c.json({ error: "file not found" }, 404);
4683
4956
  }
4684
4957
  const stats = statSync7(audioPath, { throwIfNoEntry: false });
4685
4958
  if (!stats) return c.json({ error: "file not found" }, 404);
4686
- const cacheDir = join13(project.dir, ".waveform-cache");
4687
- const cachePath = join13(cacheDir, buildWaveformCacheKey(assetPath, stats));
4959
+ const cacheDir = join14(project.dir, ".waveform-cache");
4960
+ const cachePath = join14(cacheDir, buildWaveformCacheKey(assetPath, stats));
4688
4961
  try {
4689
- if (isWaveformCacheDirectory(cacheDir) && existsSync8(cachePath)) {
4962
+ if (isWaveformCacheDirectory(cacheDir) && existsSync7(cachePath)) {
4690
4963
  const peaks2 = JSON.parse(readFileSync13(cachePath, "utf-8"));
4691
4964
  return c.json({ peaks: peaks2 });
4692
4965
  }
@@ -4819,12 +5092,12 @@ function registerFontRoutes(api) {
4819
5092
  return c.json({ error: "font file not accessible" }, 404);
4820
5093
  }
4821
5094
  try {
4822
- const stat2 = fstatSync4(fd);
4823
- if (stat2.size > SYSTEM_FONT_SIZE_LIMIT) {
5095
+ const stat3 = fstatSync4(fd);
5096
+ if (stat3.size > SYSTEM_FONT_SIZE_LIMIT) {
4824
5097
  return c.json({ error: "font file too large" }, 413);
4825
5098
  }
4826
- const buffer = Buffer.alloc(stat2.size);
4827
- readSync(fd, buffer, 0, stat2.size, 0);
5099
+ const buffer = Buffer.alloc(stat3.size);
5100
+ readSync(fd, buffer, 0, stat3.size, 0);
4828
5101
  const mimeType = located.format === "otf" ? "font/otf" : located.format === "woff2" ? "font/woff2" : located.format === "woff" ? "font/woff" : located.format === "ttc" ? "font/collection" : "font/ttf";
4829
5102
  const fileName = `${family.replace(/[^a-zA-Z0-9 -]/g, "")}.${located.format}`;
4830
5103
  return new Response(buffer, {
@@ -4852,7 +5125,7 @@ function registerRegistryRoutes(api, adapter) {
4852
5125
  });
4853
5126
  api.post("/projects/:id/registry/install", async (c) => {
4854
5127
  if (!adapter.installRegistryBlock) {
4855
- return c.json({ error: "Registry install not available" }, 501);
5128
+ return c.json({ error: "Installing catalog items needs hyperframes preview" }, 501);
4856
5129
  }
4857
5130
  const project = await adapter.resolveProject(c.req.param("id"));
4858
5131
  if (!project) return c.json({ error: "Project not found" }, 404);
@@ -4970,8 +5243,8 @@ function registerSelectionRoutes(api, adapter) {
4970
5243
 
4971
5244
  // src/routes/media.ts
4972
5245
  import { streamSSE as streamSSE2 } from "hono/streaming";
4973
- import { existsSync as existsSync9, mkdirSync as mkdirSync7 } from "fs";
4974
- import { basename as basename2, dirname as dirname5, extname as extname2, join as join14 } from "path";
5246
+ import { existsSync as existsSync8, mkdirSync as mkdirSync7 } from "fs";
5247
+ import { basename as basename3, dirname as dirname7, extname as extname2, join as join15 } from "path";
4975
5248
  var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([
4976
5249
  ".mp4",
4977
5250
  ".mov",
@@ -5001,14 +5274,14 @@ function containsNullByte(path) {
5001
5274
  return path.includes("\0");
5002
5275
  }
5003
5276
  function slugFileBase(path) {
5004
- const name = basename2(path, extname2(path)).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
5277
+ const name = basename3(path, extname2(path)).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
5005
5278
  return name || "media";
5006
5279
  }
5007
5280
  function uniqueAssetPath(projectDir, assetPath) {
5008
5281
  const ext = extname2(assetPath);
5009
5282
  const withoutExt = assetPath.slice(0, -ext.length);
5010
5283
  let candidate = assetPath;
5011
- for (let index = 2; existsSync9(join14(projectDir, candidate)); index++) {
5284
+ for (let index = 2; existsSync8(join15(projectDir, candidate)); index++) {
5012
5285
  candidate = `${withoutExt}-${index}${ext}`;
5013
5286
  }
5014
5287
  return candidate;
@@ -5057,9 +5330,9 @@ function registerMediaRoutes(api, adapter, options = {}) {
5057
5330
  if (/^(?:https?:|data:|blob:)/i.test(assetPath)) {
5058
5331
  return c.json({ error: "media metadata requires a project-local asset" }, 400);
5059
5332
  }
5060
- const filePath = resolveWithinProject(project.dir, assetPath);
5333
+ const filePath = resolveWithinProject2(project.dir, assetPath);
5061
5334
  if (!filePath) return c.json({ error: "forbidden" }, 403);
5062
- if (!existsSync9(filePath)) return c.json({ error: "media not found" }, 404);
5335
+ if (!existsSync8(filePath)) return c.json({ error: "media not found" }, 404);
5063
5336
  return c.json({ path: assetPath, metadata: await readMediaMetadata(filePath) });
5064
5337
  });
5065
5338
  api.post(
@@ -5079,9 +5352,9 @@ function registerMediaRoutes(api, adapter, options = {}) {
5079
5352
  if (/^(?:https?:|data:|blob:)/i.test(inputAssetPath)) {
5080
5353
  return c.json({ error: "background removal requires a project-local media asset" }, 400);
5081
5354
  }
5082
- const inputPath = resolveWithinProject(project.dir, inputAssetPath);
5355
+ const inputPath = resolveWithinProject2(project.dir, inputAssetPath);
5083
5356
  if (!inputPath) return c.json({ error: "forbidden" }, 403);
5084
- if (!existsSync9(inputPath)) return c.json({ error: "input media not found" }, 404);
5357
+ if (!existsSync8(inputPath)) return c.json({ error: "input media not found" }, 404);
5085
5358
  const inputIsVideo = isVideoPath(inputAssetPath);
5086
5359
  const inputIsImage = isImagePath(inputAssetPath);
5087
5360
  if (!inputIsVideo && !inputIsImage) {
@@ -5091,11 +5364,11 @@ function registerMediaRoutes(api, adapter, options = {}) {
5091
5364
  if (requestedOutput && containsNullByte(requestedOutput)) {
5092
5365
  return c.json({ error: "forbidden" }, 403);
5093
5366
  }
5094
- if (requestedOutput && !resolveWithinProject(project.dir, requestedOutput)) {
5367
+ if (requestedOutput && !resolveWithinProject2(project.dir, requestedOutput)) {
5095
5368
  return c.json({ error: "forbidden" }, 403);
5096
5369
  }
5097
5370
  const outputAssetPath = requestedOutput ? uniqueAssetPath(project.dir, requestedOutput) : defaultOutputPath(project.dir, inputAssetPath);
5098
- const outputPath = resolveWithinProject(project.dir, outputAssetPath);
5371
+ const outputPath = resolveWithinProject2(project.dir, outputAssetPath);
5099
5372
  if (!outputPath) return c.json({ error: "forbidden" }, 403);
5100
5373
  if (inputIsVideo && !VIDEO_OUTPUT_EXTENSIONS.has(extname2(outputAssetPath).toLowerCase())) {
5101
5374
  return c.json({ error: "video background removal output must be .webm or .mov" }, 400);
@@ -5110,13 +5383,13 @@ function registerMediaRoutes(api, adapter, options = {}) {
5110
5383
  return c.json({ error: "background plates are only supported for video inputs" }, 400);
5111
5384
  }
5112
5385
  backgroundOutputAssetPath = defaultPlatePath(project.dir, inputAssetPath);
5113
- backgroundOutputPath = resolveWithinProject(project.dir, backgroundOutputAssetPath) ?? void 0;
5386
+ backgroundOutputPath = resolveWithinProject2(project.dir, backgroundOutputAssetPath) ?? void 0;
5114
5387
  if (!backgroundOutputPath) {
5115
5388
  return c.json({ error: "forbidden" }, 403);
5116
5389
  }
5117
5390
  }
5118
- mkdirSync7(dirname5(outputPath), { recursive: true });
5119
- if (backgroundOutputPath) mkdirSync7(dirname5(backgroundOutputPath), { recursive: true });
5391
+ mkdirSync7(dirname7(outputPath), { recursive: true });
5392
+ if (backgroundOutputPath) mkdirSync7(dirname7(backgroundOutputPath), { recursive: true });
5120
5393
  const jobId = makeJobId(project.id, mediaJobs);
5121
5394
  const state = adapter.startBackgroundRemoval({
5122
5395
  project,
@@ -5173,12 +5446,12 @@ function registerMediaRoutes(api, adapter, options = {}) {
5173
5446
  }
5174
5447
 
5175
5448
  // src/routes/globalAssets.ts
5176
- import { existsSync as existsSync10, readFileSync as readFileSync14 } from "fs";
5449
+ import { existsSync as existsSync9, readFileSync as readFileSync14 } from "fs";
5177
5450
  import { homedir } from "os";
5178
- import { join as join15 } from "path";
5451
+ import { join as join16 } from "path";
5179
5452
  function readGlobalAssets(home = homedir()) {
5180
- const manifestPath = join15(home, ".media", "manifest.jsonl");
5181
- if (!existsSync10(manifestPath)) return [];
5453
+ const manifestPath = join16(home, ".media", "manifest.jsonl");
5454
+ if (!existsSync9(manifestPath)) return [];
5182
5455
  const out = [];
5183
5456
  for (const line of readFileSync14(manifestPath, "utf8").split("\n")) {
5184
5457
  if (!line.trim()) continue;
@@ -5199,28 +5472,38 @@ function registerGlobalAssetRoutes(api) {
5199
5472
 
5200
5473
  // src/history/projectHistory.ts
5201
5474
  import { randomUUID as randomUUID8 } from "crypto";
5202
- import { rm as rm2 } from "fs/promises";
5203
- import { existsSync as existsSync12, mkdirSync as mkdirSync11, readFileSync as readFileSync18 } from "fs";
5475
+ import { readdir as readdir2, rm as rm2, rmdir, writeFile } from "fs/promises";
5476
+ import {
5477
+ existsSync as existsSync11,
5478
+ lstatSync as lstatSync4,
5479
+ statSync as statSync9,
5480
+ mkdirSync as mkdirSync11,
5481
+ readdirSync as readdirSync7,
5482
+ readFileSync as readFileSync18,
5483
+ realpathSync as realpathSync3,
5484
+ rmSync as rmSync6
5485
+ } from "fs";
5204
5486
  import { homedir as homedir2 } from "os";
5205
- import { join as join19, relative as relative4, resolve as resolve7, sep as sep3 } from "path";
5487
+ import { dirname as dirname10, isAbsolute as isAbsolute3, join as join20, relative as relative6, resolve as resolve8, sep as sep4 } from "path";
5206
5488
 
5207
5489
  // src/history/blobStore.ts
5208
- import { createHash as createHash6, randomUUID as randomUUID5 } from "crypto";
5209
- import { constants as constants3, createReadStream as createReadStream2 } from "fs";
5210
- import { copyFile, mkdir, readdir, readFile as readFile2, rename, rm, stat } from "fs/promises";
5211
- import { dirname as dirname6, join as join16 } from "path";
5490
+ import { createHash as createHash7, randomUUID as randomUUID5 } from "crypto";
5491
+ import { constants as constants3, createReadStream as createReadStream2, renameSync as renameSync4 } from "fs";
5492
+ import { copyFile, mkdir, readdir, readFile as readFile2, rename, rm, stat as stat2 } from "fs/promises";
5493
+ import { dirname as dirname8, join as join17 } from "path";
5212
5494
  var BLOB_HASH = /^[0-9a-f]{64}$/;
5213
5495
  async function hashFile(path) {
5214
- const hash = createHash6("sha256");
5496
+ const hash = createHash7("sha256");
5215
5497
  for await (const chunk of createReadStream2(path)) hash.update(chunk);
5216
5498
  return hash.digest("hex");
5217
5499
  }
5218
- async function cloneOrCopy(from, to) {
5219
- await mkdir(dirname6(to), { recursive: true });
5500
+ async function cloneOrCopy(from, to, beforeReplace) {
5501
+ await mkdir(dirname8(to), { recursive: true });
5220
5502
  const temp = `${to}.${randomUUID5()}.tmp`;
5221
5503
  try {
5222
5504
  await copyFile(from, temp, constants3.COPYFILE_FICLONE);
5223
- await rename(temp, to);
5505
+ beforeReplace?.();
5506
+ renameSync4(temp, to);
5224
5507
  } catch (error) {
5225
5508
  await rm(temp, { force: true });
5226
5509
  throw error;
@@ -5230,16 +5513,16 @@ async function openBlobStore(dir) {
5230
5513
  const sizes = /* @__PURE__ */ new Map();
5231
5514
  await mkdir(dir, { recursive: true });
5232
5515
  for (const shard of await readdir(dir))
5233
- for (const hash of await readdir(join16(dir, shard)).catch(() => []))
5234
- sizes.set(hash, (await stat(join16(dir, shard, hash))).size);
5516
+ for (const hash of await readdir(join17(dir, shard)).catch(() => []))
5517
+ sizes.set(hash, (await stat2(join17(dir, shard, hash))).size);
5235
5518
  const pathOf = (hash) => {
5236
5519
  if (!BLOB_HASH.test(hash)) throw new Error("That is not a history blob.");
5237
- return join16(dir, hash.slice(0, 2), hash);
5520
+ return join17(dir, hash.slice(0, 2), hash);
5238
5521
  };
5239
5522
  let total = [...sizes.values()].reduce((sum, size) => sum + size, 0);
5240
5523
  return {
5241
5524
  async put(absPath) {
5242
- const temp = join16(dir, `incoming-${randomUUID5()}`);
5525
+ const temp = join17(dir, `incoming-${randomUUID5()}`);
5243
5526
  await mkdir(dir, { recursive: true });
5244
5527
  await copyFile(absPath, temp, constants3.COPYFILE_FICLONE);
5245
5528
  const hash = await hashFile(temp);
@@ -5247,8 +5530,8 @@ async function openBlobStore(dir) {
5247
5530
  await rm(temp, { force: true });
5248
5531
  return hash;
5249
5532
  }
5250
- await mkdir(dirname6(pathOf(hash)), { recursive: true });
5251
- const size = (await stat(temp)).size;
5533
+ await mkdir(dirname8(pathOf(hash)), { recursive: true });
5534
+ const size = (await stat2(temp)).size;
5252
5535
  await rename(temp, pathOf(hash));
5253
5536
  sizes.set(hash, size);
5254
5537
  total += size;
@@ -5256,7 +5539,7 @@ async function openBlobStore(dir) {
5256
5539
  },
5257
5540
  has: (hash) => sizes.has(hash),
5258
5541
  read: async (hash) => readFile2(pathOf(hash)),
5259
- writeTo: async (hash, absPath) => cloneOrCopy(pathOf(hash), absPath),
5542
+ writeTo: async (hash, absPath, beforeReplace) => cloneOrCopy(pathOf(hash), absPath, beforeReplace),
5260
5543
  bytes: () => total,
5261
5544
  size: (hash) => sizes.get(hash) ?? 0,
5262
5545
  async prune(keep) {
@@ -5272,44 +5555,50 @@ async function openBlobStore(dir) {
5272
5555
 
5273
5556
  // src/history/historyId.ts
5274
5557
  import { randomUUID as randomUUID6 } from "crypto";
5275
- import { existsSync as existsSync11, mkdirSync as mkdirSync8, readFileSync as readFileSync15, writeFileSync as writeFileSync7 } from "fs";
5276
- import { join as join17, resolve as resolve6 } from "path";
5277
- var ID_PATH = join17(".hyperframes", "history-id");
5558
+ import { existsSync as existsSync10, mkdirSync as mkdirSync8, readFileSync as readFileSync15, statSync as statSync8, writeFileSync as writeFileSync7 } from "fs";
5559
+ import { join as join18, resolve as resolve7 } from "path";
5560
+ var ID_PATH = join18(".hyperframes", "history-id");
5278
5561
  var ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
5279
5562
  function readId(projectDir) {
5280
5563
  try {
5281
- const id = readFileSync15(join17(projectDir, ID_PATH), "utf-8").trim();
5564
+ const id = readFileSync15(join18(projectDir, ID_PATH), "utf-8").trim();
5282
5565
  return ID_SHAPE.test(id) ? id : null;
5283
5566
  } catch {
5284
5567
  return null;
5285
5568
  }
5286
5569
  }
5287
- function recordedDir(historyDir) {
5570
+ var sameFolder = (a, b) => a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
5571
+ function isRecordedFolder(historyDir, folder) {
5288
5572
  try {
5289
- return JSON.parse(readFileSync15(join17(historyDir, "project.json"), "utf-8")).dir ?? null;
5573
+ const was = JSON.parse(readFileSync15(join18(historyDir, "project.json"), "utf-8"));
5574
+ return sameFolder({ ino: was.ino, birthtimeMs: was.born }, folder);
5290
5575
  } catch {
5291
- return null;
5576
+ return false;
5292
5577
  }
5293
5578
  }
5294
5579
  function projectHistoryId(projectDir, historyRoot) {
5295
- const dir = resolve6(projectDir);
5580
+ const dir = resolve7(projectDir);
5581
+ const folder = statSync8(dir);
5296
5582
  let id = readId(dir);
5297
- const was = id && recordedDir(join17(historyRoot, id));
5298
- if (!id || was && was !== dir && existsSync11(was) && readId(was) === id) {
5583
+ if (!id || existsSync10(join18(historyRoot, id)) && !isRecordedFolder(join18(historyRoot, id), folder)) {
5299
5584
  id = randomUUID6();
5300
- mkdirSync8(join17(dir, ".hyperframes"), { recursive: true });
5301
- writeFileSync7(join17(dir, ID_PATH), `${id}
5585
+ mkdirSync8(join18(dir, ".hyperframes"), { recursive: true });
5586
+ writeFileSync7(join18(dir, ID_PATH), `${id}
5302
5587
  `);
5303
5588
  }
5304
- mkdirSync8(join17(historyRoot, id), { recursive: true });
5305
- writeFileSync7(join17(historyRoot, id, "project.json"), JSON.stringify({ dir }));
5589
+ recordProject(join18(historyRoot, id), dir, folder);
5306
5590
  return id;
5307
5591
  }
5592
+ function recordProject(historyDir, dir, folder) {
5593
+ const record = { dir, ino: folder.ino, born: folder.birthtimeMs };
5594
+ mkdirSync8(historyDir, { recursive: true });
5595
+ replaceFileAtomically(join18(historyDir, "project.json"), JSON.stringify(record), 420);
5596
+ }
5308
5597
 
5309
5598
  // src/history/ownerLock.ts
5310
5599
  import { randomUUID as randomUUID7 } from "crypto";
5311
5600
  import { linkSync, mkdirSync as mkdirSync9, readFileSync as readFileSync16, rmSync as rmSync5, writeFileSync as writeFileSync8 } from "fs";
5312
- import { join as join18 } from "path";
5601
+ import { join as join19 } from "path";
5313
5602
  var HistoryBusyError = class extends Error {
5314
5603
  constructor(pid) {
5315
5604
  super(`This project's history is open in another process (pid ${pid}).`);
@@ -5368,7 +5657,7 @@ function evictDeadOwner(file) {
5368
5657
  }
5369
5658
  }
5370
5659
  async function takeHistoryOwnership(home, waitMs) {
5371
- const file = join18(home, "owner.pid");
5660
+ const file = join19(home, "owner.pid");
5372
5661
  const deadline = Date.now() + waitMs;
5373
5662
  mkdirSync9(home, { recursive: true });
5374
5663
  for (; ; ) {
@@ -5389,7 +5678,7 @@ async function takeHistoryOwnership(home, waitMs) {
5389
5678
 
5390
5679
  // src/history/historyLog.ts
5391
5680
  import { closeSync as closeSync5, constants as constants4, mkdirSync as mkdirSync10, openSync as openSync5, readFileSync as readFileSync17, writeSync as writeSync2 } from "fs";
5392
- import { dirname as dirname7 } from "path";
5681
+ import { dirname as dirname9 } from "path";
5393
5682
  var START = "start";
5394
5683
  function parseRecord(line) {
5395
5684
  try {
@@ -5441,21 +5730,25 @@ function writeLog(file, log) {
5441
5730
  ...log.entries.map((entry) => ({ type: "entry", entry })),
5442
5731
  ...[...log.pins].map((id) => ({ type: "pin", id, pinned: true }))
5443
5732
  ];
5444
- mkdirSync10(dirname7(file), { recursive: true });
5733
+ mkdirSync10(dirname9(file), { recursive: true });
5445
5734
  replaceFileAtomically(file, records.map((r) => `${JSON.stringify(r)}
5446
5735
  `).join(""), 420);
5447
5736
  }
5448
- function applyEntry(manifest, entry) {
5449
- for (const file of entry.files)
5450
- if (file.after === null) manifest.delete(file.path);
5451
- else manifest.set(file.path, file.after);
5737
+ function applyEntry(manifest, entry, side) {
5738
+ for (const file of entry.files) {
5739
+ const hash = file[side];
5740
+ if (hash === null) manifest.delete(file.path);
5741
+ else manifest.set(file.path, hash);
5742
+ }
5452
5743
  }
5453
5744
  function manifestAt(log, point) {
5745
+ return point === START ? new Map(log.baseline) : manifestAround(log, point, "after");
5746
+ }
5747
+ function manifestAround(log, id, side) {
5454
5748
  const manifest = new Map(log.baseline);
5455
- if (point === START) return manifest;
5456
5749
  for (const entry of log.entries) {
5457
- applyEntry(manifest, entry);
5458
- if (entry.id === point) return manifest;
5750
+ applyEntry(manifest, entry, entry.id === id ? side : "after");
5751
+ if (entry.id === id) return manifest;
5459
5752
  }
5460
5753
  return null;
5461
5754
  }
@@ -5467,24 +5760,35 @@ function undoneIds(entries) {
5467
5760
  }
5468
5761
  return undone;
5469
5762
  }
5470
- function stepTarget(entries, direction) {
5763
+ function stepTarget(entries, direction, mine) {
5471
5764
  const undone = undoneIds(entries);
5472
5765
  const byId = new Map(entries.map((entry) => [entry.id, entry]));
5473
- for (let i = entries.length - 1; i >= 0; i -= 1) {
5474
- const entry = entries[i];
5475
- const isChange = !entry.undoes;
5476
- if (direction === "back" && isChange && !undone.has(entry.id)) return entry;
5477
- if (direction !== "forward") continue;
5478
- if (isChange) return void 0;
5766
+ const owns = ownerOf2(entries, byId, mine);
5767
+ const newestFirst = [...entries].reverse();
5768
+ if (direction === "back")
5769
+ return newestFirst.find((entry) => !entry.undoes && !undone.has(entry.id) && owns(entry));
5770
+ for (const entry of newestFirst) {
5771
+ if (!entry.undoes) {
5772
+ if (owns(entry)) return void 0;
5773
+ continue;
5774
+ }
5479
5775
  const target = byId.get(entry.undoes);
5480
- if (!undone.has(entry.id) && target && !target.undoes) return entry;
5776
+ if (mine(entry) && !undone.has(entry.id) && target && !target.undoes) return entry;
5481
5777
  }
5482
5778
  return void 0;
5483
5779
  }
5780
+ function ownerOf2(entries, byId, mine) {
5781
+ const redoneBy = /* @__PURE__ */ new Map();
5782
+ for (const entry of entries) {
5783
+ const target = entry.undoes ? byId.get(entry.undoes) : void 0;
5784
+ if (target?.undoes) redoneBy.set(target.undoes, entry);
5785
+ }
5786
+ return (entry) => mine(entry) || mine(redoneBy.get(entry.id) ?? entry);
5787
+ }
5484
5788
  function foldOldest(log) {
5485
5789
  const oldest = log.entries[0];
5486
5790
  if (!oldest || log.pins.has(oldest.id)) return false;
5487
- applyEntry(log.baseline, oldest);
5791
+ applyEntry(log.baseline, oldest, "after");
5488
5792
  log.entries.shift();
5489
5793
  return true;
5490
5794
  }
@@ -5499,8 +5803,9 @@ function referencedHashes(log, current) {
5499
5803
  }
5500
5804
 
5501
5805
  // src/history/projectHistory.ts
5502
- var DEFAULT_HISTORY_ROOT = join19(homedir2(), ".cache", "hyperframes", "history");
5806
+ var DEFAULT_HISTORY_ROOT = join20(homedir2(), ".cache", "hyperframes", "history");
5503
5807
  var MAX_WINDOW_IDLE_MS = 10 * 6e4;
5808
+ var UNDO_MODES = ["just-this", "back-to-before", "keep-later-edits"];
5504
5809
  var OUTSIDE = { kind: "outside", name: "Outside" };
5505
5810
  var OUTSIDE_LABEL = "Changed outside the app";
5506
5811
  var RACY_MS = 2e3;
@@ -5514,6 +5819,33 @@ function splitAt(change, at, kept) {
5514
5819
  { ...change, before: at }
5515
5820
  ];
5516
5821
  }
5822
+ var isMissingFolder = (error) => ["ENOENT", "ENOTDIR"].includes(error.code ?? "");
5823
+ async function removeEmptyFolders(dir) {
5824
+ let entries;
5825
+ try {
5826
+ entries = await readdir2(dir, { withFileTypes: true });
5827
+ } catch (error) {
5828
+ if (isMissingFolder(error)) return;
5829
+ throw error;
5830
+ }
5831
+ for (const entry of entries)
5832
+ if (entry.isDirectory()) await removeEmptyFolders(join20(dir, entry.name));
5833
+ await rmdir(dir);
5834
+ }
5835
+ function fileLeftIn(dir, at, deleted) {
5836
+ for (const entry of readdirSync7(dir, { withFileTypes: true })) {
5837
+ const path = `${at}/${entry.name}`;
5838
+ const left = entry.isDirectory() ? fileLeftIn(join20(dir, entry.name), path, deleted) : deleted.has(path) ? void 0 : path;
5839
+ if (left) return left;
5840
+ }
5841
+ return void 0;
5842
+ }
5843
+ function ignoresCase(dir) {
5844
+ const self = statSync9(join20(dir, ID_PATH), { throwIfNoEntry: false });
5845
+ const other = statSync9(join20(dir, ID_PATH.toUpperCase()), { throwIfNoEntry: false });
5846
+ return self !== void 0 && other?.ino === self.ino && other.dev === self.dev;
5847
+ }
5848
+ var blocks = (removed, added) => added.startsWith(`${removed}/`) || removed.startsWith(`${added}/`);
5517
5849
  var takesWrite = (window, at) => window.idleMs === void 0 || at - (window.lastWriteAt ?? at) <= window.idleMs;
5518
5850
  function addChange(group, path, before, after) {
5519
5851
  const earlier = group.changes.get(path);
@@ -5526,19 +5858,26 @@ var Engine = class {
5526
5858
  this.options = options;
5527
5859
  this.projectId = projectId;
5528
5860
  this.blobs = blobs;
5529
- this.dir = resolve7(options.projectDir);
5530
- this.home = join19(options.historyRoot, projectId);
5861
+ this.dir = resolve8(options.projectDir);
5862
+ this.home = join20(options.historyRoot, projectId);
5863
+ this.foldsCase = ignoresCase(this.dir);
5864
+ const folder = statSync9(this.dir, { throwIfNoEntry: false });
5865
+ if (!folder || readId(this.dir) !== projectId || !isRecordedFolder(this.home, folder))
5866
+ throw this.replaced();
5867
+ this.folder = { dev: folder.dev, ino: folder.ino, birthtimeMs: folder.birthtimeMs };
5531
5868
  }
5532
5869
  options;
5533
5870
  projectId;
5534
5871
  blobs;
5535
5872
  dir;
5873
+ foldsCase;
5874
+ folder;
5536
5875
  home;
5537
5876
  log = { baseline: /* @__PURE__ */ new Map(), entries: [], pins: /* @__PURE__ */ new Set() };
5538
5877
  tracked = /* @__PURE__ */ new Map();
5539
5878
  windows = [];
5540
5879
  outside = null;
5541
- /** A coalescing claim, open until another key, its idle timer, or an operation commits it. */
5880
+ /** A coalescing claim, open until another key, its idle timer, an operation, a window, or another write. */
5542
5881
  claimed = null;
5543
5882
  writeToken;
5544
5883
  quietTimer;
@@ -5546,18 +5885,51 @@ var Engine = class {
5546
5885
  notedTimer = null;
5547
5886
  listeners = /* @__PURE__ */ new Set();
5548
5887
  tail = Promise.resolve();
5888
+ closed = false;
5889
+ closing;
5890
+ /** A path as the project's disk compares names: folded where `A` and `a` are one name. */
5891
+ nameKey(path) {
5892
+ return this.foldsCase ? path.toLowerCase() : path;
5893
+ }
5549
5894
  get logFile() {
5550
- return join19(this.home, "log.jsonl");
5895
+ return join20(this.home, "log.jsonl");
5551
5896
  }
5552
5897
  now() {
5553
5898
  return (this.options.now ?? Date.now)();
5554
5899
  }
5555
5900
  /** One operation at a time, in order: sweeps, windows and undos never interleave. */
5556
5901
  queue(task) {
5902
+ if (this.closed)
5903
+ return Promise.reject(new HistoryClosedError("This project's history is closed."));
5557
5904
  const run = this.tail.then(task);
5558
5905
  this.tail = run.catch(() => void 0);
5559
5906
  return run;
5560
5907
  }
5908
+ /** Another project if the folder or its id changed, or the id is gone: a recreated folder may reuse the inode. */
5909
+ whereFolder() {
5910
+ const now = statSync9(this.dir, { throwIfNoEntry: false });
5911
+ if (!now) return "gone";
5912
+ const same = now.dev === this.folder.dev && sameFolder(now, this.folder);
5913
+ return same && readId(this.dir) === this.projectId ? "here" : "replaced";
5914
+ }
5915
+ assertWritable() {
5916
+ const folder = this.whereFolder();
5917
+ if (folder === "gone") throw new Error(`The project folder is gone: ${this.dir}`);
5918
+ if (folder === "replaced") throw this.replaced();
5919
+ }
5920
+ persistLog(record) {
5921
+ if (record) saveRecord(this.logFile, this.log, record);
5922
+ else writeLog(this.logFile, this.log);
5923
+ if (!existsSync11(join20(this.home, "project.json")))
5924
+ recordProject(this.home, this.dir, this.folder);
5925
+ }
5926
+ replaced() {
5927
+ return new HistoryClosedError(`${this.dir} is now another project.`);
5928
+ }
5929
+ assertOpen() {
5930
+ if (this.closed) throw new HistoryClosedError("This project's history is closed.");
5931
+ if (this.whereFolder() === "replaced") throw this.replaced();
5932
+ }
5561
5933
  async start() {
5562
5934
  const log = readLog(
5563
5935
  this.logFile,
@@ -5574,9 +5946,7 @@ var Engine = class {
5574
5946
  this.tracked.set(path, { hash, stat: cached?.hash === hash ? cached.stat : "" });
5575
5947
  }
5576
5948
  this.reopenClosedWindow();
5577
- await this.sweep();
5578
- for (const window of [...this.windows]) await this.endWindow(window);
5579
- await this.commitOutside();
5949
+ await this.settleAll();
5580
5950
  }
5581
5951
  reopenClosedWindow() {
5582
5952
  const closed = this.options.closedWindow;
@@ -5586,13 +5956,13 @@ var Engine = class {
5586
5956
  }
5587
5957
  async firstOpen() {
5588
5958
  const sweptAt = Date.now();
5589
- for (const file of listProjectFiles(this.dir))
5590
- this.tracked.set(file.path, {
5591
- hash: await this.blobs.put(join19(this.dir, file.path)),
5592
- stat: statKey(file, sweptAt)
5593
- });
5959
+ for (const file of listProjectFiles(this.dir)) {
5960
+ const hash = await this.storeIfPresent(file.path);
5961
+ if (this.whereFolder() !== "here") throw this.replaced();
5962
+ if (hash !== null) this.tracked.set(file.path, { hash, stat: statKey(file, sweptAt) });
5963
+ }
5594
5964
  this.log.baseline = this.manifest();
5595
- writeLog(this.logFile, this.log);
5965
+ this.persistLog();
5596
5966
  this.saveStatCache();
5597
5967
  }
5598
5968
  manifest() {
@@ -5600,7 +5970,7 @@ var Engine = class {
5600
5970
  }
5601
5971
  readStatCache() {
5602
5972
  try {
5603
- const files = JSON.parse(readFileSync18(join19(this.home, "stat.json"), "utf-8"));
5973
+ const files = JSON.parse(readFileSync18(join20(this.home, "stat.json"), "utf-8"));
5604
5974
  return new Map(Object.entries(files));
5605
5975
  } catch {
5606
5976
  return /* @__PURE__ */ new Map();
@@ -5609,26 +5979,39 @@ var Engine = class {
5609
5979
  saveStatCache() {
5610
5980
  const files = JSON.stringify(Object.fromEntries(this.tracked));
5611
5981
  mkdirSync11(this.home, { recursive: true });
5612
- replaceFileAtomically(join19(this.home, "stat.json"), files, 420);
5982
+ replaceFileAtomically(join20(this.home, "stat.json"), files, 420);
5613
5983
  }
5614
5984
  /**
5615
5985
  * Takes in every write since the last sweep and files each change to its writer. ponytail: always the whole
5616
5986
  * project (a stat per file, a hash only when the stat moved); per-path sweeps if projects reach tens of thousands.
5617
5987
  */
5618
5988
  async sweep() {
5619
- if (!existsSync12(this.dir)) return;
5989
+ if (this.whereFolder() !== "here") return;
5620
5990
  const sweptAt = Date.now();
5621
5991
  const changedAt = (file) => Math.min(sweptAt, Math.max(file.mtimeMs, file.ctimeMs));
5622
- const seen = listProjectFiles(this.dir).sort((a, b) => changedAt(a) - changedAt(b));
5623
- let changed = false;
5624
- for (const file of seen)
5625
- changed = await this.observe(file.path, statKey(file, sweptAt), changedAt(file)) || changed;
5992
+ const seen = listProjectFiles(this.dir);
5626
5993
  const present = new Set(seen.map((file) => file.path));
5627
- for (const [path, known] of this.tracked) {
5628
- if (present.has(path)) continue;
5994
+ const removed = [...this.tracked.keys()].filter((path) => !present.has(path)).map((path) => {
5995
+ const standing = this.standingAt(path);
5996
+ return { at: standing ? changedAt(standing.stat) : sweptAt, path, file: null };
5997
+ });
5998
+ const appearedAt = (file) => Math.max(
5999
+ changedAt(file),
6000
+ ...removed.filter((gone) => blocks(this.nameKey(gone.path), this.nameKey(file.path))).map((gone) => gone.at)
6001
+ );
6002
+ const events = [
6003
+ ...removed,
6004
+ ...seen.map((file) => ({ at: appearedAt(file), path: file.path, file }))
6005
+ ].sort((a, b) => a.at - b.at);
6006
+ let changed = false;
6007
+ for (const { at, path, file } of events) {
6008
+ if (file) {
6009
+ changed = await this.observe(path, statKey(file, sweptAt), at) || changed;
6010
+ continue;
6011
+ }
6012
+ const known = this.tracked.get(path);
5629
6013
  this.tracked.delete(path);
5630
- const removedAt = sweptAt;
5631
- await this.record(path, known.hash, null, removedAt);
6014
+ await this.record(path, known.hash, null, at);
5632
6015
  changed = true;
5633
6016
  }
5634
6017
  if (changed) this.saveStatCache();
@@ -5636,23 +6019,23 @@ var Engine = class {
5636
6019
  /** The file's hash once copied in; null when it was removed before the copy (the next sweep records that). */
5637
6020
  async storeIfPresent(path) {
5638
6021
  try {
5639
- return await this.blobs.put(join19(this.dir, path));
6022
+ return await this.blobs.put(join20(this.dir, path));
5640
6023
  } catch (error) {
5641
6024
  if (error.code === "ENOENT") return null;
5642
6025
  throw error;
5643
6026
  }
5644
6027
  }
5645
- async observe(path, stat2, at) {
6028
+ async observe(path, stat3, at) {
5646
6029
  const known = this.tracked.get(path) ?? { hash: null, stat: null };
5647
- if (stat2 && known.stat === stat2) return false;
6030
+ if (stat3 && known.stat === stat3) return false;
5648
6031
  const hash = await this.storeIfPresent(path);
5649
- if (hash === null) return false;
5650
- this.tracked.set(path, { hash, stat: stat2 });
6032
+ if (hash === null || this.whereFolder() !== "here") return false;
6033
+ this.tracked.set(path, { hash, stat: stat3 });
5651
6034
  if (known.hash !== hash) await this.record(path, known.hash, hash, at);
5652
- return known.hash !== hash || known.stat !== stat2;
6035
+ return known.hash !== hash || known.stat !== stat3;
5653
6036
  }
5654
6037
  async record(path, before, after, at) {
5655
- const endedBeforeThisWrite = this.windows.filter((open) => !takesWrite(open, at));
6038
+ const endedBeforeThisWrite = this.windows.filter((open2) => !takesWrite(open2, at));
5656
6039
  if (endedBeforeThisWrite.length) await this.commitOutside();
5657
6040
  for (const window2 of endedBeforeThisWrite) await this.endWindow(window2);
5658
6041
  const window = this.windows.at(-1);
@@ -5662,28 +6045,34 @@ var Engine = class {
5662
6045
  /** ponytail: another's write between Studio's write and its sweep folds into Studio's entry (per-write tokens). */
5663
6046
  async claimNow(who, label, paths, { coalesceKey, idleMs, overwrote = {} }) {
5664
6047
  await this.sweep();
5665
- const { taken, cut } = this.takeClaimed(who, paths, overwrote);
5666
- if (!taken.length) {
5667
- if (coalesceKey !== this.claimed?.key) await this.commitClaim();
5668
- return null;
5669
- }
6048
+ const taken = await this.takeClaimed(who, paths, overwrote);
6049
+ if (!taken.length) return this.claimNothing(coalesceKey);
5670
6050
  const held = this.heldFor(coalesceKey, taken);
5671
6051
  if (!held) await this.commitClaim();
5672
- await this.commitOlder(cut);
6052
+ await this.commitPending();
5673
6053
  const group = held ?? this.newGroup(who, label);
5674
6054
  for (const change of taken) addChange(group, change.path, change.before, change.after);
5675
6055
  if (coalesceKey) return this.holdClaim(group, coalesceKey, idleMs);
5676
6056
  const entry = await this.commit(group);
5677
6057
  return entry && { id: entry.id };
5678
6058
  }
5679
- async commitOlder(cut) {
5680
- if (this.outside) cut.delete(this.outside);
6059
+ /** A claim that took nothing still ends a held claim under another key. */
6060
+ async claimNothing(coalesceKey) {
6061
+ if (coalesceKey !== this.claimed?.key) await this.commitClaim();
6062
+ return null;
6063
+ }
6064
+ /** Commits every other writer's pending changes, oldest first; open windows stay open. */
6065
+ async commitPending() {
5681
6066
  await this.commitOutside();
5682
- for (const [window, paths] of cut) await this.commitCut(window, paths);
6067
+ for (const window of [...this.windows])
6068
+ if (window.changes.size) await this.commitCut(window, [...window.changes.keys()]);
6069
+ }
6070
+ pendingElsewhere() {
6071
+ return Boolean(this.outside?.changes.size) || this.windows.some((open2) => open2.changes.size > 0);
5683
6072
  }
5684
6073
  /** The held claim `taken` continues: same key, each file from its own last change, no held file changed since. */
5685
6074
  heldFor(key, taken) {
5686
- const group = key && this.claimed?.key === key ? this.claimed.group : null;
6075
+ const group = key && this.claimed?.key === key && !this.pendingElsewhere() ? this.claimed.group : null;
5687
6076
  const after = (path, before) => {
5688
6077
  const own = group?.changes.get(path);
5689
6078
  return own ? own.after : before;
@@ -5693,10 +6082,10 @@ var Engine = class {
5693
6082
  return group && taken.every((change) => after(change.path, change.before) === change.before) && [...group.changes.values()].every(untouched) ? group : null;
5694
6083
  }
5695
6084
  logPath(path) {
5696
- return relative4(this.dir, resolve7(this.dir, path)).split(sep3).join("/");
6085
+ return relative6(this.dir, resolve8(this.dir, path)).split(sep4).join("/");
5697
6086
  }
5698
- /** Takes `paths`' uncommitted changes from others, cut at what `who` overwrote; `cut`: paths each group keeps. */
5699
- takeClaimed(who, paths, overwrote) {
6087
+ /** Takes `paths`' uncommitted changes from others, cut at what `who` overwrote. */
6088
+ async takeClaimed(who, paths, overwrote) {
5700
6089
  const wanted = new Set(paths.map((path) => this.logPath(path)));
5701
6090
  const at = new Map(
5702
6091
  Object.entries(overwrote).map(([path, version]) => [
@@ -5704,25 +6093,52 @@ var Engine = class {
5704
6093
  hashOfVersion(version)
5705
6094
  ])
5706
6095
  );
5707
- const others = this.windows.filter((open) => !sameWho(open.who, who));
6096
+ const others = this.windows.filter((open2) => !sameWho(open2.who, who));
5708
6097
  const groups = this.outside ? [this.outside, ...others] : others;
5709
- const taken = [];
5710
- const cut = /* @__PURE__ */ new Map();
5711
- for (const group of groups) {
5712
- for (const change of [...group.changes.values()]) {
5713
- if (!wanted.has(change.path)) continue;
5714
- const [kept, claimed] = this.cutOut(group, change, at.get(change.path));
5715
- if (claimed) taken.push(claimed);
5716
- if (kept && claimed) cut.set(group, [...cut.get(group) ?? [], change.path]);
5717
- }
6098
+ const hits = groups.flatMap(
6099
+ (group) => [...group.changes.values()].filter((change) => wanted.has(change.path)).map((change) => ({ group, change }))
6100
+ );
6101
+ const cuts = [];
6102
+ const walked = hits.map(() => /* @__PURE__ */ new Set());
6103
+ for (const [i, { change }] of hits.entries()) {
6104
+ const told = at.get(change.path);
6105
+ cuts.push(await this.overwrittenBy(change, told, walked[i]) ?? told);
6106
+ }
6107
+ hits.forEach(
6108
+ ({ change }, i) => forgetOverwrittenBytes(
6109
+ join20(this.dir, change.path),
6110
+ new Set([...walked[i]].map(hashVersion))
6111
+ )
6112
+ );
6113
+ return hits.flatMap(({ group, change }, i) => this.cutOut(group, change, cuts[i]) ?? []);
6114
+ }
6115
+ /** Walks the server's writes back from `change.after` to the bytes they replaced, stored so an undo restores them. */
6116
+ async overwrittenBy(change, told, seen) {
6117
+ const absPath = join20(this.dir, change.path);
6118
+ let bytes;
6119
+ for (let hash = change.after; hash; ) {
6120
+ if (seen.has(hash)) return void 0;
6121
+ seen.add(hash);
6122
+ const replaced = bytesOverwrittenBy(absPath, hashVersion(hash));
6123
+ if (replaced === void 0) break;
6124
+ bytes = replaced;
6125
+ hash = hashOfVersion(fileContentVersion(replaced));
6126
+ if (hash === change.before || hash === told) return hash;
6127
+ }
6128
+ if (bytes === void 0) return void 0;
6129
+ const staged = join20(this.home, `overwrote-${randomUUID8()}`);
6130
+ await writeFile(staged, bytes);
6131
+ try {
6132
+ return await this.blobs.put(staged);
6133
+ } finally {
6134
+ await rm2(staged, { force: true });
5718
6135
  }
5719
- return { taken, cut };
5720
6136
  }
5721
6137
  cutOut(group, change, cut) {
5722
6138
  const [kept, claimed] = splitAt(change, cut, cut !== void 0 && this.blobs.has(cut));
5723
6139
  group.changes.delete(change.path);
5724
6140
  if (kept) group.changes.set(kept.path, kept);
5725
- return [kept, claimed];
6141
+ return claimed;
5726
6142
  }
5727
6143
  /** Commits the window's cut `paths` as their own entry, other files staying: a window holds one change per file,
5728
6144
  * so the writer's next write to a cut file would otherwise span the claimer's edit. */
@@ -5736,8 +6152,8 @@ var Engine = class {
5736
6152
  }
5737
6153
  holdClaim(group, key, idleMs = this.options.quietMs ?? 2e3) {
5738
6154
  clearTimeout(this.claimed?.timer);
5739
- const timer = setTimeout(() => this.background(() => this.commitClaim()), idleMs);
5740
- timer.unref?.();
6155
+ const timer = Number.isFinite(idleMs) ? setTimeout(() => this.background(() => this.commitClaim()), idleMs) : void 0;
6156
+ timer?.unref?.();
5741
6157
  this.claimed = { group, key, timer };
5742
6158
  return group.changes.size ? { id: group.id } : null;
5743
6159
  }
@@ -5767,6 +6183,7 @@ var Engine = class {
5767
6183
  return { id: randomUUID8(), who, label, startedAt: this.now(), changes: /* @__PURE__ */ new Map() };
5768
6184
  }
5769
6185
  async commitOutside() {
6186
+ if (this.outside?.changes.size) await this.commitClaim();
5770
6187
  clearTimeout(this.quietTimer);
5771
6188
  clearTimeout(this.maxTimer);
5772
6189
  const group = this.outside;
@@ -5775,14 +6192,12 @@ var Engine = class {
5775
6192
  }
5776
6193
  async commit(group, extra = {}) {
5777
6194
  if (!group.changes.size) return null;
5778
- const { id, who, label, startedAt, lastWriteAt, changes } = group;
5779
- const files = [...changes.values()].sort((a, b) => a.path.localeCompare(b.path));
5780
- const previousEnd = this.log.entries.at(-1)?.endedAt ?? 0;
5781
- const endedAt = Math.max(lastWriteAt ?? this.now(), previousEnd);
5782
- const entry = { id, who, label, startedAt, endedAt, files, ...extra };
6195
+ const pending = this.pendingEntry(group);
6196
+ const endedAt = Math.max(pending.endedAt, this.log.entries.at(-1)?.endedAt ?? 0);
6197
+ const entry = { ...pending, endedAt, ...extra };
5783
6198
  this.log.entries.push(entry);
5784
6199
  try {
5785
- saveRecord(this.logFile, this.log, { type: "entry", entry });
6200
+ this.persistLog({ type: "entry", entry });
5786
6201
  } catch (error) {
5787
6202
  this.log.entries.pop();
5788
6203
  throw error;
@@ -5803,19 +6218,28 @@ var Engine = class {
5803
6218
  let folded = false;
5804
6219
  while (this.historyBytes() > budget && foldOldest(this.log)) {
5805
6220
  folded = true;
5806
- await this.blobs.prune(referencedHashes(this.log, this.manifest()));
6221
+ await this.blobs.prune(
6222
+ /* @__PURE__ */ new Set([...referencedHashes(this.log, this.manifest()), ...this.pendingHashes()])
6223
+ );
5807
6224
  }
5808
- if (folded) writeLog(this.logFile, this.log);
6225
+ if (folded) this.persistLog();
6226
+ }
6227
+ /** Hashes only pending changes point at yet, such as a claim's stored cut. */
6228
+ pendingHashes() {
6229
+ const changes = [...this.windows, this.outside, this.claimed?.group].flatMap((group) => [
6230
+ ...group?.changes.values() ?? []
6231
+ ]);
6232
+ return changes.flatMap((change) => [change.before, change.after]).filter((hash) => hash !== null);
5809
6233
  }
5810
- /** Before an operation: every write so far is filed, and the outside group is closed so it sorts first. */
6234
+ /** Before an operation or a window: every write is filed and every pending change committed, in write order. */
5811
6235
  async settle() {
5812
6236
  await this.sweep();
5813
6237
  await this.commitClaim();
5814
- await this.commitOutside();
6238
+ await this.commitPending();
5815
6239
  }
5816
6240
  /** A watcher saw a write: one sweep per burst takes it in (a deleted folder is reported by its name alone). */
5817
6241
  noteChange(path) {
5818
- if (this.notedTimer || !affectsProjectSignature(this.dir, resolve7(this.dir, path))) return;
6242
+ if (this.notedTimer || !affectsProjectSignature(this.dir, resolve8(this.dir, path))) return;
5819
6243
  this.notedTimer = setTimeout(() => {
5820
6244
  this.notedTimer = null;
5821
6245
  this.background(() => this.sweep());
@@ -5823,11 +6247,13 @@ var Engine = class {
5823
6247
  this.notedTimer.unref?.();
5824
6248
  }
5825
6249
  background(task) {
5826
- this.queue(task).catch((error) => this.options.onError?.(error));
6250
+ this.queue(task).catch((error) => {
6251
+ if (!(error instanceof HistoryClosedError)) this.options.onError?.(error);
6252
+ });
5827
6253
  }
5828
6254
  beginWindow(who, label, idleMs) {
5829
6255
  return this.queue(async () => {
5830
- await this.sweep();
6256
+ await this.settle();
5831
6257
  const window = { ...this.newGroup(who, label), idleMs };
5832
6258
  this.windows.push(window);
5833
6259
  this.touch(window, Date.now());
@@ -5854,7 +6280,8 @@ var Engine = class {
5854
6280
  async endWindow(window) {
5855
6281
  if (!this.windows.includes(window)) return window.entry ?? null;
5856
6282
  clearTimeout(window.idleTimer);
5857
- this.windows = this.windows.filter((open) => open !== window);
6283
+ await this.commitClaim();
6284
+ this.windows = this.windows.filter((open2) => open2 !== window);
5858
6285
  window.entry = await this.commit(window) ?? window.entry ?? null;
5859
6286
  return window.entry;
5860
6287
  }
@@ -5865,31 +6292,61 @@ var Engine = class {
5865
6292
  for (const window of [...this.windows]) await this.endWindow(window);
5866
6293
  await this.commitOutside();
5867
6294
  }
6295
+ /** What took a removed path's place, dating its removal: an entry at the path, or a file at a folder above it. */
6296
+ standingAt(path) {
6297
+ const parts = path.split("/");
6298
+ for (let depth = 1; depth <= parts.length; depth += 1) {
6299
+ const at = parts.slice(0, depth).join("/");
6300
+ const stat3 = lstatSync4(join20(this.dir, at), { throwIfNoEntry: false });
6301
+ if (!stat3) return void 0;
6302
+ if (at === path || !stat3.isDirectory()) return { at, stat: stat3 };
6303
+ }
6304
+ return void 0;
6305
+ }
6306
+ /** What a write of `path` would have to delete: a file or link at or above it, or a file in a folder at it. */
6307
+ inTheWay(path, deleted) {
6308
+ const standing = this.standingAt(path);
6309
+ if (!standing) return void 0;
6310
+ if (standing.stat.isSymbolicLink()) return standing.at;
6311
+ if (standing.at === path && !standing.stat.isDirectory()) return void 0;
6312
+ const real = realpathSync3.native(join20(this.dir, standing.at));
6313
+ const at = relative6(realpathSync3.native(this.dir), real).split(sep4).join("/");
6314
+ if (standing.at !== path) return deleted.has(at) ? void 0 : at;
6315
+ return fileLeftIn(real, at, deleted);
6316
+ }
5868
6317
  /** Writes `target` (path to hash, null deletes) as one entry of `who`'s. */
5869
6318
  async writeAs(who, label, target, extra) {
6319
+ this.assertWritable();
6320
+ const changes = [...target].filter(([path, hash]) => (this.tracked.get(path)?.hash ?? null) !== hash).sort(([, a], [, b]) => Number(a !== null) - Number(b !== null));
6321
+ const deleted = new Set(changes.filter(([, hash]) => hash === null).map(([path]) => path));
6322
+ const blocked = changes.filter(([, hash]) => hash !== null).map(([path]) => this.inTheWay(path, deleted)).find(Boolean);
6323
+ if (blocked) throw new Error(`${blocked} is in the way; move or delete it, then try again.`);
5870
6324
  const group = this.newGroup(who, label);
5871
6325
  this.windows.push(group);
5872
6326
  try {
5873
- for (const [path, hash] of target) {
5874
- if ((this.tracked.get(path)?.hash ?? null) !== hash)
5875
- await this.writeProjectFile(path, hash);
5876
- }
6327
+ for (const [path, hash] of changes) await this.writeProjectFile(path, hash);
5877
6328
  await this.sweep();
5878
6329
  } finally {
5879
- this.windows = this.windows.filter((open) => open !== group);
6330
+ this.windows = this.windows.filter((open2) => open2 !== group);
5880
6331
  }
5881
6332
  return this.commit(group, extra);
5882
6333
  }
5883
6334
  /** Writes one project file (null deletes it), first leaving the running operation's receipt for its echo. */
5884
6335
  async writeProjectFile(path, hash) {
5885
- const absPath = join19(this.dir, path);
6336
+ this.assertWritable();
6337
+ const absPath = join20(this.dir, path);
5886
6338
  const { writeToken } = this;
5887
6339
  if (writeToken) {
5888
6340
  const version = hash === null ? DELETED_VERSION : hashVersion(hash);
5889
6341
  recordFileWriteReceipt(absPath, { path, version, writeToken });
5890
6342
  }
5891
- if (hash === null) await rm2(absPath, { force: true });
5892
- else await this.blobs.writeTo(hash, absPath);
6343
+ if (hash === null) {
6344
+ this.assertWritable();
6345
+ rmSync6(absPath, { force: true });
6346
+ } else {
6347
+ await removeEmptyFolders(absPath);
6348
+ await this.blobs.writeTo(hash, absPath, () => this.assertWritable());
6349
+ }
5893
6350
  }
5894
6351
  entry(id) {
5895
6352
  const entry = this.log.entries.find((candidate) => candidate.id === id);
@@ -5913,7 +6370,9 @@ var Engine = class {
5913
6370
  entry: await this.restoreNow(point, who, `Went back to before: ${entry.label}`)
5914
6371
  };
5915
6372
  }
5916
- const target = new Map(entry.files.map((file) => [file.path, file.before]));
6373
+ const target = new Map(
6374
+ entry.files.filter((file) => mode !== "keep-later-edits" || !changed.includes(file)).map((file) => [file.path, file.before])
6375
+ );
5917
6376
  return {
5918
6377
  ok: true,
5919
6378
  entry: await this.writeAs(who, this.undoLabel(entry), target, { undoes: id })
@@ -5922,15 +6381,19 @@ var Engine = class {
5922
6381
  movedOn(entry) {
5923
6382
  return entry.files.filter((file) => (this.tracked.get(file.path)?.hash ?? null) !== file.after);
5924
6383
  }
5925
- /** Back reverts the newest change in effect, unless its files moved on under an earlier-ending edit (a held drag
5926
- * committed after an agent's turn): then that edit, if it still applies. */
5927
- next(direction) {
5928
- const top = stepTarget(this.log.entries, direction);
5929
- const moved = new Set(top && direction === "back" ? this.movedOn(top).map((f) => f.path) : []);
5930
- if (!top || !moved.size) return top;
5931
- const undone = undoneIds(this.log.entries);
5932
- const under = this.log.entries.slice(0, this.log.entries.indexOf(top)).reverse().find((e) => !e.undoes && !undone.has(e.id) && e.files.some((f) => moved.has(f.path)));
5933
- return under && !this.movedOn(under).length ? under : top;
6384
+ next(direction, who) {
6385
+ const mine = (author) => sameWho(author, who) || sameWho(author, OUTSIDE);
6386
+ const pending = [...[...this.windows].reverse(), this.outside, this.claimed?.group].find(
6387
+ (group) => group?.changes.size && mine(group.who)
6388
+ );
6389
+ if (pending) return direction === "back" ? this.pendingEntry(pending) : void 0;
6390
+ return stepTarget(this.log.entries, direction, (entry) => mine(entry.who));
6391
+ }
6392
+ /** A pending group as the entry it becomes once committed (a window's part gets a fresh id). */
6393
+ pendingEntry(group) {
6394
+ const files = [...group.changes.values()].sort((a, b) => a.path.localeCompare(b.path));
6395
+ const { id, who, label, startedAt, lastWriteAt } = group;
6396
+ return { id, who, label, startedAt, endedAt: lastWriteAt ?? this.now(), files };
5934
6397
  }
5935
6398
  conflict(entry, changed) {
5936
6399
  const paths = new Set(changed.map((file) => file.path));
@@ -5967,8 +6430,11 @@ var Engine = class {
5967
6430
  projectId: this.projectId,
5968
6431
  beginWindow: (who, label, options = {}) => this.beginWindow(who, label, options.idleMs ?? this.options.maxGroupMs ?? 3e4),
5969
6432
  claim: (who, label, paths, options = {}) => this.queue(() => this.claimNow(who, label, paths, options)),
5970
- noteChange: (path) => this.noteChange(path),
6433
+ noteChange: (path) => {
6434
+ if (!this.closed) this.noteChange(path);
6435
+ },
5971
6436
  list: () => {
6437
+ this.assertOpen();
5972
6438
  const undone = undoneIds(this.log.entries);
5973
6439
  return this.log.entries.map((entry) => ({
5974
6440
  ...entry,
@@ -5977,7 +6443,7 @@ var Engine = class {
5977
6443
  }));
5978
6444
  },
5979
6445
  step: (direction, who, { writeToken } = {}) => this.operation(writeToken, async () => {
5980
- const target = this.next(direction);
6446
+ const target = this.next(direction, who);
5981
6447
  return target ? this.undoNow(target.id, who) : { ok: true, entry: null };
5982
6448
  }),
5983
6449
  undo: (id, { who, mode, writeToken }) => this.operation(writeToken, () => this.undoNow(id, who, mode)),
@@ -5986,37 +6452,83 @@ var Engine = class {
5986
6452
  () => this.restoreNow(point, who, `Restored: ${this.pointLabel(point)}`)
5987
6453
  ),
5988
6454
  peek: (point) => {
6455
+ this.assertOpen();
5989
6456
  const files = manifestAt(this.log, point);
5990
6457
  return files && Object.fromEntries(files);
5991
6458
  },
5992
- next: (direction) => this.next(direction),
5993
- readBlob: (hash) => this.blobs.read(hash),
6459
+ checkout: (entryId, side, emptyDir) => this.queue(async () => {
6460
+ this.entry(entryId);
6461
+ if (!existsSync11(this.dir)) throw new Error(`The project folder is gone: ${this.dir}`);
6462
+ const dest = resolve8(emptyDir);
6463
+ if (isWithin(this.dir, dest))
6464
+ throw new Error(`Checkout writes outside the project only: ${dest}`);
6465
+ if ((await readdir2(dest).catch(missingIsEmpty)).length > 0)
6466
+ throw new Error(`Checkout writes into an empty folder only: ${dest}`);
6467
+ const files = manifestAround(this.log, entryId, side);
6468
+ try {
6469
+ for (const [path, hash] of files) await this.blobs.writeTo(hash, join20(dest, path));
6470
+ } catch (error) {
6471
+ for (const name of await readdir2(dest).catch(() => []))
6472
+ await rm2(join20(dest, name), { recursive: true, force: true });
6473
+ throw error;
6474
+ }
6475
+ }),
6476
+ next: (direction, who) => {
6477
+ this.assertOpen();
6478
+ return this.next(direction, who);
6479
+ },
6480
+ readBlob: async (hash) => {
6481
+ this.assertOpen();
6482
+ return this.blobs.read(hash);
6483
+ },
5994
6484
  pin: (id, pinned) => {
6485
+ this.assertOpen();
5995
6486
  this.entry(id);
5996
6487
  if (pinned) this.log.pins.add(id);
5997
6488
  else this.log.pins.delete(id);
5998
- saveRecord(this.logFile, this.log, { type: "pin", id, pinned });
6489
+ this.persistLog({ type: "pin", id, pinned });
5999
6490
  },
6000
6491
  onEntry: (listener) => {
6001
6492
  this.listeners.add(listener);
6002
6493
  return () => this.listeners.delete(listener);
6003
6494
  },
6004
6495
  flush: () => this.queue(() => this.settleAll()),
6005
- close: async () => {
6496
+ replacedAtPath: () => this.whereFolder() === "replaced",
6497
+ close: () => this.closing ??= (async () => {
6006
6498
  if (this.notedTimer) clearTimeout(this.notedTimer);
6007
- await this.queue(() => this.settleAll());
6008
- }
6499
+ const settled = this.queue(() => this.settleAll());
6500
+ this.closed = true;
6501
+ await settled;
6502
+ })()
6009
6503
  };
6010
6504
  }
6011
6505
  };
6506
+ function isWithin(dir, path) {
6507
+ let probe = resolve8(path);
6508
+ while (!existsSync11(probe) && dirname10(probe) !== probe) probe = dirname10(probe);
6509
+ const rel = relative6(realpathSync3.native(dir), realpathSync3.native(probe));
6510
+ return rel === "" || rel !== ".." && !rel.startsWith(`..${sep4}`) && !isAbsolute3(rel);
6511
+ }
6512
+ function missingIsEmpty(error) {
6513
+ if (error.code === "ENOENT") return [];
6514
+ throw error;
6515
+ }
6516
+ var HistoryClosedError = class extends Error {
6517
+ constructor(message) {
6518
+ super(message);
6519
+ this.name = "HistoryClosedError";
6520
+ }
6521
+ };
6012
6522
  async function openProjectHistory(options) {
6523
+ if (!existsSync11(options.projectDir))
6524
+ throw new HistoryClosedError(`${options.projectDir} is gone or now another project.`);
6013
6525
  const projectId = projectHistoryId(options.projectDir, options.historyRoot);
6014
6526
  const release = await takeHistoryOwnership(
6015
- join19(options.historyRoot, projectId),
6527
+ join20(options.historyRoot, projectId),
6016
6528
  options.ownerWaitMs ?? 5e3
6017
6529
  );
6018
6530
  try {
6019
- const blobs = await openBlobStore(join19(options.historyRoot, projectId, "blobs"));
6531
+ const blobs = await openBlobStore(join20(options.historyRoot, projectId, "blobs"));
6020
6532
  const engine = new Engine(options, projectId, blobs);
6021
6533
  await engine.queue(() => engine.start());
6022
6534
  const api = engine.api();
@@ -6062,7 +6574,7 @@ function versionsOf(value) {
6062
6574
  return pairs.length ? Object.fromEntries(pairs) : void 0;
6063
6575
  }
6064
6576
  function nextStep(history, direction) {
6065
- const target = history.next(direction);
6577
+ const target = history.next(direction, YOU);
6066
6578
  if (!target) return null;
6067
6579
  const paths = target.files.map((file) => file.path);
6068
6580
  return { id: target.id, label: target.label, endedAt: target.endedAt, paths };
@@ -6097,7 +6609,7 @@ function registerHistoryRoutes(api, adapter) {
6097
6609
  api.post(
6098
6610
  `${base}/undo`,
6099
6611
  (c) => withHistory(adapter, c, (history, body) => {
6100
- const mode = body.mode === "just-this" || body.mode === "back-to-before" ? body.mode : void 0;
6612
+ const mode = UNDO_MODES.find((known) => known === body.mode);
6101
6613
  return history.undo(text(body.entryId) ?? "", { who: whoOf(body), mode, ...writing(c) });
6102
6614
  })
6103
6615
  );
@@ -6133,11 +6645,9 @@ function registerHistoryRoutes(api, adapter) {
6133
6645
  (c) => withHistory(adapter, c, async (history, body) => {
6134
6646
  const paths = Array.isArray(body.paths) ? body.paths.filter((path) => text(path)) : [];
6135
6647
  const coalesceKey = text(body.coalesceKey) ?? void 0;
6136
- const idleMs = idleOf(body);
6137
6648
  const overwrote = versionsOf(body.overwrote);
6138
6649
  const claimed = await history.claim(YOU, text(body.label) ?? "Edited in Studio", paths, {
6139
- ...coalesceKey && { coalesceKey },
6140
- ...idleMs && { idleMs },
6650
+ ...coalesceKey && { coalesceKey, idleMs: idleOf(body) ?? Infinity },
6141
6651
  ...overwrote && { overwrote }
6142
6652
  });
6143
6653
  return { claimed };
@@ -6177,6 +6687,7 @@ function createStudioApi(adapter) {
6177
6687
  registerLintRoutes(api, adapter);
6178
6688
  registerRenderRoutes(api, adapter);
6179
6689
  registerThumbnailRoutes(api, adapter);
6690
+ registerImageThumbnailRoutes(api, adapter);
6180
6691
  registerSelectionRoutes(api, adapter);
6181
6692
  registerMediaRoutes(api, adapter);
6182
6693
  registerWaveformRoutes(api, adapter);
@@ -6187,7 +6698,32 @@ function createStudioApi(adapter) {
6187
6698
  return api;
6188
6699
  }
6189
6700
 
6701
+ // src/history/historyCache.ts
6702
+ function historyCache(open2) {
6703
+ const histories = /* @__PURE__ */ new Map();
6704
+ const get = async (projectDir) => {
6705
+ let cached = histories.get(projectDir);
6706
+ if (!cached) histories.set(projectDir, cached = open2(projectDir));
6707
+ const opened = await cached;
6708
+ if (!opened?.replacedAtPath()) return opened;
6709
+ const reopen = () => open2(projectDir);
6710
+ if (histories.get(projectDir) === cached)
6711
+ histories.set(projectDir, opened.close().then(reopen, reopen));
6712
+ return get(projectDir);
6713
+ };
6714
+ return {
6715
+ get,
6716
+ /** The history if one was opened, without opening it. */
6717
+ peek: (projectDir) => histories.get(projectDir),
6718
+ forget: (projectDir) => histories.delete(projectDir),
6719
+ closeAll: () => Promise.all(
6720
+ [...histories.values()].map((opened) => opened.then((history) => history?.close()))
6721
+ )
6722
+ };
6723
+ }
6724
+
6190
6725
  // src/helpers/duplicateElement.ts
6726
+ import { sameInstant } from "@hyperframes/core/clip-facts";
6191
6727
  import { ensureHfIds as ensureHfIds4 } from "@hyperframes/parsers/hf-ids";
6192
6728
  function duplicateElementInHtml(source, target, newId, at) {
6193
6729
  const { document, wrappedFragment } = parseSourceDocument(source);
@@ -6222,9 +6758,10 @@ function rippleElements(document, element, at, duration, track) {
6222
6758
  continue;
6223
6759
  }
6224
6760
  const start = numericAttribute(candidate, "data-start");
6225
- if (start !== null && start >= at) {
6226
- candidate.setAttribute("data-start", String(start + duration));
6227
- }
6761
+ if (start === null) continue;
6762
+ const metInsertionPoint = sameInstant(start, at);
6763
+ if (start < at && !metInsertionPoint) continue;
6764
+ candidate.setAttribute("data-start", String((metInsertionPoint ? at : start) + duration));
6228
6765
  }
6229
6766
  }
6230
6767
  function stripStableIds(element) {
@@ -6239,11 +6776,11 @@ function numericAttribute(element, name) {
6239
6776
  }
6240
6777
 
6241
6778
  // src/helpers/previewDocumentStore.ts
6242
- import { createHash as createHash7 } from "crypto";
6243
- import { mkdirSync as mkdirSync12, readFileSync as readFileSync19, readdirSync as readdirSync7, rmSync as rmSync6 } from "fs";
6244
- import { join as join20 } from "path";
6779
+ import { createHash as createHash8 } from "crypto";
6780
+ import { mkdirSync as mkdirSync12, readFileSync as readFileSync19, readdirSync as readdirSync8, rmSync as rmSync7 } from "fs";
6781
+ import { join as join21 } from "path";
6245
6782
  function createPreviewDocumentStore(dir, salt) {
6246
- const fileFor = (key) => join20(dir, `${createHash7("sha256").update(`${salt}
6783
+ const fileFor = (key) => join21(dir, `${createHash8("sha256").update(`${salt}
6247
6784
  ${key}`).digest("hex").slice(0, 32)}.html`);
6248
6785
  return {
6249
6786
  read(key) {
@@ -6258,8 +6795,8 @@ ${key}`).digest("hex").slice(0, 32)}.html`);
6258
6795
  mkdirSync12(dir, { recursive: true });
6259
6796
  const file = fileFor(key);
6260
6797
  replaceFileAtomically(file, html, 420);
6261
- for (const name of readdirSync7(dir)) {
6262
- if (join20(dir, name) !== file) rmSync6(join20(dir, name), { force: true });
6798
+ for (const name of readdirSync8(dir)) {
6799
+ if (join21(dir, name) !== file) rmSync7(join21(dir, name), { force: true });
6263
6800
  }
6264
6801
  } catch {
6265
6802
  }
@@ -6339,11 +6876,14 @@ export {
6339
6876
  DELETED_VERSION,
6340
6877
  START as HISTORY_START,
6341
6878
  HistoryBusyError,
6879
+ HistoryClosedError,
6342
6880
  MAX_WINDOW_IDLE_MS,
6343
6881
  MIME_TYPES,
6344
6882
  PREVIEW_BUNDLE_OPTIONS,
6345
6883
  STUDIO_MANUAL_EDITS_PATH,
6346
6884
  STUDIO_MOTION_PATH,
6885
+ UNDO_MODES,
6886
+ affectsPreview,
6347
6887
  affectsProjectSignature,
6348
6888
  applyFileMutations,
6349
6889
  buildSubCompositionHtml,
@@ -6362,6 +6902,7 @@ export {
6362
6902
  findTargetElement,
6363
6903
  getElementScreenshotClip,
6364
6904
  getMimeType,
6905
+ historyCache,
6365
6906
  identifyFileWrite,
6366
6907
  isSafePath,
6367
6908
  openProjectHistory,