@hyperframes/studio-server 0.8.75 → 0.8.77

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-KFPWE4Q7.js";
16
+ } from "./chunk-QYDRVMJ6.js";
17
17
  import {
18
18
  getElementScreenshotClip
19
19
  } from "./chunk-W2SBTCO2.js";
@@ -22,7 +22,7 @@ import {
22
22
  isAutoProxyEnabled,
23
23
  proxyEtagSalt,
24
24
  resolvePreviewMediaCodecProbeCache
25
- } from "./chunk-PGL4S2ZU.js";
25
+ } from "./chunk-X3T65KC2.js";
26
26
  import {
27
27
  ProxyCapacityError,
28
28
  ProxyTranscodeError,
@@ -52,7 +52,7 @@ import { Hono } from "hono";
52
52
 
53
53
  // src/routes/projects.ts
54
54
  import { readFile } from "fs/promises";
55
- import { join as join2 } from "path";
55
+ import { join as join3 } from "path";
56
56
 
57
57
  // src/helpers/safePath.ts
58
58
  import { join } from "path";
@@ -86,9 +86,113 @@ function walkDir(dir, prefix = "") {
86
86
  return files;
87
87
  }
88
88
 
89
- // src/helpers/projectSignature.ts
89
+ // src/helpers/hfIdPersist.ts
90
+ import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
90
91
  import { createHash } from "crypto";
91
- import { lstatSync, readFileSync, readdirSync as readdirSync2 } from "fs";
92
+ import {
93
+ closeSync,
94
+ constants,
95
+ fstatSync,
96
+ mkdirSync,
97
+ openSync,
98
+ readFileSync,
99
+ writeFileSync
100
+ } from "fs";
101
+ import { dirname, join as join2 } from "path";
102
+
103
+ // src/helpers/atomicFile.ts
104
+ import * as fs from "fs";
105
+ import { randomUUID } from "crypto";
106
+ function replaceFileAtomically(filePath, content, mode, operations = fs) {
107
+ const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
108
+ try {
109
+ operations.writeFileSync(tempPath, content, { encoding: "utf-8", mode });
110
+ operations.chmodSync(tempPath, mode);
111
+ operations.renameSync(tempPath, filePath);
112
+ } catch (error) {
113
+ try {
114
+ operations.unlinkSync(tempPath);
115
+ } catch {
116
+ }
117
+ throw error;
118
+ }
119
+ }
120
+
121
+ // src/helpers/hfIdPersist.ts
122
+ var isCompositionSource = (html) => /data-composition-id\s*=/.test(html);
123
+ var STAMP_RECORD = join2(".hyperframes", "hf-ids-stamped.json");
124
+ var contentHash = (text2) => createHash("sha1").update(text2).digest("base64url");
125
+ function stampProjectHfIds(projectDir) {
126
+ const recordPath = join2(projectDir, STAMP_RECORD);
127
+ let last = {};
128
+ try {
129
+ last = JSON.parse(readFileSync(recordPath, "utf-8"));
130
+ } catch {
131
+ }
132
+ const next = {};
133
+ for (const file of walkDir(projectDir)) {
134
+ if (!file.endsWith(".html") || isInHiddenOrVendorDir(file)) continue;
135
+ const absPath = join2(projectDir, file);
136
+ let html;
137
+ try {
138
+ html = readFileSync(absPath, "utf-8");
139
+ } catch {
140
+ continue;
141
+ }
142
+ let hash = contentHash(html);
143
+ if (last[file] !== hash && isCompositionSource(html)) {
144
+ const stamped = stampFileHfIds(absPath);
145
+ if (stamped === null) continue;
146
+ hash = contentHash(stamped);
147
+ }
148
+ next[file] = hash;
149
+ }
150
+ try {
151
+ mkdirSync(dirname(recordPath), { recursive: true });
152
+ writeFileSync(recordPath, JSON.stringify(next));
153
+ } catch {
154
+ }
155
+ }
156
+ function openNoFollow(filePath, flags) {
157
+ const noFollow = constants.O_NOFOLLOW ?? 0;
158
+ try {
159
+ return openSync(filePath, flags | noFollow);
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+ function stampFileHfIds(filePath) {
165
+ let fd = openNoFollow(filePath, constants.O_RDWR);
166
+ let writable = true;
167
+ if (fd === null) {
168
+ fd = openNoFollow(filePath, constants.O_RDONLY);
169
+ writable = false;
170
+ }
171
+ if (fd === null) return null;
172
+ try {
173
+ if (!fstatSync(fd).isFile()) return null;
174
+ const html = readFileSync(fd, "utf-8");
175
+ const normalized = ensureHfIds(html);
176
+ const idsBefore = (html.match(/\bdata-hf-id=/g) ?? []).length;
177
+ const idsAfter = (normalized.match(/\bdata-hf-id=/g) ?? []).length;
178
+ if (writable && idsAfter > idsBefore) {
179
+ const mode = fstatSync(fd).mode;
180
+ closeSync(fd);
181
+ fd = null;
182
+ replaceFileAtomically(filePath, normalized, mode);
183
+ }
184
+ return normalized;
185
+ } catch (err) {
186
+ console.warn("[hyperframes] stampFileHfIds: failed to stamp ids:", err);
187
+ return null;
188
+ } finally {
189
+ if (fd !== null) closeSync(fd);
190
+ }
191
+ }
192
+
193
+ // src/helpers/projectSignature.ts
194
+ import { createHash as createHash2 } from "crypto";
195
+ import { lstatSync, readFileSync as readFileSync2, readdirSync as readdirSync2 } from "fs";
92
196
  import { extname, isAbsolute, relative, resolve, sep } from "path";
93
197
  var SIGNATURE_TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
94
198
  ".cjs",
@@ -194,7 +298,7 @@ function collectProjectSignatureManifestFiles(projectDir, files) {
194
298
  }
195
299
  }
196
300
  function createProjectFingerprint(projectDir, files) {
197
- const hash = createHash("sha256");
301
+ const hash = createHash2("sha256");
198
302
  for (const entry of files) {
199
303
  hash.update(relative(projectDir, entry.file));
200
304
  hash.update("\0");
@@ -240,10 +344,10 @@ function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set(
240
344
  );
241
345
  files.sort((a, b) => a.file.localeCompare(b.file));
242
346
  const fingerprint = createProjectFingerprint(normalizedProjectDir, files);
243
- const cacheKey = excluding.size ? `${normalizedProjectDir}\0${createHash("sha256").update([...excluding].sort().join("\0")).digest("hex")}` : normalizedProjectDir;
347
+ const cacheKey = excluding.size ? `${normalizedProjectDir}\0${createHash2("sha256").update([...excluding].sort().join("\0")).digest("hex")}` : normalizedProjectDir;
244
348
  const cached = projectSignatureCache.get(cacheKey);
245
349
  if (cached?.fingerprint === fingerprint) return cached.signature;
246
- const hash = createHash("sha256");
350
+ const hash = createHash2("sha256");
247
351
  for (const entry of files) {
248
352
  const relativePath = relative(normalizedProjectDir, entry.file);
249
353
  hash.update(relativePath);
@@ -252,7 +356,7 @@ function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set(
252
356
  hash.update("\0");
253
357
  if (entry.textContentEligible) {
254
358
  try {
255
- hash.update(readFileSync(entry.file));
359
+ hash.update(readFileSync2(entry.file));
256
360
  } catch {
257
361
  hash.update(String(entry.mtimeMs));
258
362
  }
@@ -267,14 +371,13 @@ function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set(
267
371
  }
268
372
 
269
373
  // src/routes/projects.ts
270
- var COMPOSITION_ID_RE = /data-composition-id\s*=/;
271
374
  async function filterCompositionFiles(projectDir, files) {
272
375
  const htmlFiles = files.filter((f) => f.endsWith(".html") && !isInHiddenOrVendorDir(f));
273
376
  const checks = await Promise.all(
274
377
  htmlFiles.map(async (f) => {
275
378
  try {
276
- const content = await readFile(join2(projectDir, f), "utf-8");
277
- return COMPOSITION_ID_RE.test(content);
379
+ const content = await readFile(join3(projectDir, f), "utf-8");
380
+ return isCompositionSource(content);
278
381
  } catch {
279
382
  return false;
280
383
  }
@@ -313,23 +416,23 @@ function registerProjectRoutes(api, adapter) {
313
416
  // src/routes/files.ts
314
417
  import { bodyLimit } from "hono/body-limit";
315
418
  import {
316
- closeSync,
419
+ closeSync as closeSync2,
317
420
  existsSync as existsSync3,
318
421
  lstatSync as lstatSync3,
319
- openSync,
320
- readFileSync as readFileSync5,
422
+ openSync as openSync2,
423
+ readFileSync as readFileSync6,
321
424
  readlinkSync,
322
- writeFileSync as writeFileSync4,
425
+ writeFileSync as writeFileSync5,
323
426
  writeSync,
324
- mkdirSync as mkdirSync3,
427
+ mkdirSync as mkdirSync4,
325
428
  unlinkSync as unlinkSync2,
326
429
  rmSync as rmSync3,
327
430
  statSync as statSync3,
328
- fstatSync,
431
+ fstatSync as fstatSync2,
329
432
  renameSync as renameSync2,
330
433
  readdirSync as readdirSync4
331
434
  } from "fs";
332
- import { resolve as resolve3, dirname as dirname3, join as join6 } from "path";
435
+ import { resolve as resolve3, dirname as dirname4, join as join7 } from "path";
333
436
 
334
437
  // src/helpers/mime.ts
335
438
  var MIME_TYPES = {
@@ -377,37 +480,19 @@ function isAudioFile(name) {
377
480
  return (getMimeType(name) ?? "").startsWith("audio/");
378
481
  }
379
482
 
380
- // src/helpers/atomicFile.ts
381
- import * as fs from "fs";
382
- import { randomUUID } from "crypto";
383
- function replaceFileAtomically(filePath, content, mode, operations = fs) {
384
- const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
385
- try {
386
- operations.writeFileSync(tempPath, content, { encoding: "utf-8", mode });
387
- operations.chmodSync(tempPath, mode);
388
- operations.renameSync(tempPath, filePath);
389
- } catch (error) {
390
- try {
391
- operations.unlinkSync(tempPath);
392
- } catch {
393
- }
394
- throw error;
395
- }
396
- }
397
-
398
483
  // src/helpers/waveform.ts
399
484
  import { spawn } from "child_process";
400
485
  import {
401
486
  existsSync,
402
- writeFileSync,
403
- mkdirSync,
487
+ writeFileSync as writeFileSync2,
488
+ mkdirSync as mkdirSync2,
404
489
  lstatSync as lstatSync2,
405
490
  mkdtempSync,
406
491
  renameSync,
407
492
  rmSync,
408
493
  statSync
409
494
  } from "fs";
410
- import { dirname, join as join3 } from "path";
495
+ import { dirname as dirname2, join as join4 } from "path";
411
496
  import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
412
497
  var SAMPLE_RATE = 4e3;
413
498
  var PEAK_COUNT = 4e3;
@@ -474,11 +559,11 @@ function decodeAudioPeaks(audioPath) {
474
559
  });
475
560
  }
476
561
  async function generateWaveformCache(projectDir, assetPath) {
477
- const audioPath = join3(projectDir, assetPath);
562
+ const audioPath = join4(projectDir, assetPath);
478
563
  if (!existsSync(audioPath)) return;
479
564
  const stats = statSync(audioPath);
480
- const cacheDir = join3(projectDir, ".waveform-cache");
481
- const cachePath = join3(cacheDir, buildWaveformCacheKey(assetPath, stats));
565
+ const cacheDir = join4(projectDir, ".waveform-cache");
566
+ const cachePath = join4(cacheDir, buildWaveformCacheKey(assetPath, stats));
482
567
  if (isWaveformCacheDirectory(cacheDir) && existsSync(cachePath)) return;
483
568
  const peaks = await decodeAudioPeaks(audioPath);
484
569
  writeWaveformCache(cachePath, peaks);
@@ -487,19 +572,19 @@ function isWaveformCacheDirectory(cacheDir) {
487
572
  return lstatSync2(cacheDir, { throwIfNoEntry: false })?.isDirectory() ?? false;
488
573
  }
489
574
  function writeWaveformCache(cachePath, peaks) {
490
- const cacheDir = dirname(cachePath);
575
+ const cacheDir = dirname2(cachePath);
491
576
  try {
492
- mkdirSync(cacheDir, { mode: 448 });
577
+ mkdirSync2(cacheDir, { mode: 448 });
493
578
  } catch (error) {
494
579
  if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
495
580
  }
496
581
  if (!isWaveformCacheDirectory(cacheDir)) {
497
582
  throw new Error("Waveform cache must be a directory, not a symlink");
498
583
  }
499
- const stagingDir = mkdtempSync(join3(cacheDir, ".waveform-"));
584
+ const stagingDir = mkdtempSync(join4(cacheDir, ".waveform-"));
500
585
  try {
501
- const stagingPath = join3(stagingDir, "peaks.json");
502
- writeFileSync(stagingPath, JSON.stringify(peaks), { flag: "wx" });
586
+ const stagingPath = join4(stagingDir, "peaks.json");
587
+ writeFileSync2(stagingPath, JSON.stringify(peaks), { flag: "wx" });
503
588
  renameSync(stagingPath, cachePath);
504
589
  } finally {
505
590
  try {
@@ -511,9 +596,9 @@ function writeWaveformCache(cachePath, peaks) {
511
596
 
512
597
  // src/helpers/mediaValidation.ts
513
598
  import { spawnSync } from "child_process";
514
- import { mkdtempSync as mkdtempSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
599
+ import { mkdtempSync as mkdtempSync2, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
515
600
  import { tmpdir } from "os";
516
- import { basename, join as join4 } from "path";
601
+ import { basename, join as join5 } from "path";
517
602
  var VIDEO_EXT = /\.(mp4|webm|mov|mkv|avi|m4v|mxf|mts|m2ts|ts)$/i;
518
603
  var AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
519
604
  function validateUploadedMedia(filePath, runner = spawnSync) {
@@ -550,10 +635,10 @@ function validateUploadedMedia(filePath, runner = spawnSync) {
550
635
  }
551
636
  }
552
637
  function validateUploadedMediaBuffer(fileName, buffer, runner = spawnSync) {
553
- const tempDir = mkdtempSync2(join4(tmpdir(), "hyperframes-upload-"));
554
- const tempPath = join4(tempDir, basename(fileName));
638
+ const tempDir = mkdtempSync2(join5(tmpdir(), "hyperframes-upload-"));
639
+ const tempPath = join5(tempDir, basename(fileName));
555
640
  try {
556
- writeFileSync2(tempPath, buffer);
641
+ writeFileSync3(tempPath, buffer);
557
642
  return validateUploadedMedia(tempPath, runner);
558
643
  } finally {
559
644
  rmSync2(tempDir, { recursive: true, force: true });
@@ -561,9 +646,9 @@ function validateUploadedMediaBuffer(fileName, buffer, runner = spawnSync) {
561
646
  }
562
647
 
563
648
  // src/helpers/backupJournal.ts
564
- import { mkdirSync as mkdirSync2, readdirSync as readdirSync3, readFileSync as readFileSync2, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
649
+ import { mkdirSync as mkdirSync3, readdirSync as readdirSync3, readFileSync as readFileSync3, unlinkSync, writeFileSync as writeFileSync4 } from "fs";
565
650
  import { Buffer as Buffer2 } from "buffer";
566
- import { join as join5, relative as relative2 } from "path";
651
+ import { join as join6, relative as relative2 } from "path";
567
652
  var DEFAULT_KEEP_PER_FILE = 10;
568
653
  function backupKeyForPath(path) {
569
654
  return Buffer2.from(path, "utf-8").toString("base64url");
@@ -580,13 +665,13 @@ function backupPathForResponse(projectDir, backupPath) {
580
665
  function snapshotBeforeWrite(projectDir, absPath, options = {}) {
581
666
  if (!isSafePath(projectDir, absPath)) return { backupPath: null };
582
667
  try {
583
- const content = readFileSync2(absPath);
668
+ const content = readFileSync3(absPath);
584
669
  const relativePath = relative2(projectDir, absPath);
585
- const backupDir = join5(projectDir, ".hyperframes", "backup");
586
- mkdirSync2(backupDir, { recursive: true });
670
+ const backupDir = join6(projectDir, ".hyperframes", "backup");
671
+ mkdirSync3(backupDir, { recursive: true });
587
672
  const backupKey = backupKeyForPath(relativePath);
588
673
  const backupPath = nextBackupPath(backupDir, backupKey);
589
- writeFileSync3(backupPath, content);
674
+ writeFileSync4(backupPath, content);
590
675
  pruneBackups(backupDir, backupKey, options.keepPerFile ?? DEFAULT_KEEP_PER_FILE);
591
676
  return { backupPath };
592
677
  } catch (error) {
@@ -598,18 +683,18 @@ function snapshotBeforeWrite(projectDir, absPath, options = {}) {
598
683
  }
599
684
  function nextBackupPath(backupDir, backupKey) {
600
685
  const base = `${timestampPrefix()}-${backupKey}`;
601
- let candidate = join5(backupDir, base);
686
+ let candidate = join6(backupDir, base);
602
687
  let counter = 2;
603
688
  while (true) {
604
689
  try {
605
- readFileSync2(candidate);
690
+ readFileSync3(candidate);
606
691
  } catch (error) {
607
692
  if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
608
693
  return candidate;
609
694
  }
610
695
  throw error;
611
696
  }
612
- candidate = join5(backupDir, `${base}-${counter}`);
697
+ candidate = join6(backupDir, `${base}-${counter}`);
613
698
  counter += 1;
614
699
  }
615
700
  }
@@ -617,7 +702,7 @@ function pruneBackups(backupDir, backupKey, keepPerFile) {
617
702
  const keep = Math.max(1, Math.floor(keepPerFile));
618
703
  const suffix = `-${backupKey}`;
619
704
  const numberedSuffix = new RegExp(`-${backupKey}-\\d+$`);
620
- const matches = readdirSync3(backupDir).filter((name) => name.endsWith(suffix) || numberedSuffix.test(name)).map((name) => join5(backupDir, name)).sort((a, b) => {
705
+ const matches = readdirSync3(backupDir).filter((name) => name.endsWith(suffix) || numberedSuffix.test(name)).map((name) => join6(backupDir, name)).sort((a, b) => {
621
706
  return b.localeCompare(a);
622
707
  });
623
708
  for (const file of matches.slice(keep)) {
@@ -629,11 +714,18 @@ function pruneBackups(backupDir, backupKey, keepPerFile) {
629
714
  }
630
715
 
631
716
  // src/helpers/fileVersion.ts
632
- import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
717
+ import { createHash as createHash3, randomUUID as randomUUID2 } from "crypto";
633
718
  var RECEIPT_TTL_MS = 1e4;
634
719
  var receipts = /* @__PURE__ */ new Map();
635
720
  function fileContentVersion(content) {
636
- return `"sha256:${createHash2("sha256").update(content).digest("hex")}"`;
721
+ return hashVersion(createHash3("sha256").update(content).digest("hex"));
722
+ }
723
+ var DELETED_VERSION = '"deleted"';
724
+ function hashVersion(hex) {
725
+ return `"sha256:${hex}"`;
726
+ }
727
+ function hashOfVersion(version) {
728
+ return /^"sha256:([0-9a-f]{64})"$/.exec(version)?.[1];
637
729
  }
638
730
  function settledFileTag(stat2, now = Date.now()) {
639
731
  if (now - stat2.ctimeMs < 3e3) return null;
@@ -676,11 +768,11 @@ function identifyFileWrite(absPath, expectedVersion) {
676
768
  var consumeFileWriteReceipt = identifyFileWrite;
677
769
 
678
770
  // src/helpers/applyFileMutations.ts
679
- import { readFileSync as readFileSync3, statSync as statSync2 } from "fs";
771
+ import { readFileSync as readFileSync4, statSync as statSync2 } from "fs";
680
772
  function applyFileMutations(projectDir, mutations, requestToken, writeFile = (path, content) => replaceFileAtomically(path, content, statSync2(path).mode)) {
681
773
  const prepared = mutations.map((mutation) => ({
682
774
  ...mutation,
683
- before: mutation.before ?? readFileSync3(mutation.absPath, "utf-8")
775
+ before: mutation.before ?? readFileSync4(mutation.absPath, "utf-8")
684
776
  }));
685
777
  const results = [];
686
778
  const attempted = [];
@@ -709,7 +801,7 @@ function applyFileMutations(projectDir, mutations, requestToken, writeFile = (pa
709
801
  }
710
802
  }
711
803
  function applyOneMutation(projectDir, mutation, requestToken, writeFile, attempted) {
712
- const current = readFileSync3(mutation.absPath, "utf-8");
804
+ const current = readFileSync4(mutation.absPath, "utf-8");
713
805
  assertExpectedVersion(mutation.expectedVersion, current);
714
806
  if (mutation.after === mutation.before) {
715
807
  return {
@@ -781,11 +873,12 @@ import {
781
873
  syncPositionHoldsBeforeKeyframes
782
874
  } from "@hyperframes/parsers/gsap-writer-acorn";
783
875
  import { parseHTML as parseHTML2 } from "linkedom";
876
+ import { ensureHfIds as ensureHfIds2 } from "@hyperframes/parsers/hf-ids";
784
877
 
785
878
  // src/helpers/compositionInsertion.ts
786
- import { existsSync as existsSync2, readFileSync as readFileSync4, realpathSync } from "fs";
879
+ import { existsSync as existsSync2, readFileSync as readFileSync5, realpathSync } from "fs";
787
880
  import { randomUUID as randomUUID3 } from "crypto";
788
- import { dirname as dirname2, relative as relative3, resolve as resolve2, sep as sep2 } from "path";
881
+ import { dirname as dirname3, relative as relative3, resolve as resolve2, sep as sep2 } from "path";
789
882
  import { parseHTML } from "linkedom";
790
883
  var CompositionInsertionError = class extends Error {
791
884
  constructor(message, status) {
@@ -840,7 +933,7 @@ function canonicalDependency(projectDir, ownerAbs, sourcePath) {
840
933
  validateSourcePath(sourcePath);
841
934
  return canonicalProjectPath(
842
935
  projectDir,
843
- resolveWithinProject(projectDir, relative3(projectDir, resolve2(dirname2(ownerAbs), sourcePath)))
936
+ resolveWithinProject(projectDir, relative3(projectDir, resolve2(dirname3(ownerAbs), sourcePath)))
844
937
  );
845
938
  }
846
939
  function validateDependencyGraph(projectDir, targetAbs, sourceAbs) {
@@ -855,7 +948,7 @@ function validateDependencyGraph(projectDir, targetAbs, sourceAbs) {
855
948
  }
856
949
  if (visited.has(file)) return;
857
950
  visiting.add(file);
858
- const source = readFileSync4(file, "utf-8");
951
+ const source = readFileSync5(file, "utf-8");
859
952
  const { document } = compositionRoot(source);
860
953
  for (const host of descendants(document, "[data-composition-src]")) {
861
954
  const dependency = host.getAttribute("data-composition-src");
@@ -913,13 +1006,13 @@ function uniqueHostId(root, base) {
913
1006
  return `${base}_${suffix}`;
914
1007
  }
915
1008
  function relativeSourcePath(targetAbs, sourceAbs) {
916
- return relative3(dirname2(targetAbs), sourceAbs).split(sep2).join("/");
1009
+ return relative3(dirname3(targetAbs), sourceAbs).split(sep2).join("/");
917
1010
  }
918
1011
  function insertCompositionIntoSource(input) {
919
1012
  const targetAbs = canonicalProjectFile(input.projectDir, input.targetPath);
920
1013
  const sourceAbs = canonicalProjectFile(input.projectDir, input.sourcePath);
921
1014
  validateDependencyGraph(input.projectDir, targetAbs, sourceAbs);
922
- const source = readFileSync4(sourceAbs, "utf-8");
1015
+ const source = readFileSync5(sourceAbs, "utf-8");
923
1016
  const sourceComposition = compositionRoot(source).root;
924
1017
  const duration = positiveAttribute(
925
1018
  sourceComposition,
@@ -1040,11 +1133,12 @@ function requestSubPath(url, route) {
1040
1133
  }
1041
1134
 
1042
1135
  // src/routes/files.ts
1136
+ import { insertBeforeCloseTag } from "@hyperframes/core/compiler/html-document";
1043
1137
  async function loadGsapParser() {
1044
1138
  return import("@hyperframes/parsers/gsap-parser-recast");
1045
1139
  }
1046
1140
  function isDanglingSymlinkInProject(projectDir, lexicalPath) {
1047
- if (!isSafePath(projectDir, dirname3(lexicalPath))) return false;
1141
+ if (!isSafePath(projectDir, dirname4(lexicalPath))) return false;
1048
1142
  let stats;
1049
1143
  try {
1050
1144
  stats = lstatSync3(lexicalPath);
@@ -1052,7 +1146,7 @@ function isDanglingSymlinkInProject(projectDir, lexicalPath) {
1052
1146
  return false;
1053
1147
  }
1054
1148
  if (!stats.isSymbolicLink()) return false;
1055
- const target = resolve3(dirname3(lexicalPath), readlinkSync(lexicalPath));
1149
+ const target = resolve3(dirname4(lexicalPath), readlinkSync(lexicalPath));
1056
1150
  if (!isSafePath(projectDir, target)) return false;
1057
1151
  try {
1058
1152
  statSync3(lexicalPath);
@@ -1151,7 +1245,7 @@ function commitElementPatchBatches(projectDir, batches, writeFile = (path, conte
1151
1245
  resolvedPaths.add(absPath);
1152
1246
  let before;
1153
1247
  try {
1154
- before = readFileSync5(absPath, "utf-8");
1248
+ before = readFileSync6(absPath, "utf-8");
1155
1249
  } catch {
1156
1250
  return { error: "not-found", sourceFile: batch.sourceFile };
1157
1251
  }
@@ -1202,7 +1296,7 @@ function commitElementPatchBatchesWithReceipts(c, projectDir, batches) {
1202
1296
  return commitElementPatchBatches(
1203
1297
  projectDir,
1204
1298
  batches,
1205
- writeFileSync4,
1299
+ writeFileSync5,
1206
1300
  c.req.header("X-Hyperframes-Write-Token")
1207
1301
  );
1208
1302
  }
@@ -1260,8 +1354,8 @@ async function parseMutationBody(c) {
1260
1354
  return { target: body.target, body };
1261
1355
  }
1262
1356
  function ensureDir(filePath) {
1263
- const dir = dirname3(filePath);
1264
- if (!existsSync3(dir)) mkdirSync3(dir, { recursive: true });
1357
+ const dir = dirname4(filePath);
1358
+ if (!existsSync3(dir)) mkdirSync4(dir, { recursive: true });
1265
1359
  }
1266
1360
  function generateCopyPath(projectDir, originalPath) {
1267
1361
  const ext = originalPath.includes(".") ? "." + originalPath.split(".").pop() : "";
@@ -1279,7 +1373,7 @@ function generateCopyPath(projectDir, originalPath) {
1279
1373
  function walkFiles(dir, filter) {
1280
1374
  const results = [];
1281
1375
  for (const entry of readdirSync4(dir, { withFileTypes: true })) {
1282
- const full = join6(dir, entry.name);
1376
+ const full = join7(dir, entry.name);
1283
1377
  if (entry.isDirectory()) {
1284
1378
  if (entry.name === "node_modules" || entry.name === ".thumbnails" || entry.name === "renders" || entry.name === ".transcode-cache")
1285
1379
  continue;
@@ -1298,7 +1392,7 @@ function updateReferences(projectDir, oldPath, newPath) {
1298
1392
  let updatedCount = 0;
1299
1393
  for (const file of textFiles) {
1300
1394
  if (!isSafePath(projectDir, file)) continue;
1301
- const content = readFileSync5(file, "utf-8");
1395
+ const content = readFileSync6(file, "utf-8");
1302
1396
  if (!content.includes(oldPath)) continue;
1303
1397
  const updated = content.split(oldPath).join(newPath);
1304
1398
  if (updated !== content) {
@@ -1309,7 +1403,7 @@ function updateReferences(projectDir, oldPath, newPath) {
1309
1403
  return updatedCount;
1310
1404
  }
1311
1405
  function extractGsapScriptBlock(html) {
1312
- const { document } = parseHTML2(html);
1406
+ const { document } = parseHTML2(ensureHfIds2(html));
1313
1407
  const scripts = [
1314
1408
  ...document.querySelectorAll("script:not([src])"),
1315
1409
  ...Array.from(document.querySelectorAll("template")).flatMap(
@@ -1572,7 +1666,7 @@ function containsRawGsapExpression(value) {
1572
1666
  return Object.values(value).some(containsRawGsapExpression);
1573
1667
  }
1574
1668
  async function prepareGsapMutationScript(c, res, firstMutation) {
1575
- const beforeHtml = readFileSync5(res.absPath, "utf-8");
1669
+ const beforeHtml = readFileSync6(res.absPath, "utf-8");
1576
1670
  let html = beforeHtml;
1577
1671
  let block = extractGsapScriptBlock(html);
1578
1672
  if (!block && (firstMutation.type === "add" || firstMutation.type === "add-with-keyframes")) {
@@ -1586,8 +1680,8 @@ async function prepareGsapMutationScript(c, res, firstMutation) {
1586
1680
  `window.__timelines["${compId}"] = tl;`,
1587
1681
  "</script>"
1588
1682
  ].join("\n");
1589
- html = html.includes("</body>") ? html.replace("</body>", `${bootstrap}
1590
- </body>`) : `${html}
1683
+ html = insertBeforeCloseTag(html, "body", `${bootstrap}
1684
+ `) ?? `${html}
1591
1685
  ${bootstrap}`;
1592
1686
  block = extractGsapScriptBlock(html);
1593
1687
  }
@@ -1639,7 +1733,7 @@ async function applyGsapMutations(c, res, mutations) {
1639
1733
  const changed = block.scriptText !== initialScript;
1640
1734
  const newHtml = changed ? block.replaceScript(block.scriptText) : html;
1641
1735
  let backupPath = null;
1642
- if (readFileSync5(res.absPath, "utf-8") !== beforeHtml) {
1736
+ if (readFileSync6(res.absPath, "utf-8") !== beforeHtml) {
1643
1737
  return c.json({ error: "file changed during GSAP mutation", conflict: true }, 409);
1644
1738
  }
1645
1739
  if (changed) {
@@ -2420,11 +2514,11 @@ async function processUploadedFiles(formData, targetDir, projectDir) {
2420
2514
  let written = false;
2421
2515
  while (n < MAX_COPY_INDEX && isSafePath(projectDir, finalPath)) {
2422
2516
  try {
2423
- const fd = openSync(finalPath, "wx");
2517
+ const fd = openSync2(finalPath, "wx");
2424
2518
  try {
2425
- writeFileSync4(fd, buffer);
2519
+ writeFileSync5(fd, buffer);
2426
2520
  } finally {
2427
- closeSync(fd);
2521
+ closeSync2(fd);
2428
2522
  }
2429
2523
  written = true;
2430
2524
  break;
@@ -2441,7 +2535,7 @@ async function processUploadedFiles(formData, targetDir, projectDir) {
2441
2535
  if (n >= MAX_COPY_INDEX) skipped.push(name);
2442
2536
  continue;
2443
2537
  }
2444
- const relativePath = subDir ? join6(subDir, finalName) : finalName;
2538
+ const relativePath = subDir ? join7(subDir, finalName) : finalName;
2445
2539
  uploaded.push(relativePath);
2446
2540
  if (isAudioFile(finalName)) {
2447
2541
  generateWaveformCache(projectDir, relativePath).catch(() => {
@@ -2456,7 +2550,7 @@ function registerFileRoutes(api, adapter) {
2456
2550
  if ("error" in res) return res.error;
2457
2551
  let fd;
2458
2552
  try {
2459
- fd = openSync(res.absPath, "r");
2553
+ fd = openSync2(res.absPath, "r");
2460
2554
  } catch {
2461
2555
  if (c.req.query("optional") === "1") {
2462
2556
  return c.json({ filename: res.filePath, content: "", missing: true });
@@ -2464,10 +2558,10 @@ function registerFileRoutes(api, adapter) {
2464
2558
  return c.json({ error: "not found" }, 404);
2465
2559
  }
2466
2560
  try {
2467
- if (!fstatSync(fd).isFile()) {
2561
+ if (!fstatSync2(fd).isFile()) {
2468
2562
  return c.json({ error: "not found", why: "not_a_file" }, 404);
2469
2563
  }
2470
- const content = readFileSync5(fd);
2564
+ const content = readFileSync6(fd);
2471
2565
  const version = fileContentVersion(content);
2472
2566
  c.header("ETag", version);
2473
2567
  return c.json({
@@ -2477,7 +2571,7 @@ function registerFileRoutes(api, adapter) {
2477
2571
  missing: false
2478
2572
  });
2479
2573
  } finally {
2480
- closeSync(fd);
2574
+ closeSync2(fd);
2481
2575
  }
2482
2576
  });
2483
2577
  api.put("/projects/:id/files/*", async (c) => {
@@ -2489,7 +2583,7 @@ function registerFileRoutes(api, adapter) {
2489
2583
  if (expectedVersion === null && !createOnly) {
2490
2584
  let currentContent = null;
2491
2585
  try {
2492
- currentContent = readFileSync5(res.absPath);
2586
+ currentContent = readFileSync6(res.absPath);
2493
2587
  } catch (error) {
2494
2588
  if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
2495
2589
  throw error;
@@ -2510,12 +2604,12 @@ function registerFileRoutes(api, adapter) {
2510
2604
  ensureDir(res.absPath);
2511
2605
  let fd;
2512
2606
  try {
2513
- fd = openSync(res.absPath, "wx");
2607
+ fd = openSync2(res.absPath, "wx");
2514
2608
  } catch (error) {
2515
2609
  if (!error || typeof error !== "object" || !("code" in error) || error.code !== "EEXIST") {
2516
2610
  throw error;
2517
2611
  }
2518
- const currentContent = readFileSync5(res.absPath);
2612
+ const currentContent = readFileSync6(res.absPath);
2519
2613
  return c.json(
2520
2614
  {
2521
2615
  error: "file conflict",
@@ -2529,12 +2623,12 @@ function registerFileRoutes(api, adapter) {
2529
2623
  try {
2530
2624
  writeSync(fd, body, 0, body.length, 0);
2531
2625
  } finally {
2532
- closeSync(fd);
2626
+ closeSync2(fd);
2533
2627
  }
2534
2628
  } else {
2535
2629
  let fd;
2536
2630
  try {
2537
- fd = openSync(res.absPath, "r+");
2631
+ fd = openSync2(res.absPath, "r+");
2538
2632
  } catch (error) {
2539
2633
  if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
2540
2634
  throw error;
@@ -2550,7 +2644,7 @@ function registerFileRoutes(api, adapter) {
2550
2644
  );
2551
2645
  }
2552
2646
  try {
2553
- const currentContent = readFileSync5(fd);
2647
+ const currentContent = readFileSync6(fd);
2554
2648
  const currentVersion = fileContentVersion(currentContent);
2555
2649
  if (expectedVersion !== currentVersion) {
2556
2650
  return c.json(
@@ -2565,12 +2659,12 @@ function registerFileRoutes(api, adapter) {
2565
2659
  }
2566
2660
  backup = snapshotBeforeWrite(res.project.dir, res.absPath);
2567
2661
  if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
2568
- const mode = fstatSync(fd).mode;
2569
- closeSync(fd);
2662
+ const mode = fstatSync2(fd).mode;
2663
+ closeSync2(fd);
2570
2664
  fd = null;
2571
2665
  replaceFileAtomically(res.absPath, body, mode);
2572
2666
  } finally {
2573
- if (fd !== null) closeSync(fd);
2667
+ if (fd !== null) closeSync2(fd);
2574
2668
  }
2575
2669
  }
2576
2670
  const version = fileContentVersion(body);
@@ -2591,7 +2685,7 @@ function registerFileRoutes(api, adapter) {
2591
2685
  ensureDir(res.absPath);
2592
2686
  const body = Buffer.from(await c.req.arrayBuffer());
2593
2687
  try {
2594
- writeFileSync4(res.absPath, body, { flag: "wx" });
2688
+ writeFileSync5(res.absPath, body, { flag: "wx" });
2595
2689
  } catch (error) {
2596
2690
  if (!error || typeof error !== "object" || !("code" in error) || error.code !== "EEXIST") {
2597
2691
  throw error;
@@ -2625,7 +2719,7 @@ function registerFileRoutes(api, adapter) {
2625
2719
  }
2626
2720
  let before;
2627
2721
  try {
2628
- before = readFileSync5(ctx.absPath, "utf-8");
2722
+ before = readFileSync6(ctx.absPath, "utf-8");
2629
2723
  } catch (error) {
2630
2724
  if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
2631
2725
  throw error;
@@ -2682,7 +2776,7 @@ function registerFileRoutes(api, adapter) {
2682
2776
  }
2683
2777
  const parsed = await parseMutationBody(c);
2684
2778
  if ("error" in parsed) return parsed.error;
2685
- const originalContent = readFileSync5(ctx.absPath, "utf-8");
2779
+ const originalContent = readFileSync6(ctx.absPath, "utf-8");
2686
2780
  return writeIfChanged(
2687
2781
  c,
2688
2782
  ctx.project.dir,
@@ -2703,7 +2797,7 @@ function registerFileRoutes(api, adapter) {
2703
2797
  if (!Array.isArray(targets) || targets.length === 0) {
2704
2798
  return c.json({ error: "targets required" }, 400);
2705
2799
  }
2706
- const originalContent = readFileSync5(ctx.absPath, "utf-8");
2800
+ const originalContent = readFileSync6(ctx.absPath, "utf-8");
2707
2801
  let next = originalContent;
2708
2802
  for (const target of targets) {
2709
2803
  next = removeElementFromHtml(next, target);
@@ -2736,7 +2830,7 @@ function registerFileRoutes(api, adapter) {
2736
2830
  seen.add(absPath);
2737
2831
  let before;
2738
2832
  try {
2739
- before = readFileSync5(absPath, "utf-8");
2833
+ before = readFileSync6(absPath, "utf-8");
2740
2834
  } catch {
2741
2835
  return c.json({ error: `not found: ${file.path}` }, 404);
2742
2836
  }
@@ -2763,7 +2857,7 @@ function registerFileRoutes(api, adapter) {
2763
2857
  prepared.push(folded);
2764
2858
  }
2765
2859
  for (const file of prepared) {
2766
- const current = readFileSync5(file.absPath, "utf-8");
2860
+ const current = readFileSync6(file.absPath, "utf-8");
2767
2861
  if (current !== file.before) {
2768
2862
  return c.json(
2769
2863
  {
@@ -2805,7 +2899,7 @@ function registerFileRoutes(api, adapter) {
2805
2899
  const conflicts = [];
2806
2900
  for (const file of written.reverse()) {
2807
2901
  try {
2808
- const current = readFileSync5(file.absPath, "utf-8");
2902
+ const current = readFileSync6(file.absPath, "utf-8");
2809
2903
  if (current !== file.after) {
2810
2904
  conflicts.push(file.path);
2811
2905
  continue;
@@ -2853,7 +2947,7 @@ function registerFileRoutes(api, adapter) {
2853
2947
  const fallbackTiming = typeof parsed.body.elementStart === "number" && typeof parsed.body.elementDuration === "number" ? { start: parsed.body.elementStart, duration: parsed.body.elementDuration } : void 0;
2854
2948
  let originalContent;
2855
2949
  try {
2856
- originalContent = readFileSync5(ctx.absPath, "utf-8");
2950
+ originalContent = readFileSync6(ctx.absPath, "utf-8");
2857
2951
  } catch {
2858
2952
  return c.json({ error: "not found" }, 404);
2859
2953
  }
@@ -2909,7 +3003,7 @@ function registerFileRoutes(api, adapter) {
2909
3003
  }
2910
3004
  let originalContent;
2911
3005
  try {
2912
- originalContent = readFileSync5(ctx.absPath, "utf-8");
3006
+ originalContent = readFileSync6(ctx.absPath, "utf-8");
2913
3007
  } catch {
2914
3008
  return c.json({ error: "not found" }, 404);
2915
3009
  }
@@ -3010,7 +3104,7 @@ function registerFileRoutes(api, adapter) {
3010
3104
  }
3011
3105
  let originalContent;
3012
3106
  try {
3013
- originalContent = readFileSync5(ctx.absPath, "utf-8");
3107
+ originalContent = readFileSync6(ctx.absPath, "utf-8");
3014
3108
  } catch {
3015
3109
  return c.json({ error: "not found" }, 404);
3016
3110
  }
@@ -3065,7 +3159,7 @@ function registerFileRoutes(api, adapter) {
3065
3159
  ).map((entry) => ({ target: entry.target, track: entry.track }));
3066
3160
  let originalContent;
3067
3161
  try {
3068
- originalContent = readFileSync5(ctx.absPath, "utf-8");
3162
+ originalContent = readFileSync6(ctx.absPath, "utf-8");
3069
3163
  } catch {
3070
3164
  return c.json({ error: "not found" }, 404);
3071
3165
  }
@@ -3094,7 +3188,7 @@ function registerFileRoutes(api, adapter) {
3094
3188
  if ("error" in parsed) return parsed.error;
3095
3189
  let content;
3096
3190
  try {
3097
- content = readFileSync5(ctx.absPath, "utf-8");
3191
+ content = readFileSync6(ctx.absPath, "utf-8");
3098
3192
  } catch {
3099
3193
  return c.json({ exists: false });
3100
3194
  }
@@ -3137,7 +3231,7 @@ function registerFileRoutes(api, adapter) {
3137
3231
  return c.json({ error: "forbidden" }, 403);
3138
3232
  }
3139
3233
  ensureDir(destAbs);
3140
- writeFileSync4(destAbs, readFileSync5(srcAbs));
3234
+ writeFileSync5(destAbs, readFileSync6(srcAbs));
3141
3235
  return c.json({ ok: true, path: copyPath }, 201);
3142
3236
  });
3143
3237
  const MAX_UPLOAD_BYTES = 500 * 1024 * 1024;
@@ -3153,7 +3247,7 @@ function registerFileRoutes(api, adapter) {
3153
3247
  const subDir = c.req.query("dir") ?? "";
3154
3248
  const targetDir = subDir ? resolveWithinProject(project.dir, subDir) : project.dir;
3155
3249
  if (!targetDir) return c.json({ error: "forbidden" }, 403);
3156
- if (subDir && !existsSync3(targetDir)) mkdirSync3(targetDir, { recursive: true });
3250
+ if (subDir && !existsSync3(targetDir)) mkdirSync4(targetDir, { recursive: true });
3157
3251
  const formData = await c.req.formData();
3158
3252
  const result = await processUploadedFiles(formData, targetDir, project.dir);
3159
3253
  return c.json(
@@ -3167,7 +3261,7 @@ function registerFileRoutes(api, adapter) {
3167
3261
  mustExist: true
3168
3262
  });
3169
3263
  if ("error" in res) return res.error;
3170
- const html = readFileSync5(res.absPath, "utf-8");
3264
+ const html = readFileSync6(res.absPath, "utf-8");
3171
3265
  const block = extractGsapScriptBlock(html);
3172
3266
  if (!block) {
3173
3267
  return c.json({
@@ -3216,7 +3310,7 @@ function registerFileRoutes(api, adapter) {
3216
3310
  if (!body || typeof body.expected !== "string" || typeof body.restore !== "string") {
3217
3311
  return c.json({ error: "expected and restore contents required" }, 400);
3218
3312
  }
3219
- const current = readFileSync5(res.absPath, "utf-8");
3313
+ const current = readFileSync6(res.absPath, "utf-8");
3220
3314
  if (current !== body.expected) {
3221
3315
  return c.json({ ok: true, restored: false, conflict: true });
3222
3316
  }
@@ -3226,19 +3320,20 @@ function registerFileRoutes(api, adapter) {
3226
3320
  }
3227
3321
 
3228
3322
  // src/routes/preview.ts
3229
- import { createReadStream, existsSync as existsSync5, readFileSync as readFileSync8, statSync as statSync5 } from "fs";
3323
+ import { createReadStream, existsSync as existsSync5, readFileSync as readFileSync8, statSync as statSync4 } from "fs";
3230
3324
  import { Readable } from "stream";
3231
- import { join as join8, resolve as resolve4 } from "path";
3232
- import { createHash as createHash3 } from "crypto";
3325
+ import { join as join9, resolve as resolve4 } from "path";
3326
+ import { createHash as createHash4 } from "crypto";
3233
3327
  import {
3234
3328
  injectScriptsIntoHtml,
3329
+ insertBeforeCloseTag as insertBeforeCloseTag2,
3235
3330
  stripEmbeddedRuntimeScripts as stripEmbeddedRuntimeScripts2
3236
3331
  } from "@hyperframes/core/compiler";
3237
3332
  import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
3238
3333
 
3239
3334
  // src/helpers/subComposition.ts
3240
- import { existsSync as existsSync4, readFileSync as readFileSync6 } from "fs";
3241
- import { join as join7 } from "path";
3335
+ import { existsSync as existsSync4, readFileSync as readFileSync7 } from "fs";
3336
+ import { join as join8 } from "path";
3242
3337
  import { parseHTML as parseHTML3 } from "linkedom";
3243
3338
  import {
3244
3339
  rewriteAssetPaths,
@@ -3250,7 +3345,7 @@ function isFullHtmlDocument(html) {
3250
3345
  return /^\s*(?:<!doctype\s|<html[\s>])/i.test(html);
3251
3346
  }
3252
3347
  function rewriteRelativePaths(root, compPath, projectDir) {
3253
- const assetExists = (path) => existsSync4(join7(projectDir, path));
3348
+ const assetExists = (path) => existsSync4(join8(projectDir, path));
3254
3349
  rewriteAssetPaths(
3255
3350
  root.querySelectorAll("[src], [href]"),
3256
3351
  compPath,
@@ -3347,9 +3442,9 @@ function tagRootCompositionFile(bodyHtml, compPath) {
3347
3442
  return bodyHtml.slice(0, tagEnd) + ` data-composition-file="${compPath}"` + bodyHtml.slice(tagEnd);
3348
3443
  }
3349
3444
  function buildSubCompositionHtml(projectDir, compPath, runtimeUrl, baseHref, rawOverride) {
3350
- const compFile = join7(projectDir, compPath);
3445
+ const compFile = join8(projectDir, compPath);
3351
3446
  if (!existsSync4(compFile)) return null;
3352
- const rawComp = rawOverride ?? readFileSync6(compFile, "utf-8");
3447
+ const rawComp = rawOverride ?? readFileSync7(compFile, "utf-8");
3353
3448
  let compHeadContent = "";
3354
3449
  let rewrittenContent;
3355
3450
  let htmlAttrs = "";
@@ -3379,10 +3474,10 @@ function buildSubCompositionHtml(projectDir, compPath, runtimeUrl, baseHref, raw
3379
3474
  }
3380
3475
  rewrittenContent = stripEmbeddedRuntimeScripts(rewrittenContent);
3381
3476
  rewrittenContent = tagRootCompositionFile(rewrittenContent, compPath);
3382
- const indexPath = join7(projectDir, "index.html");
3477
+ const indexPath = join8(projectDir, "index.html");
3383
3478
  let headContent = "";
3384
3479
  if (existsSync4(indexPath)) {
3385
- const indexHtml = readFileSync6(indexPath, "utf-8");
3480
+ const indexHtml = readFileSync7(indexPath, "utf-8");
3386
3481
  const headMatch = indexHtml.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
3387
3482
  headContent = headMatch?.[1] ?? "";
3388
3483
  }
@@ -3419,63 +3514,7 @@ function hasBaseElement(html) {
3419
3514
  }
3420
3515
 
3421
3516
  // src/routes/preview.ts
3422
- import { ensureHfIds as ensureHfIds2 } from "@hyperframes/parsers/hf-ids";
3423
-
3424
- // src/helpers/hfIdPersist.ts
3425
- import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
3426
- import { closeSync as closeSync2, constants, fstatSync as fstatSync2, openSync as openSync2, readFileSync as readFileSync7, statSync as statSync4 } from "fs";
3427
- function persistHfIdsIfNeeded(filePath, html) {
3428
- const normalized = ensureHfIds(html);
3429
- const idsBefore = (html.match(/\bdata-hf-id=/g) ?? []).length;
3430
- const idsAfter = (normalized.match(/\bdata-hf-id=/g) ?? []).length;
3431
- if (idsAfter > idsBefore) {
3432
- try {
3433
- const current = readFileSync7(filePath, "utf-8");
3434
- if (current === html) {
3435
- replaceFileAtomically(filePath, normalized, statSync4(filePath).mode);
3436
- }
3437
- } catch (err) {
3438
- console.warn("[hyperframes] persistHfIdsIfNeeded: failed to write ids to disk:", err);
3439
- }
3440
- }
3441
- return normalized;
3442
- }
3443
- function openNoFollow(filePath, flags) {
3444
- const noFollow = constants.O_NOFOLLOW ?? 0;
3445
- try {
3446
- return openSync2(filePath, flags | noFollow);
3447
- } catch {
3448
- return null;
3449
- }
3450
- }
3451
- function stampFileHfIds(filePath) {
3452
- let fd = openNoFollow(filePath, constants.O_RDWR);
3453
- let writable = true;
3454
- if (fd === null) {
3455
- fd = openNoFollow(filePath, constants.O_RDONLY);
3456
- writable = false;
3457
- }
3458
- if (fd === null) return null;
3459
- try {
3460
- if (!fstatSync2(fd).isFile()) return null;
3461
- const html = readFileSync7(fd, "utf-8");
3462
- const normalized = ensureHfIds(html);
3463
- const idsBefore = (html.match(/\bdata-hf-id=/g) ?? []).length;
3464
- const idsAfter = (normalized.match(/\bdata-hf-id=/g) ?? []).length;
3465
- if (writable && idsAfter > idsBefore) {
3466
- const mode = fstatSync2(fd).mode;
3467
- closeSync2(fd);
3468
- fd = null;
3469
- replaceFileAtomically(filePath, normalized, mode);
3470
- }
3471
- return normalized;
3472
- } catch (err) {
3473
- console.warn("[hyperframes] stampFileHfIds: failed to stamp ids:", err);
3474
- return null;
3475
- } finally {
3476
- if (fd !== null) closeSync2(fd);
3477
- }
3478
- }
3517
+ import { ensureHfIds as ensureHfIds3 } from "@hyperframes/parsers/hf-ids";
3479
3518
 
3480
3519
  // src/helpers/variablesPayload.ts
3481
3520
  var VARIABLES_PAYLOAD_ERROR = "variables must be a JSON object of {variableId: value}";
@@ -3513,13 +3552,12 @@ function injectProjectSignature(html, signature) {
3513
3552
  tag
3514
3553
  );
3515
3554
  }
3516
- if (html.includes("</head>")) return html.replace("</head>", `${tag}
3517
- </head>`);
3518
- return `${tag}
3555
+ return insertBeforeCloseTag2(html, "head", `${tag}
3556
+ `) ?? `${tag}
3519
3557
  ${html}`;
3520
3558
  }
3521
3559
  function readStudioMotionManifestContent(projectDir) {
3522
- const manifestPath = join8(projectDir, STUDIO_MOTION_PATH);
3560
+ const manifestPath = join9(projectDir, STUDIO_MOTION_PATH);
3523
3561
  if (!existsSync5(manifestPath)) return "";
3524
3562
  try {
3525
3563
  return readFileSync8(manifestPath, "utf-8");
@@ -3540,9 +3578,8 @@ function parseStudioMotionManifestContent(content) {
3540
3578
  }
3541
3579
  }
3542
3580
  function injectScriptTagIntoHead(html, scriptTag) {
3543
- if (html.includes("</head>")) return html.replace("</head>", `${scriptTag}
3544
- </head>`);
3545
- return `${scriptTag}
3581
+ return insertBeforeCloseTag2(html, "head", `${scriptTag}
3582
+ `) ?? `${scriptTag}
3546
3583
  ${html}`;
3547
3584
  }
3548
3585
  function htmlHasGsap(html) {
@@ -3633,7 +3670,7 @@ function parsePreviewVariablesParam(raw) {
3633
3670
  }
3634
3671
  function variablesEtagSalt(raw) {
3635
3672
  if (!raw) return "";
3636
- return `:vars:${createHash3("sha1").update(raw).digest("hex").slice(0, 12)}`;
3673
+ return `:vars:${createHash4("sha1").update(raw).digest("hex").slice(0, 12)}`;
3637
3674
  }
3638
3675
  function previewVariablesFromRequest(rawVariables) {
3639
3676
  const parse = parsePreviewVariablesParam(rawVariables);
@@ -3665,14 +3702,14 @@ async function transformPreviewHtml(html, adapter, project, activeCompositionPat
3665
3702
  }
3666
3703
  }
3667
3704
  function resolveProjectMainHtml(projectDir, projectId) {
3668
- const indexPath = join8(projectDir, "index.html");
3705
+ const indexPath = join9(projectDir, "index.html");
3669
3706
  if (existsSync5(indexPath)) {
3670
3707
  return {
3671
3708
  html: readFileSync8(indexPath, "utf-8"),
3672
3709
  compositionPath: "index.html"
3673
3710
  };
3674
3711
  }
3675
- const blockHtmlPath = join8(projectDir, `${projectId}.html`);
3712
+ const blockHtmlPath = join9(projectDir, `${projectId}.html`);
3676
3713
  if (existsSync5(blockHtmlPath)) {
3677
3714
  return {
3678
3715
  html: readFileSync8(blockHtmlPath, "utf-8"),
@@ -3683,7 +3720,8 @@ function resolveProjectMainHtml(projectDir, projectId) {
3683
3720
  }
3684
3721
  var PREVIEW_BUNDLE_OPTIONS = {
3685
3722
  runtime: "placeholder",
3686
- inlineAssets: false
3723
+ inlineAssets: false,
3724
+ staticGuard: false
3687
3725
  };
3688
3726
  function registerPreviewRoutes(api, adapter) {
3689
3727
  const previewCacheHeaders = (etag) => ({
@@ -3691,35 +3729,28 @@ function registerPreviewRoutes(api, adapter) {
3691
3729
  ETag: etag
3692
3730
  });
3693
3731
  const mediaCodecProbeCache = resolvePreviewMediaCodecProbeCache(adapter);
3694
- api.get("/projects/:id/preview", async (c) => {
3695
- const resolved = await resolveProjectAndSignature(adapter, c.req.param("id"));
3696
- if (!resolved) return c.json({ error: "not found" }, 404);
3697
- const { project, signature } = resolved;
3698
- const vars = previewVariablesFromRequest(c.req.query("variables"));
3699
- if (vars.error !== void 0) return c.json({ error: vars.error }, 400);
3700
- const previewVariables = vars.values;
3701
- const etag = `"preview:${signature}${variablesEtagSalt(vars.raw)}"`;
3702
- const ifNoneMatch = c.req.header("If-None-Match");
3703
- if (ifNoneMatch === etag) {
3704
- return new Response(null, {
3705
- status: 304,
3706
- headers: previewCacheHeaders(etag)
3707
- });
3708
- }
3732
+ const builtPreviews = /* @__PURE__ */ new Map();
3733
+ const rememberPreview = (key, html) => {
3734
+ builtPreviews.delete(key);
3735
+ builtPreviews.set(key, html);
3736
+ if (builtPreviews.size > 4) builtPreviews.delete(builtPreviews.keys().next().value);
3737
+ };
3738
+ const previewBuilds = /* @__PURE__ */ new Map();
3739
+ async function buildPreview(project, previewVariables, builtKey) {
3709
3740
  const diskMain = resolveProjectMainHtml(project.dir, project.id);
3710
- const normalizedDisk = diskMain ? persistHfIdsIfNeeded(join8(project.dir, diskMain.compositionPath), diskMain.html) : null;
3741
+ const normalizedDisk = diskMain ? ensureHfIds3(diskMain.html) : null;
3711
3742
  try {
3712
- let bundled = await adapter.bundle(project.dir);
3743
+ let bundled = await adapter.bundle(project.dir, { stampHfIds: true });
3713
3744
  let mainCompositionPath = "index.html";
3714
3745
  if (!bundled) {
3715
- if (!diskMain) return c.text("not found", 404);
3746
+ if (!diskMain) return null;
3716
3747
  bundled = stripEmbeddedRuntimeScripts2(normalizedDisk ?? diskMain.html);
3717
3748
  mainCompositionPath = diskMain.compositionPath;
3718
3749
  }
3719
3750
  if (!bundled.includes("hyperframe.runtime") && !bundled.includes("hyperframes-preview-runtime")) {
3720
3751
  const runtimeTag = `<script src="${adapter.runtimeUrl}"></script>`;
3721
- bundled = bundled.includes("</body>") ? bundled.replace("</body>", `${runtimeTag}
3722
- </body>`) : bundled + `
3752
+ bundled = insertBeforeCloseTag2(bundled, "body", `${runtimeTag}
3753
+ `) ?? `${bundled}
3723
3754
  ${runtimeTag}`;
3724
3755
  }
3725
3756
  const baseHref = `/api/projects/${project.id}/preview/`;
@@ -3727,7 +3758,7 @@ ${runtimeTag}`;
3727
3758
  bundled = bundled.replace(/<head>/i, `<head><base href="${baseHref}">`);
3728
3759
  }
3729
3760
  bundled = injectStudioPreviewAugmentations(
3730
- ensureHfIds2(await transformPreviewHtml(bundled, adapter, project, mainCompositionPath)),
3761
+ ensureHfIds3(await transformPreviewHtml(bundled, adapter, project, mainCompositionPath)),
3731
3762
  adapter,
3732
3763
  project.dir,
3733
3764
  mainCompositionPath
@@ -3740,14 +3771,13 @@ ${runtimeTag}`;
3740
3771
  mainCompositionPath,
3741
3772
  mediaCodecProbeCache
3742
3773
  );
3743
- return c.html(bundled, 200, previewCacheHeaders(etag));
3774
+ rememberPreview(builtKey, bundled);
3775
+ adapter.previewDocuments?.write(builtKey, bundled);
3776
+ return bundled;
3744
3777
  } catch {
3745
3778
  const fallback = resolveProjectMainHtml(project.dir, project.id);
3746
3779
  if (fallback) {
3747
- const fallbackHtml = persistHfIdsIfNeeded(
3748
- join8(project.dir, fallback.compositionPath),
3749
- fallback.html
3750
- );
3780
+ const fallbackHtml = ensureHfIds3(fallback.html);
3751
3781
  let fallbackAugmented = injectStudioPreviewAugmentations(
3752
3782
  await transformPreviewHtml(fallbackHtml, adapter, project, fallback.compositionPath),
3753
3783
  adapter,
@@ -3764,14 +3794,51 @@ ${runtimeTag}`;
3764
3794
  fallback.compositionPath,
3765
3795
  mediaCodecProbeCache
3766
3796
  );
3767
- return c.html(fallbackAugmented, 200, previewCacheHeaders(etag));
3797
+ return fallbackAugmented;
3768
3798
  }
3769
- return c.text("not found", 404);
3799
+ return null;
3800
+ }
3801
+ }
3802
+ api.get("/projects/:id/preview", async (c) => {
3803
+ const resolved = await resolveProjectAndSignature(adapter, c.req.param("id"));
3804
+ if (!resolved) return c.json({ error: "not found" }, 404);
3805
+ const { project, signature } = resolved;
3806
+ const vars = previewVariablesFromRequest(c.req.query("variables"));
3807
+ if (vars.error !== void 0) return c.json({ error: vars.error }, 400);
3808
+ const previewVariables = vars.values;
3809
+ const etag = `"preview:${signature}${variablesEtagSalt(vars.raw)}"`;
3810
+ const ifNoneMatch = c.req.header("If-None-Match");
3811
+ if (ifNoneMatch === etag) {
3812
+ return new Response(null, {
3813
+ status: 304,
3814
+ headers: previewCacheHeaders(etag)
3815
+ });
3770
3816
  }
3817
+ const builtKey = `${project.id}
3818
+ ${etag}`;
3819
+ const cached = builtPreviews.get(builtKey) ?? adapter.previewDocuments?.read(builtKey);
3820
+ if (cached) {
3821
+ rememberPreview(builtKey, cached);
3822
+ return c.html(cached, 200, previewCacheHeaders(etag));
3823
+ }
3824
+ let pending = previewBuilds.get(builtKey);
3825
+ if (!pending) {
3826
+ pending = buildPreview(project, previewVariables, builtKey).finally(
3827
+ () => previewBuilds.delete(builtKey)
3828
+ );
3829
+ previewBuilds.set(builtKey, pending);
3830
+ }
3831
+ const html = await pending;
3832
+ if (!html) return c.text("not found", 404);
3833
+ return c.html(html, 200, previewCacheHeaders(etag));
3771
3834
  });
3772
3835
  function pinSubCompHfIds(compFile, compPath) {
3773
3836
  if (!/\.html?$/i.test(compPath)) return void 0;
3774
- return stampFileHfIds(compFile);
3837
+ try {
3838
+ return ensureHfIds3(readFileSync8(compFile, "utf-8"));
3839
+ } catch {
3840
+ return null;
3841
+ }
3775
3842
  }
3776
3843
  api.get("/projects/:id/preview/comp/*", async (c) => {
3777
3844
  const resolved = await resolveProjectAndSignature(adapter, c.req.param("id"));
@@ -3782,10 +3849,10 @@ ${runtimeTag}`;
3782
3849
  const previewVariables = vars.values;
3783
3850
  const compPath = requestSubPath(c.req.url, "projects/:id/preview/comp");
3784
3851
  const compFile = resolveWithinProject(project.dir, compPath);
3785
- if (!compFile || !existsSync5(compFile) || !statSync5(compFile).isFile()) {
3852
+ if (!compFile || !existsSync5(compFile) || !statSync4(compFile).isFile()) {
3786
3853
  return c.text("not found", 404);
3787
3854
  }
3788
- const compPathHash = createHash3("sha1").update(compPath).digest("hex");
3855
+ const compPathHash = createHash4("sha1").update(compPath).digest("hex");
3789
3856
  const etag = `"comp:v2:${compPathHash}:${signature}${variablesEtagSalt(vars.raw)}"`;
3790
3857
  const ifNoneMatch = c.req.header("If-None-Match");
3791
3858
  if (ifNoneMatch === etag) {
@@ -3805,7 +3872,7 @@ ${runtimeTag}`;
3805
3872
  stamped
3806
3873
  );
3807
3874
  if (!html) return c.text("not found", 404);
3808
- html = ensureHfIds2(await transformPreviewHtml(html, adapter, project, compPath));
3875
+ html = ensureHfIds3(await transformPreviewHtml(html, adapter, project, compPath));
3809
3876
  html = injectStudioPreviewAugmentations(html, adapter, project.dir, compPath);
3810
3877
  if (previewVariables) html = injectPreviewVariables(html, previewVariables);
3811
3878
  html = await injectMediaCodecMap(html, adapter, project.dir, compPath, mediaCodecProbeCache);
@@ -3820,7 +3887,7 @@ ${runtimeTag}`;
3820
3887
  if (!file) {
3821
3888
  return c.text("not found", 404);
3822
3889
  }
3823
- const stat2 = existsSync5(file) ? statSync5(file) : null;
3890
+ const stat2 = existsSync5(file) ? statSync4(file) : null;
3824
3891
  if (!stat2?.isFile()) {
3825
3892
  return c.text("not found", 404);
3826
3893
  }
@@ -3868,7 +3935,7 @@ ${runtimeTag}`;
3868
3935
  servedContentType = PROXY_VARIANT_CONFIG[proxyVariant].contentType;
3869
3936
  }
3870
3937
  const textBuffer = isText ? Buffer.from(readFileSync8(file, "utf-8"), "utf-8") : null;
3871
- const totalSize = textBuffer ? textBuffer.length : statSync5(servedPath).size;
3938
+ const totalSize = textBuffer ? textBuffer.length : statSync4(servedPath).size;
3872
3939
  const bodyFor = (start, end) => textBuffer ? new Uint8Array(textBuffer.subarray(start, end + 1)) : (
3873
3940
  // Node's web stream type and the DOM one do not overlap for tsc on
3874
3941
  // every platform's lib set; the double cast is the documented bridge.
@@ -3912,7 +3979,7 @@ ${runtimeTag}`;
3912
3979
 
3913
3980
  // src/routes/lint.ts
3914
3981
  import { readFileSync as readFileSync9 } from "fs";
3915
- import { join as join9 } from "path";
3982
+ import { join as join10 } from "path";
3916
3983
  async function collectProjectFindings(adapter, projectDir) {
3917
3984
  const findings = [];
3918
3985
  const coveredFiles = /* @__PURE__ */ new Set();
@@ -3930,7 +3997,7 @@ async function lintUncoveredHtml(adapter, projectDir, htmlFiles, coveredFiles) {
3930
3997
  const findings = [];
3931
3998
  for (const file of htmlFiles) {
3932
3999
  if (coveredFiles.has(file)) continue;
3933
- const content = readFileSync9(join9(projectDir, file), "utf-8");
4000
+ const content = readFileSync9(join10(projectDir, file), "utf-8");
3934
4001
  const result = await adapter.lint(content, { filePath: file, isSubComposition: true });
3935
4002
  for (const finding of result?.findings ?? []) {
3936
4003
  findings.push({ ...finding, file });
@@ -3963,8 +4030,8 @@ function registerLintRoutes(api, adapter) {
3963
4030
 
3964
4031
  // src/routes/render.ts
3965
4032
  import { streamSSE } from "hono/streaming";
3966
- import { existsSync as existsSync6, readFileSync as readFileSync10, mkdirSync as mkdirSync4, unlinkSync as unlinkSync3, readdirSync as readdirSync5, statSync as statSync6 } from "fs";
3967
- import { join as join10 } from "path";
4033
+ import { existsSync as existsSync6, readFileSync as readFileSync10, mkdirSync as mkdirSync5, unlinkSync as unlinkSync3, readdirSync as readdirSync5, statSync as statSync5 } from "fs";
4034
+ import { join as join11 } from "path";
3968
4035
  import { VALID_CANVAS_RESOLUTIONS } from "@hyperframes/parsers";
3969
4036
  import { formatRenderOutputTimestamp, parseFps } from "@hyperframes/core";
3970
4037
  var VALID_RESOLUTIONS = new Set(VALID_CANVAS_RESOLUTIONS);
@@ -4030,9 +4097,9 @@ function registerRenderRoutes(api, adapter) {
4030
4097
  const now = /* @__PURE__ */ new Date();
4031
4098
  const jobId = `${project.id}_${formatRenderOutputTimestamp(now)}`;
4032
4099
  const rendersDir = adapter.rendersDir(project);
4033
- if (!existsSync6(rendersDir)) mkdirSync4(rendersDir, { recursive: true });
4100
+ if (!existsSync6(rendersDir)) mkdirSync5(rendersDir, { recursive: true });
4034
4101
  const ext = FORMAT_EXT[format] ?? ".mp4";
4035
- const outputPath = join10(rendersDir, `${jobId}${ext}`);
4102
+ const outputPath = join11(rendersDir, `${jobId}${ext}`);
4036
4103
  const jobState = adapter.startRender({
4037
4104
  project,
4038
4105
  outputPath,
@@ -4133,7 +4200,7 @@ function registerRenderRoutes(api, adapter) {
4133
4200
  if (state.id === jobId && state.outputPath) {
4134
4201
  const dir = state.outputPath.replace(/\/[^/]+$/, "");
4135
4202
  for (const ext of [".mp4", ".webm", ".mov", ".meta.json"]) {
4136
- const fp = join10(dir, `${jobId}${ext}`);
4203
+ const fp = join11(dir, `${jobId}${ext}`);
4137
4204
  if (existsSync6(fp)) unlinkSync3(fp);
4138
4205
  }
4139
4206
  break;
@@ -4168,10 +4235,10 @@ function registerRenderRoutes(api, adapter) {
4168
4235
  const rendersDir = adapter.rendersDir(project);
4169
4236
  if (!existsSync6(rendersDir)) return c.json({ renders: [] });
4170
4237
  const files = readdirSync5(rendersDir).filter((f) => f.endsWith(".mp4") || f.endsWith(".webm") || f.endsWith(".mov")).map((f) => {
4171
- const fp = join10(rendersDir, f);
4172
- const stat2 = statSync6(fp);
4238
+ const fp = join11(rendersDir, f);
4239
+ const stat2 = statSync5(fp);
4173
4240
  const rid = f.replace(/\.(mp4|webm|mov)$/, "");
4174
- const metaPath = join10(rendersDir, `${rid}.meta.json`);
4241
+ const metaPath = join11(rendersDir, `${rid}.meta.json`);
4175
4242
  let status = "complete";
4176
4243
  let durationMs;
4177
4244
  if (existsSync6(metaPath)) {
@@ -4197,7 +4264,7 @@ function registerRenderRoutes(api, adapter) {
4197
4264
  id: file.id,
4198
4265
  status: file.status,
4199
4266
  progress: 100,
4200
- outputPath: join10(rendersDir, file.filename),
4267
+ outputPath: join11(rendersDir, file.filename),
4201
4268
  createdAt: file.createdAt
4202
4269
  });
4203
4270
  }
@@ -4212,17 +4279,17 @@ import {
4212
4279
  existsSync as existsSync7,
4213
4280
  fstatSync as fstatSync3,
4214
4281
  openSync as openSync3,
4215
- mkdirSync as mkdirSync5,
4282
+ mkdirSync as mkdirSync6,
4216
4283
  readFileSync as readFileSync12,
4217
4284
  readdirSync as readdirSync6,
4218
4285
  renameSync as renameSync3,
4219
4286
  rmSync as rmSync4,
4220
- statSync as statSync7,
4287
+ statSync as statSync6,
4221
4288
  unlinkSync as unlinkSync4,
4222
- writeFileSync as writeFileSync5
4289
+ writeFileSync as writeFileSync6
4223
4290
  } from "fs";
4224
- import { join as join11 } from "path";
4225
- import { createHash as createHash4, randomUUID as randomUUID4 } from "crypto";
4291
+ import { join as join12 } from "path";
4292
+ import { createHash as createHash5, randomUUID as randomUUID4 } from "crypto";
4226
4293
 
4227
4294
  // src/helpers/compositionInputs.ts
4228
4295
  import { readFileSync as readFileSync11 } from "fs";
@@ -4395,9 +4462,9 @@ function pruneThumbnailCache(cacheDir, protectedPaths, now = Date.now()) {
4395
4462
  if (!existsSync7(cacheDir)) return;
4396
4463
  const files = readdirSync6(cacheDir, { withFileTypes: true }).flatMap((entry) => {
4397
4464
  if (!entry.isFile()) return [];
4398
- const path = join11(cacheDir, entry.name);
4465
+ const path = join12(cacheDir, entry.name);
4399
4466
  try {
4400
- const stats = statSync7(path);
4467
+ const stats = statSync6(path);
4401
4468
  return [{ path, bytes: stats.size, mtimeMs: stats.mtimeMs }];
4402
4469
  } catch {
4403
4470
  return [];
@@ -4425,7 +4492,7 @@ function pruneThumbnailCache(cacheDir, protectedPaths, now = Date.now()) {
4425
4492
  function writeThumbnailAtomically(path, buffer) {
4426
4493
  const temporaryPath = `${path}.${process.pid}.${randomUUID4()}.tmp`;
4427
4494
  try {
4428
- writeFileSync5(temporaryPath, buffer, { flag: "wx" });
4495
+ writeFileSync6(temporaryPath, buffer, { flag: "wx" });
4429
4496
  renameSync3(temporaryPath, path);
4430
4497
  } finally {
4431
4498
  rmSync4(temporaryPath, { force: true });
@@ -4481,7 +4548,7 @@ function registerThumbnailRoutes(api, adapter) {
4481
4548
  let sourceKey = "";
4482
4549
  if (source !== "missing") {
4483
4550
  const { html } = source;
4484
- sourceKey = `_${createHash4("sha1").update(html).digest("hex").slice(0, 16)}`;
4551
+ sourceKey = `_${createHash5("sha1").update(html).digest("hex").slice(0, 16)}`;
4485
4552
  sourceMtime = Math.round(source.mtimeMs);
4486
4553
  if (!vpWidth) {
4487
4554
  const wMatch = html.match(/data-width=["'](\d+)["']/);
@@ -4490,31 +4557,31 @@ function registerThumbnailRoutes(api, adapter) {
4490
4557
  if (hMatch?.[1]) compH = parseInt(hMatch[1]);
4491
4558
  }
4492
4559
  }
4493
- const manualEditsFile = join11(project.dir, STUDIO_MANUAL_EDITS_PATH);
4560
+ const manualEditsFile = join12(project.dir, STUDIO_MANUAL_EDITS_PATH);
4494
4561
  let manualEditsKey = "";
4495
4562
  if (existsSync7(manualEditsFile)) {
4496
4563
  const manualEditsContent = readFileSync12(manualEditsFile, "utf-8");
4497
- manualEditsKey = `_${createHash4("sha1").update(manualEditsContent).digest("hex").slice(0, 16)}`;
4498
- sourceMtime = Math.max(sourceMtime, Math.round(statSync7(manualEditsFile).mtimeMs));
4564
+ manualEditsKey = `_${createHash5("sha1").update(manualEditsContent).digest("hex").slice(0, 16)}`;
4565
+ sourceMtime = Math.max(sourceMtime, Math.round(statSync6(manualEditsFile).mtimeMs));
4499
4566
  }
4500
- const motionFile = join11(project.dir, STUDIO_MOTION_PATH);
4567
+ const motionFile = join12(project.dir, STUDIO_MOTION_PATH);
4501
4568
  let motionKey = "";
4502
4569
  if (existsSync7(motionFile)) {
4503
4570
  const motionContent = readFileSync12(motionFile, "utf-8");
4504
- motionKey = `_${createHash4("sha1").update(motionContent).digest("hex").slice(0, 16)}`;
4505
- sourceMtime = Math.max(sourceMtime, Math.round(statSync7(motionFile).mtimeMs));
4571
+ motionKey = `_${createHash5("sha1").update(motionContent).digest("hex").slice(0, 16)}`;
4572
+ sourceMtime = Math.max(sourceMtime, Math.round(statSync6(motionFile).mtimeMs));
4506
4573
  }
4507
4574
  const projectUrl = `http://${c.req.header("host")}/api/projects/${encodeURIComponent(project.id)}`;
4508
4575
  const previewUrl = compPath === "index.html" ? `${projectUrl}/preview` : `${projectUrl}/preview/comp/${compPath.split("/").map(encodeURIComponent).join("/")}`;
4509
- const cacheDir = join11(project.dir, ".thumbnails");
4576
+ const cacheDir = join12(project.dir, ".thumbnails");
4510
4577
  const selectorKey = selector ? `_${selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}_${selectorIndex ?? 0}` : "";
4511
4578
  const urlVersionKey = urlVersion ? `_${urlVersion.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 32)}` : "";
4512
- const inputSignatureKey = `_${createHash4("sha1").update(inputSignature).digest("hex").slice(0, 16)}`;
4579
+ const inputSignatureKey = `_${createHash5("sha1").update(inputSignature).digest("hex").slice(0, 16)}`;
4513
4580
  const outputScale = outputMode === "source" ? 1 : Math.min(1, THUMBNAIL_MAX_OUTPUT_WIDTH / compW, THUMBNAIL_MAX_OUTPUT_HEIGHT / compH);
4514
4581
  const outputWidth = Math.max(1, Math.round(compW * outputScale));
4515
4582
  const outputHeight = Math.max(1, Math.round(compH * outputScale));
4516
4583
  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"}`;
4517
- const cachePath = join11(cacheDir, cacheKey);
4584
+ const cachePath = join12(cacheDir, cacheKey);
4518
4585
  if (!prunedCacheDirs.has(cacheDir)) {
4519
4586
  prunedCacheDirs.add(cacheDir);
4520
4587
  pruneThumbnailCache(
@@ -4527,6 +4594,7 @@ function registerThumbnailRoutes(api, adapter) {
4527
4594
  headers: { "Content-Type": contentType, "Cache-Control": "no-cache" }
4528
4595
  });
4529
4596
  }
4597
+ if (url.searchParams.get("cached") === "1") return c.body(null, 404);
4530
4598
  try {
4531
4599
  const buffer = await thumbnailGenerationCoordinator.acquire(
4532
4600
  cachePath,
@@ -4552,7 +4620,7 @@ function registerThumbnailRoutes(api, adapter) {
4552
4620
  if (afterGeneration?.project.dir !== project.dir || !inputsUnchanged(afterGeneration.signature) || !inputsUnchanged(createProjectSignature(project.dir))) {
4553
4621
  return generated;
4554
4622
  }
4555
- if (!existsSync7(cacheDir)) mkdirSync5(cacheDir, { recursive: true });
4623
+ if (!existsSync7(cacheDir)) mkdirSync6(cacheDir, { recursive: true });
4556
4624
  writeThumbnailAtomically(cachePath, generated);
4557
4625
  return generated;
4558
4626
  }
@@ -4578,8 +4646,8 @@ function registerThumbnailRoutes(api, adapter) {
4578
4646
  }
4579
4647
 
4580
4648
  // src/routes/waveform.ts
4581
- import { existsSync as existsSync8, readFileSync as readFileSync13, statSync as statSync8 } from "fs";
4582
- import { join as join12, resolve as resolve5 } from "path";
4649
+ import { existsSync as existsSync8, readFileSync as readFileSync13, statSync as statSync7 } from "fs";
4650
+ import { join as join13, resolve as resolve5 } from "path";
4583
4651
  import { isWithinProjectRoot as isWithinProjectRoot2 } from "@hyperframes/parsers/asset-resolution";
4584
4652
  function registerWaveformRoutes(api, adapter) {
4585
4653
  api.get("/projects/:id/waveform/*", async (c) => {
@@ -4590,10 +4658,10 @@ function registerWaveformRoutes(api, adapter) {
4590
4658
  if (!isWithinProjectRoot2(project.dir, audioPath)) {
4591
4659
  return c.json({ error: "file not found" }, 404);
4592
4660
  }
4593
- const stats = statSync8(audioPath, { throwIfNoEntry: false });
4661
+ const stats = statSync7(audioPath, { throwIfNoEntry: false });
4594
4662
  if (!stats) return c.json({ error: "file not found" }, 404);
4595
- const cacheDir = join12(project.dir, ".waveform-cache");
4596
- const cachePath = join12(cacheDir, buildWaveformCacheKey(assetPath, stats));
4663
+ const cacheDir = join13(project.dir, ".waveform-cache");
4664
+ const cachePath = join13(cacheDir, buildWaveformCacheKey(assetPath, stats));
4597
4665
  try {
4598
4666
  if (isWaveformCacheDirectory(cacheDir) && existsSync8(cachePath)) {
4599
4667
  const peaks2 = JSON.parse(readFileSync13(cachePath, "utf-8"));
@@ -4879,8 +4947,8 @@ function registerSelectionRoutes(api, adapter) {
4879
4947
 
4880
4948
  // src/routes/media.ts
4881
4949
  import { streamSSE as streamSSE2 } from "hono/streaming";
4882
- import { existsSync as existsSync9, mkdirSync as mkdirSync6 } from "fs";
4883
- import { basename as basename2, dirname as dirname4, extname as extname2, join as join13 } from "path";
4950
+ import { existsSync as existsSync9, mkdirSync as mkdirSync7 } from "fs";
4951
+ import { basename as basename2, dirname as dirname5, extname as extname2, join as join14 } from "path";
4884
4952
  var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([
4885
4953
  ".mp4",
4886
4954
  ".mov",
@@ -4917,7 +4985,7 @@ function uniqueAssetPath(projectDir, assetPath) {
4917
4985
  const ext = extname2(assetPath);
4918
4986
  const withoutExt = assetPath.slice(0, -ext.length);
4919
4987
  let candidate = assetPath;
4920
- for (let index = 2; existsSync9(join13(projectDir, candidate)); index++) {
4988
+ for (let index = 2; existsSync9(join14(projectDir, candidate)); index++) {
4921
4989
  candidate = `${withoutExt}-${index}${ext}`;
4922
4990
  }
4923
4991
  return candidate;
@@ -5024,8 +5092,8 @@ function registerMediaRoutes(api, adapter, options = {}) {
5024
5092
  return c.json({ error: "forbidden" }, 403);
5025
5093
  }
5026
5094
  }
5027
- mkdirSync6(dirname4(outputPath), { recursive: true });
5028
- if (backgroundOutputPath) mkdirSync6(dirname4(backgroundOutputPath), { recursive: true });
5095
+ mkdirSync7(dirname5(outputPath), { recursive: true });
5096
+ if (backgroundOutputPath) mkdirSync7(dirname5(backgroundOutputPath), { recursive: true });
5029
5097
  const jobId = makeJobId(project.id, mediaJobs);
5030
5098
  const state = adapter.startBackgroundRemoval({
5031
5099
  project,
@@ -5084,9 +5152,9 @@ function registerMediaRoutes(api, adapter, options = {}) {
5084
5152
  // src/routes/globalAssets.ts
5085
5153
  import { existsSync as existsSync10, readFileSync as readFileSync14 } from "fs";
5086
5154
  import { homedir } from "os";
5087
- import { join as join14 } from "path";
5155
+ import { join as join15 } from "path";
5088
5156
  function readGlobalAssets(home = homedir()) {
5089
- const manifestPath = join14(home, ".media", "manifest.jsonl");
5157
+ const manifestPath = join15(home, ".media", "manifest.jsonl");
5090
5158
  if (!existsSync10(manifestPath)) return [];
5091
5159
  const out = [];
5092
5160
  for (const line of readFileSync14(manifestPath, "utf8").split("\n")) {
@@ -5106,117 +5174,6 @@ function registerGlobalAssetRoutes(api) {
5106
5174
  api.get("/assets/global", (c) => c.json({ assets: readGlobalAssets().map(toPublicAsset) }));
5107
5175
  }
5108
5176
 
5109
- // src/history/historyLog.ts
5110
- import { closeSync as closeSync5, constants as constants3, mkdirSync as mkdirSync7, openSync as openSync5, readFileSync as readFileSync15, writeSync as writeSync2 } from "fs";
5111
- import { dirname as dirname5 } from "path";
5112
- var START = "start";
5113
- function parseRecord(line) {
5114
- try {
5115
- return JSON.parse(line);
5116
- } catch {
5117
- return null;
5118
- }
5119
- }
5120
- function readLog(file, onUnreadable) {
5121
- let text2;
5122
- try {
5123
- text2 = readFileSync15(file, "utf-8");
5124
- } catch {
5125
- return null;
5126
- }
5127
- const log = { baseline: /* @__PURE__ */ new Map(), entries: [], pins: /* @__PURE__ */ new Set() };
5128
- const lines = text2.split("\n");
5129
- lines.forEach((line, index) => {
5130
- const record = line ? parseRecord(line) : null;
5131
- if (record) applyRecord(log, record);
5132
- else if (line && index < lines.length - 1) onUnreadable(index + 1);
5133
- });
5134
- return log;
5135
- }
5136
- function applyRecord(log, record) {
5137
- if (record.type === "baseline") log.baseline = new Map(Object.entries(record.files));
5138
- else if (record.type === "entry") log.entries.push(record.entry);
5139
- else if (record.pinned) log.pins.add(record.id);
5140
- else log.pins.delete(record.id);
5141
- }
5142
- function saveRecord(file, log, record) {
5143
- let fd;
5144
- try {
5145
- fd = openSync5(file, constants3.O_WRONLY | constants3.O_APPEND);
5146
- } catch (error) {
5147
- if (error.code !== "ENOENT") throw error;
5148
- return writeLog(file, log);
5149
- }
5150
- try {
5151
- writeSync2(fd, `${JSON.stringify(record)}
5152
- `);
5153
- } finally {
5154
- closeSync5(fd);
5155
- }
5156
- }
5157
- function writeLog(file, log) {
5158
- const records = [
5159
- { type: "baseline", files: Object.fromEntries(log.baseline) },
5160
- ...log.entries.map((entry) => ({ type: "entry", entry })),
5161
- ...[...log.pins].map((id) => ({ type: "pin", id, pinned: true }))
5162
- ];
5163
- mkdirSync7(dirname5(file), { recursive: true });
5164
- replaceFileAtomically(file, records.map((r) => `${JSON.stringify(r)}
5165
- `).join(""), 420);
5166
- }
5167
- function applyEntry(manifest, entry) {
5168
- for (const file of entry.files)
5169
- if (file.after === null) manifest.delete(file.path);
5170
- else manifest.set(file.path, file.after);
5171
- }
5172
- function manifestAt(log, point) {
5173
- const manifest = new Map(log.baseline);
5174
- if (point === START) return manifest;
5175
- for (const entry of log.entries) {
5176
- applyEntry(manifest, entry);
5177
- if (entry.id === point) return manifest;
5178
- }
5179
- return null;
5180
- }
5181
- function undoneIds(entries) {
5182
- const undone = /* @__PURE__ */ new Set();
5183
- for (let i = entries.length - 1; i >= 0; i -= 1) {
5184
- const entry = entries[i];
5185
- if (entry.undoes && !undone.has(entry.id)) undone.add(entry.undoes);
5186
- }
5187
- return undone;
5188
- }
5189
- function stepTarget(entries, direction) {
5190
- const undone = undoneIds(entries);
5191
- const byId = new Map(entries.map((entry) => [entry.id, entry]));
5192
- for (let i = entries.length - 1; i >= 0; i -= 1) {
5193
- const entry = entries[i];
5194
- const isChange = !entry.undoes;
5195
- if (direction === "back" && isChange && !undone.has(entry.id)) return entry;
5196
- if (direction !== "forward") continue;
5197
- if (isChange) return void 0;
5198
- const target = byId.get(entry.undoes);
5199
- if (!undone.has(entry.id) && target && !target.undoes) return entry;
5200
- }
5201
- return void 0;
5202
- }
5203
- function foldOldest(log) {
5204
- const oldest = log.entries[0];
5205
- if (!oldest || log.pins.has(oldest.id)) return false;
5206
- applyEntry(log.baseline, oldest);
5207
- log.entries.shift();
5208
- return true;
5209
- }
5210
- function referencedHashes(log, current) {
5211
- const keep = /* @__PURE__ */ new Set([...log.baseline.values(), ...current.values()]);
5212
- for (const entry of log.entries)
5213
- for (const file of entry.files) {
5214
- if (file.before) keep.add(file.before);
5215
- if (file.after) keep.add(file.after);
5216
- }
5217
- return keep;
5218
- }
5219
-
5220
5177
  // src/routes/history.ts
5221
5178
  var YOU = { kind: "person", name: "You" };
5222
5179
  var MAX_WINDOW_IDLE_MS = 10 * 6e4;
@@ -5229,12 +5186,23 @@ async function bodyOf(c) {
5229
5186
  return body && typeof body === "object" ? body : {};
5230
5187
  }
5231
5188
  var text = (value) => typeof value === "string" && value ? value : null;
5189
+ function writing(c) {
5190
+ const header = c.req.header("X-Hyperframes-Write-Token");
5191
+ return header ? { writeToken: createWriteToken(header) } : {};
5192
+ }
5232
5193
  function idleOf(body) {
5233
5194
  const idleMs = body.idleMs;
5234
5195
  return typeof idleMs === "number" && idleMs > 0 ? Math.min(idleMs, MAX_WINDOW_IDLE_MS) : void 0;
5235
5196
  }
5236
- function nextStep(entries, direction) {
5237
- const target = stepTarget(entries, direction);
5197
+ function versionsOf(value) {
5198
+ if (!value || typeof value !== "object") return void 0;
5199
+ const pairs = Object.entries(value).filter(
5200
+ (pair) => typeof pair[1] === "string"
5201
+ );
5202
+ return pairs.length ? Object.fromEntries(pairs) : void 0;
5203
+ }
5204
+ function nextStep(history, direction) {
5205
+ const target = history.next(direction);
5238
5206
  if (!target) return null;
5239
5207
  const paths = target.files.map((file) => file.path);
5240
5208
  return { id: target.id, label: target.label, endedAt: target.endedAt, paths };
@@ -5255,7 +5223,7 @@ function registerHistoryRoutes(api, adapter) {
5255
5223
  base,
5256
5224
  (c) => withHistory(adapter, c, (history) => {
5257
5225
  const entries = history.list();
5258
- return { entries, back: nextStep(entries, "back"), forward: nextStep(entries, "forward") };
5226
+ return { entries, back: nextStep(history, "back"), forward: nextStep(history, "forward") };
5259
5227
  })
5260
5228
  );
5261
5229
  api.post(
@@ -5263,19 +5231,23 @@ function registerHistoryRoutes(api, adapter) {
5263
5231
  (c) => withHistory(
5264
5232
  adapter,
5265
5233
  c,
5266
- (history, body) => history.step(body.direction === "forward" ? "forward" : "back", YOU)
5234
+ (history, body) => history.step(body.direction === "forward" ? "forward" : "back", YOU, writing(c))
5267
5235
  )
5268
5236
  );
5269
5237
  api.post(
5270
5238
  `${base}/undo`,
5271
5239
  (c) => withHistory(adapter, c, (history, body) => {
5272
5240
  const mode = body.mode === "just-this" || body.mode === "back-to-before" ? body.mode : void 0;
5273
- return history.undo(text(body.entryId) ?? "", { who: YOU, mode });
5241
+ return history.undo(text(body.entryId) ?? "", { who: YOU, mode, ...writing(c) });
5274
5242
  })
5275
5243
  );
5276
5244
  api.post(
5277
5245
  `${base}/restore`,
5278
- (c) => withHistory(adapter, c, (history, body) => history.restore(text(body.point) ?? "", YOU))
5246
+ (c) => withHistory(
5247
+ adapter,
5248
+ c,
5249
+ (history, body) => history.restore(text(body.point) ?? "", YOU, writing(c))
5250
+ )
5279
5251
  );
5280
5252
  api.get(
5281
5253
  `${base}/peek/:point`,
@@ -5294,9 +5266,11 @@ function registerHistoryRoutes(api, adapter) {
5294
5266
  const paths = Array.isArray(body.paths) ? body.paths.filter((path) => text(path)) : [];
5295
5267
  const coalesceKey = text(body.coalesceKey) ?? void 0;
5296
5268
  const idleMs = idleOf(body);
5269
+ const overwrote = versionsOf(body.overwrote);
5297
5270
  const claimed = await history.claim(YOU, text(body.label) ?? "Edited in Studio", paths, {
5298
5271
  ...coalesceKey && { coalesceKey },
5299
- ...idleMs && { idleMs }
5272
+ ...idleMs && { idleMs },
5273
+ ...overwrote && { overwrote }
5300
5274
  });
5301
5275
  return { claimed };
5302
5276
  })
@@ -5346,18 +5320,20 @@ function createStudioApi(adapter) {
5346
5320
  }
5347
5321
 
5348
5322
  // src/history/projectHistory.ts
5349
- import { randomUUID as randomUUID7 } from "crypto";
5323
+ import { randomUUID as randomUUID8 } from "crypto";
5350
5324
  import { rm as rm2 } from "fs/promises";
5351
- import { existsSync as existsSync12, mkdirSync as mkdirSync9, readFileSync as readFileSync17 } from "fs";
5352
- import { join as join17, relative as relative4, resolve as resolve7, sep as sep3 } from "path";
5325
+ import { existsSync as existsSync12, mkdirSync as mkdirSync11, readFileSync as readFileSync18 } from "fs";
5326
+ import { homedir as homedir2 } from "os";
5327
+ import { join as join19, relative as relative4, resolve as resolve7, sep as sep3 } from "path";
5353
5328
 
5354
5329
  // src/history/blobStore.ts
5355
- import { createHash as createHash5, randomUUID as randomUUID5 } from "crypto";
5356
- import { constants as constants4, createReadStream as createReadStream2 } from "fs";
5330
+ import { createHash as createHash6, randomUUID as randomUUID5 } from "crypto";
5331
+ import { constants as constants3, createReadStream as createReadStream2 } from "fs";
5357
5332
  import { copyFile, mkdir, readdir, readFile as readFile2, rename, rm, stat } from "fs/promises";
5358
- import { dirname as dirname6, join as join15 } from "path";
5333
+ import { dirname as dirname6, join as join16 } from "path";
5334
+ var BLOB_HASH = /^[0-9a-f]{64}$/;
5359
5335
  async function hashFile(path) {
5360
- const hash = createHash5("sha256");
5336
+ const hash = createHash6("sha256");
5361
5337
  for await (const chunk of createReadStream2(path)) hash.update(chunk);
5362
5338
  return hash.digest("hex");
5363
5339
  }
@@ -5365,7 +5341,7 @@ async function cloneOrCopy(from, to) {
5365
5341
  await mkdir(dirname6(to), { recursive: true });
5366
5342
  const temp = `${to}.${randomUUID5()}.tmp`;
5367
5343
  try {
5368
- await copyFile(from, temp, constants4.COPYFILE_FICLONE);
5344
+ await copyFile(from, temp, constants3.COPYFILE_FICLONE);
5369
5345
  await rename(temp, to);
5370
5346
  } catch (error) {
5371
5347
  await rm(temp, { force: true });
@@ -5376,15 +5352,18 @@ async function openBlobStore(dir) {
5376
5352
  const sizes = /* @__PURE__ */ new Map();
5377
5353
  await mkdir(dir, { recursive: true });
5378
5354
  for (const shard of await readdir(dir))
5379
- for (const hash of await readdir(join15(dir, shard)).catch(() => []))
5380
- sizes.set(hash, (await stat(join15(dir, shard, hash))).size);
5381
- const pathOf = (hash) => join15(dir, hash.slice(0, 2), hash);
5355
+ for (const hash of await readdir(join16(dir, shard)).catch(() => []))
5356
+ sizes.set(hash, (await stat(join16(dir, shard, hash))).size);
5357
+ const pathOf = (hash) => {
5358
+ if (!BLOB_HASH.test(hash)) throw new Error("That is not a history blob.");
5359
+ return join16(dir, hash.slice(0, 2), hash);
5360
+ };
5382
5361
  let total = [...sizes.values()].reduce((sum, size) => sum + size, 0);
5383
5362
  return {
5384
5363
  async put(absPath) {
5385
- const temp = join15(dir, `incoming-${randomUUID5()}`);
5364
+ const temp = join16(dir, `incoming-${randomUUID5()}`);
5386
5365
  await mkdir(dir, { recursive: true });
5387
- await copyFile(absPath, temp, constants4.COPYFILE_FICLONE);
5366
+ await copyFile(absPath, temp, constants3.COPYFILE_FICLONE);
5388
5367
  const hash = await hashFile(temp);
5389
5368
  if (sizes.has(hash)) {
5390
5369
  await rm(temp, { force: true });
@@ -5397,8 +5376,9 @@ async function openBlobStore(dir) {
5397
5376
  total += size;
5398
5377
  return hash;
5399
5378
  },
5400
- read: (hash) => readFile2(pathOf(hash)),
5401
- writeTo: (hash, absPath) => cloneOrCopy(pathOf(hash), absPath),
5379
+ has: (hash) => sizes.has(hash),
5380
+ read: async (hash) => readFile2(pathOf(hash)),
5381
+ writeTo: async (hash, absPath) => cloneOrCopy(pathOf(hash), absPath),
5402
5382
  bytes: () => total,
5403
5383
  size: (hash) => sizes.get(hash) ?? 0,
5404
5384
  async prune(keep) {
@@ -5414,13 +5394,13 @@ async function openBlobStore(dir) {
5414
5394
 
5415
5395
  // src/history/historyId.ts
5416
5396
  import { randomUUID as randomUUID6 } from "crypto";
5417
- import { existsSync as existsSync11, mkdirSync as mkdirSync8, readFileSync as readFileSync16, writeFileSync as writeFileSync6 } from "fs";
5418
- import { join as join16, resolve as resolve6 } from "path";
5419
- var ID_PATH = join16(".hyperframes", "history-id");
5397
+ import { existsSync as existsSync11, mkdirSync as mkdirSync8, readFileSync as readFileSync15, writeFileSync as writeFileSync7 } from "fs";
5398
+ import { join as join17, resolve as resolve6 } from "path";
5399
+ var ID_PATH = join17(".hyperframes", "history-id");
5420
5400
  var ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
5421
5401
  function readId(projectDir) {
5422
5402
  try {
5423
- const id = readFileSync16(join16(projectDir, ID_PATH), "utf-8").trim();
5403
+ const id = readFileSync15(join17(projectDir, ID_PATH), "utf-8").trim();
5424
5404
  return ID_SHAPE.test(id) ? id : null;
5425
5405
  } catch {
5426
5406
  return null;
@@ -5428,7 +5408,7 @@ function readId(projectDir) {
5428
5408
  }
5429
5409
  function recordedDir(historyDir) {
5430
5410
  try {
5431
- return JSON.parse(readFileSync16(join16(historyDir, "project.json"), "utf-8")).dir ?? null;
5411
+ return JSON.parse(readFileSync15(join17(historyDir, "project.json"), "utf-8")).dir ?? null;
5432
5412
  } catch {
5433
5413
  return null;
5434
5414
  }
@@ -5436,23 +5416,225 @@ function recordedDir(historyDir) {
5436
5416
  function projectHistoryId(projectDir, historyRoot) {
5437
5417
  const dir = resolve6(projectDir);
5438
5418
  let id = readId(dir);
5439
- const was = id && recordedDir(join16(historyRoot, id));
5419
+ const was = id && recordedDir(join17(historyRoot, id));
5440
5420
  if (!id || was && was !== dir && existsSync11(was) && readId(was) === id) {
5441
5421
  id = randomUUID6();
5442
- mkdirSync8(join16(dir, ".hyperframes"), { recursive: true });
5443
- writeFileSync6(join16(dir, ID_PATH), `${id}
5422
+ mkdirSync8(join17(dir, ".hyperframes"), { recursive: true });
5423
+ writeFileSync7(join17(dir, ID_PATH), `${id}
5444
5424
  `);
5445
5425
  }
5446
- mkdirSync8(join16(historyRoot, id), { recursive: true });
5447
- writeFileSync6(join16(historyRoot, id, "project.json"), JSON.stringify({ dir }));
5426
+ mkdirSync8(join17(historyRoot, id), { recursive: true });
5427
+ writeFileSync7(join17(historyRoot, id, "project.json"), JSON.stringify({ dir }));
5448
5428
  return id;
5449
5429
  }
5450
5430
 
5431
+ // src/history/ownerLock.ts
5432
+ import { randomUUID as randomUUID7 } from "crypto";
5433
+ import { linkSync, mkdirSync as mkdirSync9, readFileSync as readFileSync16, rmSync as rmSync5, writeFileSync as writeFileSync8 } from "fs";
5434
+ import { join as join18 } from "path";
5435
+ var HistoryBusyError = class extends Error {
5436
+ constructor(pid) {
5437
+ super(`This project's history is open in another process (pid ${pid}).`);
5438
+ this.pid = pid;
5439
+ this.name = "HistoryBusyError";
5440
+ }
5441
+ pid;
5442
+ };
5443
+ function alive(pid) {
5444
+ if (!Number.isInteger(pid) || pid <= 0) return false;
5445
+ try {
5446
+ process.kill(pid, 0);
5447
+ return true;
5448
+ } catch (error) {
5449
+ return error.code === "EPERM";
5450
+ }
5451
+ }
5452
+ function ownerOf(file) {
5453
+ try {
5454
+ const text2 = readFileSync16(file, "utf-8");
5455
+ return /^\d+$/.test(text2) ? Number(text2) : Number.NaN;
5456
+ } catch (error) {
5457
+ if (error.code === "ENOENT") return null;
5458
+ throw error;
5459
+ }
5460
+ }
5461
+ function claim(file) {
5462
+ const draft = `${file}-${randomUUID7()}.tmp`;
5463
+ writeFileSync8(draft, String(process.pid));
5464
+ try {
5465
+ linkSync(draft, file);
5466
+ return true;
5467
+ } catch (error) {
5468
+ if (error.code !== "EEXIST") throw error;
5469
+ return false;
5470
+ } finally {
5471
+ rmSync5(draft, { force: true });
5472
+ }
5473
+ }
5474
+ function releaseOwn(file) {
5475
+ if (ownerOf(file) === process.pid) rmSync5(file, { force: true });
5476
+ }
5477
+ function evictDeadOwner(file) {
5478
+ const evictor = `${file}.evict`;
5479
+ if (!claim(evictor)) {
5480
+ const holder = ownerOf(evictor);
5481
+ if (holder !== null && !alive(holder)) rmSync5(evictor, { force: true });
5482
+ return false;
5483
+ }
5484
+ try {
5485
+ const pid = ownerOf(file);
5486
+ if (pid !== null && !alive(pid)) rmSync5(file, { force: true });
5487
+ return true;
5488
+ } finally {
5489
+ releaseOwn(evictor);
5490
+ }
5491
+ }
5492
+ async function takeHistoryOwnership(home, waitMs) {
5493
+ const file = join18(home, "owner.pid");
5494
+ const deadline = Date.now() + waitMs;
5495
+ mkdirSync9(home, { recursive: true });
5496
+ for (; ; ) {
5497
+ if (claim(file)) {
5498
+ let held = true;
5499
+ return () => {
5500
+ if (held) releaseOwn(file);
5501
+ held = false;
5502
+ };
5503
+ }
5504
+ const pid = ownerOf(file);
5505
+ if (pid === null) continue;
5506
+ if (!alive(pid) && evictDeadOwner(file)) continue;
5507
+ if (Date.now() >= deadline) throw new HistoryBusyError(pid);
5508
+ await new Promise((settle) => setTimeout(settle, 50));
5509
+ }
5510
+ }
5511
+
5512
+ // src/history/historyLog.ts
5513
+ import { closeSync as closeSync5, constants as constants4, mkdirSync as mkdirSync10, openSync as openSync5, readFileSync as readFileSync17, writeSync as writeSync2 } from "fs";
5514
+ import { dirname as dirname7 } from "path";
5515
+ var START = "start";
5516
+ function parseRecord(line) {
5517
+ try {
5518
+ return JSON.parse(line);
5519
+ } catch {
5520
+ return null;
5521
+ }
5522
+ }
5523
+ function readLog(file, onUnreadable) {
5524
+ let text2;
5525
+ try {
5526
+ text2 = readFileSync17(file, "utf-8");
5527
+ } catch {
5528
+ return null;
5529
+ }
5530
+ const log = { baseline: /* @__PURE__ */ new Map(), entries: [], pins: /* @__PURE__ */ new Set() };
5531
+ const lines = text2.split("\n");
5532
+ lines.forEach((line, index) => {
5533
+ const record = line ? parseRecord(line) : null;
5534
+ if (record) applyRecord(log, record);
5535
+ else if (line && index < lines.length - 1) onUnreadable(index + 1);
5536
+ });
5537
+ return log;
5538
+ }
5539
+ function applyRecord(log, record) {
5540
+ if (record.type === "baseline") log.baseline = new Map(Object.entries(record.files));
5541
+ else if (record.type === "entry") log.entries.push(record.entry);
5542
+ else if (record.pinned) log.pins.add(record.id);
5543
+ else log.pins.delete(record.id);
5544
+ }
5545
+ function saveRecord(file, log, record) {
5546
+ let fd;
5547
+ try {
5548
+ fd = openSync5(file, constants4.O_WRONLY | constants4.O_APPEND);
5549
+ } catch (error) {
5550
+ if (error.code !== "ENOENT") throw error;
5551
+ return writeLog(file, log);
5552
+ }
5553
+ try {
5554
+ writeSync2(fd, `${JSON.stringify(record)}
5555
+ `);
5556
+ } finally {
5557
+ closeSync5(fd);
5558
+ }
5559
+ }
5560
+ function writeLog(file, log) {
5561
+ const records = [
5562
+ { type: "baseline", files: Object.fromEntries(log.baseline) },
5563
+ ...log.entries.map((entry) => ({ type: "entry", entry })),
5564
+ ...[...log.pins].map((id) => ({ type: "pin", id, pinned: true }))
5565
+ ];
5566
+ mkdirSync10(dirname7(file), { recursive: true });
5567
+ replaceFileAtomically(file, records.map((r) => `${JSON.stringify(r)}
5568
+ `).join(""), 420);
5569
+ }
5570
+ function applyEntry(manifest, entry) {
5571
+ for (const file of entry.files)
5572
+ if (file.after === null) manifest.delete(file.path);
5573
+ else manifest.set(file.path, file.after);
5574
+ }
5575
+ function manifestAt(log, point) {
5576
+ const manifest = new Map(log.baseline);
5577
+ if (point === START) return manifest;
5578
+ for (const entry of log.entries) {
5579
+ applyEntry(manifest, entry);
5580
+ if (entry.id === point) return manifest;
5581
+ }
5582
+ return null;
5583
+ }
5584
+ function undoneIds(entries) {
5585
+ const undone = /* @__PURE__ */ new Set();
5586
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
5587
+ const entry = entries[i];
5588
+ if (entry.undoes && !undone.has(entry.id)) undone.add(entry.undoes);
5589
+ }
5590
+ return undone;
5591
+ }
5592
+ function stepTarget(entries, direction) {
5593
+ const undone = undoneIds(entries);
5594
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
5595
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
5596
+ const entry = entries[i];
5597
+ const isChange = !entry.undoes;
5598
+ if (direction === "back" && isChange && !undone.has(entry.id)) return entry;
5599
+ if (direction !== "forward") continue;
5600
+ if (isChange) return void 0;
5601
+ const target = byId.get(entry.undoes);
5602
+ if (!undone.has(entry.id) && target && !target.undoes) return entry;
5603
+ }
5604
+ return void 0;
5605
+ }
5606
+ function foldOldest(log) {
5607
+ const oldest = log.entries[0];
5608
+ if (!oldest || log.pins.has(oldest.id)) return false;
5609
+ applyEntry(log.baseline, oldest);
5610
+ log.entries.shift();
5611
+ return true;
5612
+ }
5613
+ function referencedHashes(log, current) {
5614
+ const keep = /* @__PURE__ */ new Set([...log.baseline.values(), ...current.values()]);
5615
+ for (const entry of log.entries)
5616
+ for (const file of entry.files) {
5617
+ if (file.before) keep.add(file.before);
5618
+ if (file.after) keep.add(file.after);
5619
+ }
5620
+ return keep;
5621
+ }
5622
+
5451
5623
  // src/history/projectHistory.ts
5624
+ var DEFAULT_HISTORY_ROOT = join19(homedir2(), ".cache", "hyperframes", "history");
5452
5625
  var OUTSIDE = { kind: "outside", name: "Outside" };
5453
5626
  var OUTSIDE_LABEL = "Changed outside the app";
5454
5627
  var RACY_MS = 2e3;
5455
5628
  var statKey = (file, sweptAt) => sweptAt - Math.max(file.mtimeMs, file.ctimeMs) < RACY_MS ? "" : `${file.size}:${file.mtimeMs}:${file.ctimeMs}`;
5629
+ var sameWho = (a, b) => a.kind === b.kind && a.name === b.name;
5630
+ function splitAt(change, at, kept) {
5631
+ if (at === change.after) return [change, null];
5632
+ if (!at || at === change.before || !kept) return [null, change];
5633
+ return [
5634
+ { ...change, after: at },
5635
+ { ...change, before: at }
5636
+ ];
5637
+ }
5456
5638
  function addChange(group, path, before, after) {
5457
5639
  const earlier = group.changes.get(path);
5458
5640
  const from = earlier ? earlier.before : before;
@@ -5465,7 +5647,7 @@ var Engine = class {
5465
5647
  this.projectId = projectId;
5466
5648
  this.blobs = blobs;
5467
5649
  this.dir = resolve7(options.projectDir);
5468
- this.home = join17(options.historyRoot, projectId);
5650
+ this.home = join19(options.historyRoot, projectId);
5469
5651
  }
5470
5652
  options;
5471
5653
  projectId;
@@ -5478,13 +5660,14 @@ var Engine = class {
5478
5660
  outside = null;
5479
5661
  /** A coalescing claim, open until another key, its idle timer, or an operation commits it. */
5480
5662
  claimed = null;
5663
+ writeToken;
5481
5664
  quietTimer;
5482
5665
  maxTimer;
5483
5666
  notedTimer = null;
5484
5667
  listeners = /* @__PURE__ */ new Set();
5485
5668
  tail = Promise.resolve();
5486
5669
  get logFile() {
5487
- return join17(this.home, "log.jsonl");
5670
+ return join19(this.home, "log.jsonl");
5488
5671
  }
5489
5672
  now() {
5490
5673
  return (this.options.now ?? Date.now)();
@@ -5517,7 +5700,7 @@ var Engine = class {
5517
5700
  const sweptAt = Date.now();
5518
5701
  for (const file of listProjectFiles(this.dir))
5519
5702
  this.tracked.set(file.path, {
5520
- hash: await this.blobs.put(join17(this.dir, file.path)),
5703
+ hash: await this.blobs.put(join19(this.dir, file.path)),
5521
5704
  stat: statKey(file, sweptAt)
5522
5705
  });
5523
5706
  this.log.baseline = this.manifest();
@@ -5529,7 +5712,7 @@ var Engine = class {
5529
5712
  }
5530
5713
  readStatCache() {
5531
5714
  try {
5532
- const files = JSON.parse(readFileSync17(join17(this.home, "stat.json"), "utf-8"));
5715
+ const files = JSON.parse(readFileSync18(join19(this.home, "stat.json"), "utf-8"));
5533
5716
  return new Map(Object.entries(files));
5534
5717
  } catch {
5535
5718
  return /* @__PURE__ */ new Map();
@@ -5537,8 +5720,8 @@ var Engine = class {
5537
5720
  }
5538
5721
  saveStatCache() {
5539
5722
  const files = JSON.stringify(Object.fromEntries(this.tracked));
5540
- mkdirSync9(this.home, { recursive: true });
5541
- replaceFileAtomically(join17(this.home, "stat.json"), files, 420);
5723
+ mkdirSync11(this.home, { recursive: true });
5724
+ replaceFileAtomically(join19(this.home, "stat.json"), files, 420);
5542
5725
  }
5543
5726
  /**
5544
5727
  * Takes in every write since the last sweep and files each change to its writer. ponytail: always the whole
@@ -5563,7 +5746,7 @@ var Engine = class {
5563
5746
  /** The file's hash once copied in; null when it was removed before the copy (the next sweep records that). */
5564
5747
  async storeIfPresent(path) {
5565
5748
  try {
5566
- return await this.blobs.put(join17(this.dir, path));
5749
+ return await this.blobs.put(join19(this.dir, path));
5567
5750
  } catch (error) {
5568
5751
  if (error.code === "ENOENT") return null;
5569
5752
  throw error;
@@ -5583,37 +5766,80 @@ var Engine = class {
5583
5766
  if (window) this.touch(window);
5584
5767
  addChange(window ?? this.outsideGroup(), path, before, after);
5585
5768
  }
5586
- /**
5587
- * ponytail: a claim takes every uncommitted outside change to its paths, so another writer's write to the same file
5588
- * between Studio's write and its claim (milliseconds) folds into Studio's entry. Per-write tokens would split them.
5589
- * A write filed to another writer's open window stays that window's.
5590
- */
5591
- async claimNow(who, label, paths, { coalesceKey, idleMs }) {
5769
+ /** ponytail: another's write between Studio's write and its sweep folds into Studio's entry (per-write tokens). */
5770
+ async claimNow(who, label, paths, { coalesceKey, idleMs, overwrote = {} }) {
5592
5771
  await this.sweep();
5593
- const taken = this.takeOutside(paths);
5594
- if (!taken.length) return null;
5595
- const group = await this.claimGroup(who, label, coalesceKey);
5772
+ const { taken, cut } = this.takeClaimed(who, paths, overwrote);
5773
+ if (!taken.length) {
5774
+ if (coalesceKey !== this.claimed?.key) await this.commitClaim();
5775
+ return null;
5776
+ }
5777
+ const held = this.heldFor(coalesceKey, taken);
5778
+ if (!held) await this.commitClaim();
5779
+ await this.commitOlder(cut);
5780
+ const group = held ?? this.newGroup(who, label);
5596
5781
  for (const change of taken) addChange(group, change.path, change.before, change.after);
5597
5782
  if (coalesceKey) return this.holdClaim(group, coalesceKey, idleMs);
5598
5783
  const entry = await this.commit(group);
5599
5784
  return entry && { id: entry.id };
5600
5785
  }
5601
- /** The held claim when the key matches; otherwise it is committed and a new group starts. */
5602
- async claimGroup(who, label, key) {
5603
- if (key && this.claimed?.key === key) return this.claimed.group;
5604
- await this.commitClaim();
5605
- return this.newGroup(who, label);
5606
- }
5607
- /** Removes and returns the uncommitted outside changes to `paths` (project-relative or absolute). */
5608
- takeOutside(paths) {
5609
- const outside = this.outside;
5610
- if (!outside) return [];
5611
- const wanted = new Set(
5612
- paths.map((path) => relative4(this.dir, resolve7(this.dir, path)).split(sep3).join("/"))
5786
+ async commitOlder(cut) {
5787
+ if (this.outside) cut.delete(this.outside);
5788
+ await this.commitOutside();
5789
+ for (const [window, paths] of cut) await this.commitCut(window, paths);
5790
+ }
5791
+ /** The held claim `taken` continues: same key, each file from its own last change, no held file changed since. */
5792
+ heldFor(key, taken) {
5793
+ const group = key && this.claimed?.key === key ? this.claimed.group : null;
5794
+ const after = (path, before) => {
5795
+ const own = group?.changes.get(path);
5796
+ return own ? own.after : before;
5797
+ };
5798
+ const takenPaths = new Set(taken.map((change) => change.path));
5799
+ const untouched = (change) => takenPaths.has(change.path) || (this.tracked.get(change.path)?.hash ?? null) === change.after;
5800
+ return group && taken.every((change) => after(change.path, change.before) === change.before) && [...group.changes.values()].every(untouched) ? group : null;
5801
+ }
5802
+ logPath(path) {
5803
+ return relative4(this.dir, resolve7(this.dir, path)).split(sep3).join("/");
5804
+ }
5805
+ /** Takes `paths`' uncommitted changes from others, cut at what `who` overwrote; `cut`: paths each group keeps. */
5806
+ takeClaimed(who, paths, overwrote) {
5807
+ const wanted = new Set(paths.map((path) => this.logPath(path)));
5808
+ const at = new Map(
5809
+ Object.entries(overwrote).map(([path, version]) => [
5810
+ this.logPath(path),
5811
+ hashOfVersion(version)
5812
+ ])
5613
5813
  );
5614
- const taken = [...outside.changes.values()].filter((change) => wanted.has(change.path));
5615
- for (const change of taken) outside.changes.delete(change.path);
5616
- return taken;
5814
+ const others = this.windows.filter((open) => !sameWho(open.who, who));
5815
+ const groups = this.outside ? [this.outside, ...others] : others;
5816
+ const taken = [];
5817
+ const cut = /* @__PURE__ */ new Map();
5818
+ for (const group of groups) {
5819
+ for (const change of [...group.changes.values()]) {
5820
+ if (!wanted.has(change.path)) continue;
5821
+ const [kept, claimed] = this.cutOut(group, change, at.get(change.path));
5822
+ if (claimed) taken.push(claimed);
5823
+ if (kept && claimed) cut.set(group, [...cut.get(group) ?? [], change.path]);
5824
+ }
5825
+ }
5826
+ return { taken, cut };
5827
+ }
5828
+ cutOut(group, change, cut) {
5829
+ const [kept, claimed] = splitAt(change, cut, cut !== void 0 && this.blobs.has(cut));
5830
+ group.changes.delete(change.path);
5831
+ if (kept) group.changes.set(kept.path, kept);
5832
+ return [kept, claimed];
5833
+ }
5834
+ /** Commits the window's cut `paths` as their own entry, other files staying: a window holds one change per file,
5835
+ * so the writer's next write to a cut file would otherwise span the claimer's edit. */
5836
+ async commitCut(window, paths) {
5837
+ const part = { ...this.newGroup(window.who, window.label), startedAt: window.startedAt };
5838
+ for (const path of paths) {
5839
+ part.changes.set(path, window.changes.get(path));
5840
+ window.changes.delete(path);
5841
+ }
5842
+ window.entry = await this.commit(part);
5617
5843
  }
5618
5844
  holdClaim(group, key, idleMs = this.options.quietMs ?? 2e3) {
5619
5845
  clearTimeout(this.claimed?.timer);
@@ -5645,7 +5871,7 @@ var Engine = class {
5645
5871
  return this.outside;
5646
5872
  }
5647
5873
  newGroup(who, label) {
5648
- return { id: randomUUID7(), who, label, startedAt: this.now(), changes: /* @__PURE__ */ new Map() };
5874
+ return { id: randomUUID8(), who, label, startedAt: this.now(), changes: /* @__PURE__ */ new Map() };
5649
5875
  }
5650
5876
  async commitOutside() {
5651
5877
  clearTimeout(this.quietTimer);
@@ -5656,9 +5882,9 @@ var Engine = class {
5656
5882
  }
5657
5883
  async commit(group, extra = {}) {
5658
5884
  if (!group.changes.size) return null;
5659
- const { changes, ...rest } = group;
5885
+ const { id, who, label, startedAt, changes } = group;
5660
5886
  const files = [...changes.values()].sort((a, b) => a.path.localeCompare(b.path));
5661
- const entry = { ...rest, endedAt: this.now(), files, ...extra };
5887
+ const entry = { id, who, label, startedAt, endedAt: this.now(), files, ...extra };
5662
5888
  this.log.entries.push(entry);
5663
5889
  try {
5664
5890
  saveRecord(this.logFile, this.log, { type: "entry", entry });
@@ -5732,7 +5958,7 @@ var Engine = class {
5732
5958
  if (!this.windows.includes(window)) return window.entry ?? null;
5733
5959
  clearTimeout(window.idleTimer);
5734
5960
  this.windows = this.windows.filter((open) => open !== window);
5735
- window.entry = await this.commit(window);
5961
+ window.entry = await this.commit(window) ?? window.entry ?? null;
5736
5962
  return window.entry;
5737
5963
  }
5738
5964
  /** Every pending write, open window and outside group, committed: for flush and close. */
@@ -5748,9 +5974,8 @@ var Engine = class {
5748
5974
  this.windows.push(group);
5749
5975
  try {
5750
5976
  for (const [path, hash] of target) {
5751
- if ((this.tracked.get(path)?.hash ?? null) === hash) continue;
5752
- if (hash === null) await rm2(join17(this.dir, path), { force: true });
5753
- else await this.blobs.writeTo(hash, join17(this.dir, path));
5977
+ if ((this.tracked.get(path)?.hash ?? null) !== hash)
5978
+ await this.writeProjectFile(path, hash);
5754
5979
  }
5755
5980
  await this.sweep();
5756
5981
  } finally {
@@ -5758,6 +5983,17 @@ var Engine = class {
5758
5983
  }
5759
5984
  return this.commit(group, extra);
5760
5985
  }
5986
+ /** Writes one project file (null deletes it), first leaving the running operation's receipt for its echo. */
5987
+ async writeProjectFile(path, hash) {
5988
+ const absPath = join19(this.dir, path);
5989
+ const { writeToken } = this;
5990
+ if (writeToken) {
5991
+ const version = hash === null ? DELETED_VERSION : hashVersion(hash);
5992
+ recordFileWriteReceipt(absPath, { path, version, writeToken });
5993
+ }
5994
+ if (hash === null) await rm2(absPath, { force: true });
5995
+ else await this.blobs.writeTo(hash, absPath);
5996
+ }
5761
5997
  entry(id) {
5762
5998
  const entry = this.log.entries.find((candidate) => candidate.id === id);
5763
5999
  if (!entry) throw new Error("That change is no longer kept in this project's history.");
@@ -5770,9 +6006,7 @@ var Engine = class {
5770
6006
  }
5771
6007
  async undoNow(id, who, mode) {
5772
6008
  const entry = this.entry(id);
5773
- const changed = entry.files.filter(
5774
- (file) => (this.tracked.get(file.path)?.hash ?? null) !== file.after
5775
- );
6009
+ const changed = this.movedOn(entry);
5776
6010
  if (changed.length && !mode) return { ok: false, conflict: this.conflict(entry, changed) };
5777
6011
  if (mode === "back-to-before") {
5778
6012
  const index = this.log.entries.indexOf(entry);
@@ -5788,6 +6022,19 @@ var Engine = class {
5788
6022
  entry: await this.writeAs(who, this.undoLabel(entry), target, { undoes: id })
5789
6023
  };
5790
6024
  }
6025
+ movedOn(entry) {
6026
+ return entry.files.filter((file) => (this.tracked.get(file.path)?.hash ?? null) !== file.after);
6027
+ }
6028
+ /** Back reverts the newest change in effect, unless its files moved on under an earlier-ending edit (a held drag
6029
+ * committed after an agent's turn): then that edit, if it still applies. */
6030
+ next(direction) {
6031
+ const top = stepTarget(this.log.entries, direction);
6032
+ const moved = new Set(top && direction === "back" ? this.movedOn(top).map((f) => f.path) : []);
6033
+ if (!top || !moved.size) return top;
6034
+ const undone = undoneIds(this.log.entries);
6035
+ 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)));
6036
+ return under && !this.movedOn(under).length ? under : top;
6037
+ }
5791
6038
  conflict(entry, changed) {
5792
6039
  const paths = new Set(changed.map((file) => file.path));
5793
6040
  const after = this.log.entries.slice(this.log.entries.indexOf(entry) + 1);
@@ -5803,6 +6050,18 @@ var Engine = class {
5803
6050
  for (const [path, hash] of files) target.set(path, hash);
5804
6051
  return this.writeAs(who, label, target, { restoredTo: point });
5805
6052
  }
6053
+ /** Settles, then runs `task` with its writes labelled `writeToken`. */
6054
+ operation(writeToken, task) {
6055
+ return this.queue(async () => {
6056
+ await this.settle();
6057
+ this.writeToken = writeToken;
6058
+ try {
6059
+ return await task();
6060
+ } finally {
6061
+ this.writeToken = void 0;
6062
+ }
6063
+ });
6064
+ }
5806
6065
  pointLabel(point) {
5807
6066
  return point === START ? "the start" : this.entry(point).label;
5808
6067
  }
@@ -5820,23 +6079,20 @@ var Engine = class {
5820
6079
  undone: undone.has(entry.id)
5821
6080
  }));
5822
6081
  },
5823
- step: (direction, who) => this.queue(async () => {
5824
- await this.settle();
5825
- const target = stepTarget(this.log.entries, direction);
6082
+ step: (direction, who, { writeToken } = {}) => this.operation(writeToken, async () => {
6083
+ const target = this.next(direction);
5826
6084
  return target ? this.undoNow(target.id, who) : { ok: true, entry: null };
5827
6085
  }),
5828
- undo: (id, { who, mode }) => this.queue(async () => {
5829
- await this.settle();
5830
- return this.undoNow(id, who, mode);
5831
- }),
5832
- restore: (point, who) => this.queue(async () => {
5833
- await this.settle();
5834
- return this.restoreNow(point, who, `Restored: ${this.pointLabel(point)}`);
5835
- }),
6086
+ undo: (id, { who, mode, writeToken }) => this.operation(writeToken, () => this.undoNow(id, who, mode)),
6087
+ restore: (point, who, { writeToken } = {}) => this.operation(
6088
+ writeToken,
6089
+ () => this.restoreNow(point, who, `Restored: ${this.pointLabel(point)}`)
6090
+ ),
5836
6091
  peek: (point) => {
5837
6092
  const files = manifestAt(this.log, point);
5838
6093
  return files && Object.fromEntries(files);
5839
6094
  },
6095
+ next: (direction) => this.next(direction),
5840
6096
  readBlob: (hash) => this.blobs.read(hash),
5841
6097
  pin: (id, pinned) => {
5842
6098
  this.entry(id);
@@ -5858,14 +6114,27 @@ var Engine = class {
5858
6114
  };
5859
6115
  async function openProjectHistory(options) {
5860
6116
  const projectId = projectHistoryId(options.projectDir, options.historyRoot);
5861
- const blobs = await openBlobStore(join17(options.historyRoot, projectId, "blobs"));
5862
- const engine = new Engine(options, projectId, blobs);
5863
- await engine.queue(() => engine.start());
5864
- return engine.api();
6117
+ const release = await takeHistoryOwnership(
6118
+ join19(options.historyRoot, projectId),
6119
+ options.ownerWaitMs ?? 5e3
6120
+ );
6121
+ try {
6122
+ const blobs = await openBlobStore(join19(options.historyRoot, projectId, "blobs"));
6123
+ const engine = new Engine(options, projectId, blobs);
6124
+ await engine.queue(() => engine.start());
6125
+ const api = engine.api();
6126
+ return {
6127
+ ...api,
6128
+ close: () => api.close().finally(release)
6129
+ };
6130
+ } catch (error) {
6131
+ release();
6132
+ throw error;
6133
+ }
5865
6134
  }
5866
6135
 
5867
6136
  // src/helpers/duplicateElement.ts
5868
- import { ensureHfIds as ensureHfIds3 } from "@hyperframes/parsers/hf-ids";
6137
+ import { ensureHfIds as ensureHfIds4 } from "@hyperframes/parsers/hf-ids";
5869
6138
  function duplicateElementInHtml(source, target, newId, at) {
5870
6139
  const { document, wrappedFragment } = parseSourceDocument(source);
5871
6140
  const element = findTargetElement(document, target);
@@ -5885,7 +6154,7 @@ function duplicateElementInHtml(source, target, newId, at) {
5885
6154
  clone.setAttribute("data-start", String(at));
5886
6155
  element.parentElement.insertBefore(clone, element.nextSibling);
5887
6156
  const html = wrappedFragment ? document.body.innerHTML || "" : document.toString();
5888
- return { html: ensureHfIds3(html), matched: true, newId: uniqueId };
6157
+ return { html: ensureHfIds4(html), matched: true, newId: uniqueId };
5889
6158
  }
5890
6159
  function nextUniqueId(document, newId) {
5891
6160
  let uniqueId = newId;
@@ -5915,6 +6184,35 @@ function numericAttribute(element, name) {
5915
6184
  return Number.isFinite(value) ? value : null;
5916
6185
  }
5917
6186
 
6187
+ // src/helpers/previewDocumentStore.ts
6188
+ import { createHash as createHash7 } from "crypto";
6189
+ import { mkdirSync as mkdirSync12, readFileSync as readFileSync19, readdirSync as readdirSync7, rmSync as rmSync6 } from "fs";
6190
+ import { join as join20 } from "path";
6191
+ function createPreviewDocumentStore(dir, salt) {
6192
+ const fileFor = (key) => join20(dir, `${createHash7("sha256").update(`${salt}
6193
+ ${key}`).digest("hex").slice(0, 32)}.html`);
6194
+ return {
6195
+ read(key) {
6196
+ try {
6197
+ return readFileSync19(fileFor(key), "utf-8");
6198
+ } catch {
6199
+ return null;
6200
+ }
6201
+ },
6202
+ write(key, html) {
6203
+ try {
6204
+ mkdirSync12(dir, { recursive: true });
6205
+ const file = fileFor(key);
6206
+ replaceFileAtomically(file, html, 420);
6207
+ for (const name of readdirSync7(dir)) {
6208
+ if (join20(dir, name) !== file) rmSync6(join20(dir, name), { force: true });
6209
+ }
6210
+ } catch {
6211
+ }
6212
+ }
6213
+ };
6214
+ }
6215
+
5918
6216
  // src/helpers/thumbnailOutput.ts
5919
6217
  function thumbnailDeviceScaleFactor({
5920
6218
  width,
@@ -5983,7 +6281,10 @@ function updateBackgroundRemovalProgress(state, event) {
5983
6281
  state.avgMsPerFrame = event.avgMsPerFrame;
5984
6282
  }
5985
6283
  export {
6284
+ DEFAULT_HISTORY_ROOT,
6285
+ DELETED_VERSION,
5986
6286
  START as HISTORY_START,
6287
+ HistoryBusyError,
5987
6288
  MIME_TYPES,
5988
6289
  PREVIEW_BUNDLE_OPTIONS,
5989
6290
  STUDIO_MANUAL_EDITS_PATH,
@@ -5994,6 +6295,7 @@ export {
5994
6295
  compositionsAffectedBy,
5995
6296
  consumeFileWriteReceipt,
5996
6297
  createBackgroundRemovalJob,
6298
+ createPreviewDocumentStore,
5997
6299
  createProjectSignature,
5998
6300
  createStudioApi,
5999
6301
  createStudioManualEditsRenderBodyScript,
@@ -6014,6 +6316,7 @@ export {
6014
6316
  requestSubPath,
6015
6317
  settledFileTag,
6016
6318
  splitElementInHtml,
6319
+ stampProjectHfIds,
6017
6320
  thumbnailDeviceScaleFactor,
6018
6321
  walkDir
6019
6322
  };