@hyperframes/studio-server 0.7.58 → 0.7.60

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
@@ -31,6 +31,153 @@ function walkDir(dir, prefix = "") {
31
31
  return files;
32
32
  }
33
33
 
34
+ // src/helpers/projectSignature.ts
35
+ import { createHash } from "crypto";
36
+ import { lstatSync, readFileSync, readdirSync as readdirSync2 } from "fs";
37
+ import { extname, isAbsolute, relative, resolve } from "path";
38
+ var SIGNATURE_TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
39
+ ".cjs",
40
+ ".css",
41
+ ".html",
42
+ ".js",
43
+ ".json",
44
+ ".jsx",
45
+ ".mjs",
46
+ ".svg",
47
+ ".ts",
48
+ ".tsx"
49
+ ]);
50
+ var SIGNATURE_EXCLUDED_DIRS = /* @__PURE__ */ new Set([
51
+ ".cache",
52
+ ".git",
53
+ ".hyperframes",
54
+ ".next",
55
+ ".vite",
56
+ "build",
57
+ "coverage",
58
+ "dist",
59
+ "node_modules",
60
+ "outputs",
61
+ "renders"
62
+ ]);
63
+ var MAX_SIGNATURE_TEXT_BYTES = 2e6;
64
+ var STUDIO_SIGNATURE_MANIFEST_PATHS = [
65
+ ".hyperframes/studio-manual-edits.json",
66
+ ".hyperframes/studio-motion.json"
67
+ ];
68
+ var projectSignatureCache = /* @__PURE__ */ new Map();
69
+ function isPathWithin(parentDir, childPath) {
70
+ const childRelativePath = relative(parentDir, childPath);
71
+ return childRelativePath === "" || !childRelativePath.startsWith("..") && !isAbsolute(childRelativePath);
72
+ }
73
+ function isTextContentEligible(file, size) {
74
+ return SIGNATURE_TEXT_EXTENSIONS.has(extname(file).toLowerCase()) && size <= MAX_SIGNATURE_TEXT_BYTES;
75
+ }
76
+ function collectProjectSignatureFiles(projectDir, dir, files) {
77
+ let entries;
78
+ try {
79
+ entries = readdirSync2(dir).sort();
80
+ } catch {
81
+ return;
82
+ }
83
+ for (const entry of entries) {
84
+ if (SIGNATURE_EXCLUDED_DIRS.has(entry)) continue;
85
+ const file = resolve(dir, entry);
86
+ if (!isPathWithin(projectDir, file)) continue;
87
+ let stat;
88
+ try {
89
+ stat = lstatSync(file);
90
+ } catch {
91
+ continue;
92
+ }
93
+ if (stat.isSymbolicLink()) continue;
94
+ if (stat.isDirectory()) {
95
+ collectProjectSignatureFiles(projectDir, file, files);
96
+ } else if (stat.isFile()) {
97
+ files.push({
98
+ file,
99
+ mtimeMs: stat.mtimeMs,
100
+ size: stat.size,
101
+ textContentEligible: isTextContentEligible(file, stat.size)
102
+ });
103
+ }
104
+ }
105
+ }
106
+ function collectProjectSignatureManifestFiles(projectDir, files) {
107
+ const seen = new Set(files.map((entry) => entry.file));
108
+ for (const manifestPath of STUDIO_SIGNATURE_MANIFEST_PATHS) {
109
+ const file = resolve(projectDir, manifestPath);
110
+ if (seen.has(file) || !isPathWithin(projectDir, file)) continue;
111
+ let stat;
112
+ try {
113
+ stat = lstatSync(file);
114
+ } catch {
115
+ continue;
116
+ }
117
+ if (stat.isSymbolicLink() || !stat.isFile()) continue;
118
+ files.push({
119
+ file,
120
+ mtimeMs: stat.mtimeMs,
121
+ size: stat.size,
122
+ textContentEligible: isTextContentEligible(file, stat.size)
123
+ });
124
+ seen.add(file);
125
+ }
126
+ }
127
+ function createProjectFingerprint(projectDir, files) {
128
+ const hash = createHash("sha256");
129
+ for (const entry of files) {
130
+ hash.update(relative(projectDir, entry.file));
131
+ hash.update("\0");
132
+ hash.update(String(entry.size));
133
+ hash.update("\0");
134
+ hash.update(String(entry.mtimeMs));
135
+ hash.update("\0");
136
+ hash.update(entry.textContentEligible ? "text" : "binary");
137
+ hash.update("\0");
138
+ }
139
+ return hash.digest("hex").slice(0, 24);
140
+ }
141
+ function resolveProjectSignature(adapter, projectDir) {
142
+ return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir);
143
+ }
144
+ async function resolveProjectAndSignature(adapter, projectId) {
145
+ const project = await adapter.resolveProject(projectId);
146
+ if (!project) return null;
147
+ return { project, signature: resolveProjectSignature(adapter, project.dir) };
148
+ }
149
+ function createProjectSignature(projectDir) {
150
+ const normalizedProjectDir = resolve(projectDir);
151
+ const files = [];
152
+ collectProjectSignatureFiles(normalizedProjectDir, normalizedProjectDir, files);
153
+ collectProjectSignatureManifestFiles(normalizedProjectDir, files);
154
+ files.sort((a, b) => a.file.localeCompare(b.file));
155
+ const fingerprint = createProjectFingerprint(normalizedProjectDir, files);
156
+ const cached = projectSignatureCache.get(normalizedProjectDir);
157
+ if (cached?.fingerprint === fingerprint) return cached.signature;
158
+ const hash = createHash("sha256");
159
+ for (const entry of files) {
160
+ const relativePath = relative(normalizedProjectDir, entry.file);
161
+ hash.update(relativePath);
162
+ hash.update("\0");
163
+ hash.update(String(entry.size));
164
+ hash.update("\0");
165
+ if (entry.textContentEligible) {
166
+ try {
167
+ hash.update(readFileSync(entry.file));
168
+ } catch {
169
+ hash.update(String(entry.mtimeMs));
170
+ }
171
+ } else {
172
+ hash.update(String(entry.mtimeMs));
173
+ }
174
+ hash.update("\0");
175
+ }
176
+ const signature = hash.digest("hex").slice(0, 24);
177
+ projectSignatureCache.set(normalizedProjectDir, { fingerprint, signature });
178
+ return signature;
179
+ }
180
+
34
181
  // src/routes/projects.ts
35
182
  var COMPOSITION_ID_RE = /data-composition-id\s*=/;
36
183
  async function filterCompositionFiles(projectDir, files) {
@@ -61,6 +208,11 @@ function registerProjectRoutes(api, adapter) {
61
208
  if (!result) return c.json({ error: "Session not found" }, 404);
62
209
  return c.json(result);
63
210
  });
211
+ api.get("/projects/:id/signature", async (c) => {
212
+ const project = await adapter.resolveProject(c.req.param("id"));
213
+ if (!project) return c.json({ error: "not found" }, 404);
214
+ return c.json({ signature: resolveProjectSignature(adapter, project.dir) });
215
+ });
64
216
  api.get("/projects/:id", async (c) => {
65
217
  const project = await adapter.resolveProject(c.req.param("id"));
66
218
  if (!project) return c.json({ error: "not found" }, 404);
@@ -71,7 +223,7 @@ function registerProjectRoutes(api, adapter) {
71
223
  }
72
224
 
73
225
  // src/routes/storyboard.ts
74
- import { existsSync, readFileSync } from "fs";
226
+ import { existsSync, readFileSync as readFileSync2 } from "fs";
75
227
  import {
76
228
  parseStoryboard,
77
229
  SCRIPT_FILENAME,
@@ -91,7 +243,7 @@ function readScript(projectDir) {
91
243
  const abs = resolveWithinProject(projectDir, SCRIPT_FILENAME);
92
244
  if (abs && existsSync(abs)) {
93
245
  try {
94
- return { exists: true, path: SCRIPT_FILENAME, content: readFileSync(abs, "utf-8") };
246
+ return { exists: true, path: SCRIPT_FILENAME, content: readFileSync2(abs, "utf-8") };
95
247
  } catch {
96
248
  }
97
249
  }
@@ -99,8 +251,9 @@ function readScript(projectDir) {
99
251
  }
100
252
  function registerStoryboardRoutes(api, adapter) {
101
253
  api.get("/projects/:id/storyboard", async (c) => {
102
- const project = await adapter.resolveProject(c.req.param("id"));
103
- if (!project) return c.json({ error: "not found" }, 404);
254
+ const resolved = await resolveProjectAndSignature(adapter, c.req.param("id"));
255
+ if (!resolved) return c.json({ error: "not found" }, 404);
256
+ const { project, signature } = resolved;
104
257
  const abs = resolveWithinProject(project.dir, STORYBOARD_FILENAME);
105
258
  if (!abs || !existsSync(abs)) {
106
259
  return c.json({
@@ -109,12 +262,13 @@ function registerStoryboardRoutes(api, adapter) {
109
262
  globals: { extra: {} },
110
263
  frames: [],
111
264
  warnings: [],
112
- script: readScript(project.dir)
265
+ script: readScript(project.dir),
266
+ signature
113
267
  });
114
268
  }
115
269
  let source;
116
270
  try {
117
- source = readFileSync(abs, "utf-8");
271
+ source = readFileSync2(abs, "utf-8");
118
272
  } catch {
119
273
  return c.json({ error: "failed to read storyboard" }, 500);
120
274
  }
@@ -125,7 +279,8 @@ function registerStoryboardRoutes(api, adapter) {
125
279
  globals: manifest.globals,
126
280
  frames: resolveFrames(project.dir, manifest.frames),
127
281
  warnings: manifest.warnings,
128
- script: readScript(project.dir)
282
+ script: readScript(project.dir),
283
+ signature
129
284
  });
130
285
  });
131
286
  }
@@ -133,17 +288,21 @@ function registerStoryboardRoutes(api, adapter) {
133
288
  // src/routes/files.ts
134
289
  import { bodyLimit } from "hono/body-limit";
135
290
  import {
291
+ closeSync,
136
292
  existsSync as existsSync3,
137
- readFileSync as readFileSync3,
293
+ ftruncateSync,
294
+ openSync,
295
+ readFileSync as readFileSync4,
138
296
  writeFileSync as writeFileSync4,
297
+ writeSync,
139
298
  mkdirSync as mkdirSync3,
140
299
  unlinkSync as unlinkSync2,
141
300
  rmSync as rmSync2,
142
301
  statSync,
143
302
  renameSync,
144
- readdirSync as readdirSync3
303
+ readdirSync as readdirSync4
145
304
  } from "fs";
146
- import { resolve as resolve2, dirname, join as join6 } from "path";
305
+ import { resolve as resolve3, dirname, join as join6 } from "path";
147
306
 
148
307
  // src/helpers/mime.ts
149
308
  var MIME_TYPES = {
@@ -188,7 +347,7 @@ function isAudioFile(name) {
188
347
  // src/helpers/waveform.ts
189
348
  import { spawn } from "child_process";
190
349
  import { existsSync as existsSync2, writeFileSync, mkdirSync } from "fs";
191
- import { join as join3, resolve } from "path";
350
+ import { join as join3, resolve as resolve2 } from "path";
192
351
  var SAMPLE_RATE = 4e3;
193
352
  var PEAK_COUNT = 4e3;
194
353
  var WAVEFORM_CACHE_VERSION = "v2";
@@ -213,7 +372,7 @@ function computePeaks(floats, count) {
213
372
  }
214
373
  function ffmpegBinary() {
215
374
  const configured = process.env.HYPERFRAMES_FFMPEG_PATH?.trim();
216
- if (configured) return resolve(configured);
375
+ if (configured) return resolve2(configured);
217
376
  return "ffmpeg";
218
377
  }
219
378
  function decodeAudioPeaks(audioPath) {
@@ -322,9 +481,9 @@ function validateUploadedMediaBuffer(fileName, buffer, runner = spawnSync) {
322
481
  }
323
482
 
324
483
  // src/helpers/backupJournal.ts
325
- import { mkdirSync as mkdirSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
484
+ import { mkdirSync as mkdirSync2, readdirSync as readdirSync3, readFileSync as readFileSync3, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
326
485
  import { Buffer as Buffer2 } from "buffer";
327
- import { join as join5, relative } from "path";
486
+ import { join as join5, relative as relative2 } from "path";
328
487
  var DEFAULT_KEEP_PER_FILE = 10;
329
488
  function backupKeyForPath(path) {
330
489
  return Buffer2.from(path, "utf-8").toString("base64url");
@@ -334,15 +493,15 @@ function timestampPrefix() {
334
493
  }
335
494
  function backupPathForResponse(projectDir, backupPath) {
336
495
  if (!backupPath) return null;
337
- const rel = relative(projectDir, backupPath);
496
+ const rel = relative2(projectDir, backupPath);
338
497
  if (!rel || rel.startsWith("..")) return null;
339
498
  return rel.split("\\").join("/");
340
499
  }
341
500
  function snapshotBeforeWrite(projectDir, absPath, options = {}) {
342
501
  if (!isSafePath(projectDir, absPath)) return { backupPath: null };
343
502
  try {
344
- const content = readFileSync2(absPath);
345
- const relativePath = relative(projectDir, absPath);
503
+ const content = readFileSync3(absPath);
504
+ const relativePath = relative2(projectDir, absPath);
346
505
  const backupDir = join5(projectDir, ".hyperframes", "backup");
347
506
  mkdirSync2(backupDir, { recursive: true });
348
507
  const backupKey = backupKeyForPath(relativePath);
@@ -363,7 +522,7 @@ function nextBackupPath(backupDir, backupKey) {
363
522
  let counter = 2;
364
523
  while (true) {
365
524
  try {
366
- readFileSync2(candidate);
525
+ readFileSync3(candidate);
367
526
  } catch (error) {
368
527
  if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
369
528
  return candidate;
@@ -378,7 +537,7 @@ function pruneBackups(backupDir, backupKey, keepPerFile) {
378
537
  const keep = Math.max(1, Math.floor(keepPerFile));
379
538
  const suffix = `-${backupKey}`;
380
539
  const numberedSuffix = new RegExp(`-${backupKey}-\\d+$`);
381
- const matches = readdirSync2(backupDir).filter((name) => name.endsWith(suffix) || numberedSuffix.test(name)).map((name) => join5(backupDir, name)).sort((a, b) => {
540
+ const matches = readdirSync3(backupDir).filter((name) => name.endsWith(suffix) || numberedSuffix.test(name)).map((name) => join5(backupDir, name)).sort((a, b) => {
382
541
  return b.localeCompare(a);
383
542
  });
384
543
  for (const file of matches.slice(keep)) {
@@ -389,6 +548,38 @@ function pruneBackups(backupDir, backupKey, keepPerFile) {
389
548
  }
390
549
  }
391
550
 
551
+ // src/helpers/fileVersion.ts
552
+ import { createHash as createHash2, randomUUID } from "crypto";
553
+ var RECEIPT_TTL_MS = 1e4;
554
+ var receipts = /* @__PURE__ */ new Map();
555
+ function fileContentVersion(content) {
556
+ return `"sha256:${createHash2("sha256").update(content, "utf8").digest("hex")}"`;
557
+ }
558
+ function createWriteToken(requestToken) {
559
+ const token = requestToken?.trim();
560
+ return token && token.length <= 200 ? token : randomUUID();
561
+ }
562
+ function recordFileWriteReceipt(absPath, receipt) {
563
+ const now = Date.now();
564
+ const current = (receipts.get(absPath) ?? []).filter(
565
+ (entry) => now - entry.recordedAt < RECEIPT_TTL_MS
566
+ );
567
+ current.push({ ...receipt, recordedAt: now });
568
+ receipts.set(absPath, current);
569
+ }
570
+ function consumeFileWriteReceipt(absPath) {
571
+ const now = Date.now();
572
+ const current = (receipts.get(absPath) ?? []).filter(
573
+ (entry) => now - entry.recordedAt < RECEIPT_TTL_MS
574
+ );
575
+ const receipt = current.shift() ?? null;
576
+ if (current.length > 0) receipts.set(absPath, current);
577
+ else receipts.delete(absPath);
578
+ if (!receipt) return null;
579
+ const { path, version, writeToken } = receipt;
580
+ return { path, version, writeToken };
581
+ }
582
+
392
583
  // src/helpers/finiteMutation.ts
393
584
  function findUnsafeMutationValues(value, path = "body", options = {}) {
394
585
  if (value === null) {
@@ -931,7 +1122,7 @@ function commitElementPatchBatches(projectDir, batches, writeFile = writeFileSyn
931
1122
  resolvedPaths.add(absPath);
932
1123
  let before;
933
1124
  try {
934
- before = readFileSync3(absPath, "utf-8");
1125
+ before = readFileSync4(absPath, "utf-8");
935
1126
  } catch {
936
1127
  return { error: "not-found", sourceFile: batch.sourceFile };
937
1128
  }
@@ -1053,7 +1244,7 @@ function generateCopyPath(projectDir, originalPath) {
1053
1244
  const cleanBase = copyMatch ? base.slice(0, -copyMatch[0].length) : base;
1054
1245
  let num = copyMatch ? copyMatch[1] ? parseInt(copyMatch[1]) + 1 : 2 : 1;
1055
1246
  let candidate = num === 1 ? `${cleanBase} (copy)${ext}` : `${cleanBase} (copy ${num})${ext}`;
1056
- while (existsSync3(resolve2(projectDir, candidate))) {
1247
+ while (existsSync3(resolve3(projectDir, candidate))) {
1057
1248
  num++;
1058
1249
  candidate = `${cleanBase} (copy ${num})${ext}`;
1059
1250
  }
@@ -1061,7 +1252,7 @@ function generateCopyPath(projectDir, originalPath) {
1061
1252
  }
1062
1253
  function walkFiles(dir, filter) {
1063
1254
  const results = [];
1064
- for (const entry of readdirSync3(dir, { withFileTypes: true })) {
1255
+ for (const entry of readdirSync4(dir, { withFileTypes: true })) {
1065
1256
  const full = join6(dir, entry.name);
1066
1257
  if (entry.isDirectory()) {
1067
1258
  if (entry.name === "node_modules" || entry.name === ".thumbnails" || entry.name === "renders")
@@ -1080,7 +1271,7 @@ function updateReferences(projectDir, oldPath, newPath) {
1080
1271
  );
1081
1272
  let updatedCount = 0;
1082
1273
  for (const file of textFiles) {
1083
- const content = readFileSync3(file, "utf-8");
1274
+ const content = readFileSync4(file, "utf-8");
1084
1275
  if (!content.includes(oldPath)) continue;
1085
1276
  const updated = content.split(oldPath).join(newPath);
1086
1277
  if (updated !== content) {
@@ -1337,7 +1528,7 @@ function validateGsapMutationRequest(c, body) {
1337
1528
  return null;
1338
1529
  }
1339
1530
  async function prepareGsapMutationScript(c, res, firstMutation) {
1340
- const beforeHtml = readFileSync3(res.absPath, "utf-8");
1531
+ const beforeHtml = readFileSync4(res.absPath, "utf-8");
1341
1532
  let html = beforeHtml;
1342
1533
  let block = extractGsapScriptBlock(html);
1343
1534
  if (!block && (firstMutation.type === "add" || firstMutation.type === "add-with-keyframes")) {
@@ -1397,7 +1588,7 @@ async function applyGsapMutations(c, res, mutations) {
1397
1588
  const changed = block.scriptText !== initialScript;
1398
1589
  const newHtml = changed ? block.replaceScript(block.scriptText) : html;
1399
1590
  let backupPath = null;
1400
- if (readFileSync3(res.absPath, "utf-8") !== beforeHtml) {
1591
+ if (readFileSync4(res.absPath, "utf-8") !== beforeHtml) {
1401
1592
  return c.json({ error: "file changed during GSAP mutation", conflict: true }, 409);
1402
1593
  }
1403
1594
  if (changed) {
@@ -1415,9 +1606,11 @@ async function applyGsapMutations(c, res, mutations) {
1415
1606
  after: newHtml,
1416
1607
  scriptText: block.scriptText,
1417
1608
  path: res.filePath,
1609
+ version: fileContentVersion(newHtml),
1418
1610
  backupPath
1419
1611
  };
1420
1612
  if (skippedSelectors.size > 0) responsePayload.skippedSelectors = [...skippedSelectors];
1613
+ c.header("ETag", responsePayload.version);
1421
1614
  return c.json(responsePayload);
1422
1615
  }
1423
1616
  function executeGsapMutationAcorn(body, block, respond) {
@@ -2033,7 +2226,7 @@ async function processUploadedFiles(formData, targetDir, projectDir) {
2033
2226
  skipped.push(name);
2034
2227
  continue;
2035
2228
  }
2036
- const destPath = resolve2(targetDir, name);
2229
+ const destPath = resolve3(targetDir, name);
2037
2230
  if (!isSafePath(projectDir, destPath)) continue;
2038
2231
  let finalPath = destPath;
2039
2232
  let finalName = name;
@@ -2043,13 +2236,13 @@ async function processUploadedFiles(formData, targetDir, projectDir) {
2043
2236
  const base = dotIdx > 0 ? name.slice(0, dotIdx) : name;
2044
2237
  let n = 2;
2045
2238
  const MAX_COPY_INDEX = 1e4;
2046
- while (n < MAX_COPY_INDEX && existsSync3(resolve2(targetDir, `${base} (${n})${ext}`))) n++;
2239
+ while (n < MAX_COPY_INDEX && existsSync3(resolve3(targetDir, `${base} (${n})${ext}`))) n++;
2047
2240
  if (n >= MAX_COPY_INDEX) {
2048
2241
  skipped.push(name);
2049
2242
  continue;
2050
2243
  }
2051
2244
  finalName = `${base} (${n})${ext}`;
2052
- finalPath = resolve2(targetDir, finalName);
2245
+ finalPath = resolve3(targetDir, finalName);
2053
2246
  }
2054
2247
  const buffer = Buffer.from(await value.arrayBuffer());
2055
2248
  const validation = validateUploadedMediaBuffer(finalName, buffer);
@@ -2077,20 +2270,112 @@ function registerFileRoutes(api, adapter) {
2077
2270
  }
2078
2271
  return c.json({ error: "not found" }, 404);
2079
2272
  }
2080
- const content = readFileSync3(res.absPath, "utf-8");
2081
- return c.json({ filename: res.filePath, content });
2273
+ const content = readFileSync4(res.absPath, "utf-8");
2274
+ const version = fileContentVersion(content);
2275
+ c.header("ETag", version);
2276
+ return c.json({ filename: res.filePath, content, version });
2082
2277
  });
2083
2278
  api.put("/projects/:id/files/*", async (c) => {
2084
2279
  const res = await resolveProjectFile(c, adapter);
2085
2280
  if ("error" in res) return res.error;
2086
- ensureDir(res.absPath);
2087
2281
  const body = await c.req.text();
2088
- const backup = snapshotBeforeWrite(res.project.dir, res.absPath);
2089
- if (backup.error) console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`);
2090
- writeFileSync4(res.absPath, body, "utf-8");
2282
+ const expectedVersion = c.req.header("If-Match")?.trim() ?? null;
2283
+ const createOnly = c.req.header("If-None-Match")?.trim() === "*";
2284
+ if (expectedVersion === null && !createOnly) {
2285
+ let currentContent = null;
2286
+ try {
2287
+ currentContent = readFileSync4(res.absPath, "utf-8");
2288
+ } catch (error) {
2289
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
2290
+ throw error;
2291
+ }
2292
+ }
2293
+ return c.json(
2294
+ {
2295
+ error: "precondition required",
2296
+ path: res.filePath,
2297
+ currentVersion: currentContent === null ? null : fileContentVersion(currentContent),
2298
+ currentContent
2299
+ },
2300
+ 428
2301
+ );
2302
+ }
2303
+ let backup = { backupPath: null };
2304
+ if (createOnly) {
2305
+ ensureDir(res.absPath);
2306
+ let fd;
2307
+ try {
2308
+ fd = openSync(res.absPath, "wx");
2309
+ } catch (error) {
2310
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "EEXIST") {
2311
+ throw error;
2312
+ }
2313
+ const currentContent = readFileSync4(res.absPath, "utf-8");
2314
+ return c.json(
2315
+ {
2316
+ error: "file conflict",
2317
+ path: res.filePath,
2318
+ currentVersion: fileContentVersion(currentContent),
2319
+ currentContent
2320
+ },
2321
+ 409
2322
+ );
2323
+ }
2324
+ try {
2325
+ writeSync(fd, body, 0, "utf-8");
2326
+ } finally {
2327
+ closeSync(fd);
2328
+ }
2329
+ } else {
2330
+ let fd;
2331
+ try {
2332
+ fd = openSync(res.absPath, "r+");
2333
+ } catch (error) {
2334
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
2335
+ throw error;
2336
+ }
2337
+ return c.json(
2338
+ {
2339
+ error: "file conflict",
2340
+ path: res.filePath,
2341
+ currentVersion: null,
2342
+ currentContent: null
2343
+ },
2344
+ 409
2345
+ );
2346
+ }
2347
+ try {
2348
+ const currentContent = readFileSync4(fd, "utf-8");
2349
+ const currentVersion = fileContentVersion(currentContent);
2350
+ if (expectedVersion !== currentVersion) {
2351
+ return c.json(
2352
+ {
2353
+ error: "file conflict",
2354
+ path: res.filePath,
2355
+ currentVersion,
2356
+ currentContent
2357
+ },
2358
+ 409
2359
+ );
2360
+ }
2361
+ backup = snapshotBeforeWrite(res.project.dir, res.absPath);
2362
+ if (backup.error)
2363
+ console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`);
2364
+ ftruncateSync(fd, 0);
2365
+ writeSync(fd, body, 0, "utf-8");
2366
+ } finally {
2367
+ closeSync(fd);
2368
+ }
2369
+ }
2370
+ const version = fileContentVersion(body);
2371
+ const writeToken = createWriteToken(c.req.header("X-Hyperframes-Write-Token"));
2372
+ recordFileWriteReceipt(res.absPath, { path: res.filePath, version, writeToken });
2373
+ c.header("ETag", version);
2091
2374
  return c.json({
2092
2375
  ok: true,
2093
2376
  path: res.filePath,
2377
+ version,
2378
+ writeToken,
2094
2379
  backupPath: backupPathForResponse(res.project.dir, backup.backupPath)
2095
2380
  });
2096
2381
  });
@@ -2129,7 +2414,7 @@ function registerFileRoutes(api, adapter) {
2129
2414
  }
2130
2415
  const parsed = await parseMutationBody(c);
2131
2416
  if ("error" in parsed) return parsed.error;
2132
- const originalContent = readFileSync3(ctx.absPath, "utf-8");
2417
+ const originalContent = readFileSync4(ctx.absPath, "utf-8");
2133
2418
  return writeIfChanged(
2134
2419
  c,
2135
2420
  ctx.project.dir,
@@ -2150,7 +2435,7 @@ function registerFileRoutes(api, adapter) {
2150
2435
  const fallbackTiming = typeof parsed.body.elementStart === "number" && typeof parsed.body.elementDuration === "number" ? { start: parsed.body.elementStart, duration: parsed.body.elementDuration } : void 0;
2151
2436
  let originalContent;
2152
2437
  try {
2153
- originalContent = readFileSync3(ctx.absPath, "utf-8");
2438
+ originalContent = readFileSync4(ctx.absPath, "utf-8");
2154
2439
  } catch {
2155
2440
  return c.json({ error: "not found" }, 404);
2156
2441
  }
@@ -2162,17 +2447,28 @@ function registerFileRoutes(api, adapter) {
2162
2447
  fallbackTiming
2163
2448
  );
2164
2449
  if (!result.matched) {
2165
- return c.json({ ok: false, changed: false, content: originalContent, path: ctx.filePath });
2450
+ const version2 = fileContentVersion(originalContent);
2451
+ c.header("ETag", version2);
2452
+ return c.json({
2453
+ ok: false,
2454
+ changed: false,
2455
+ content: originalContent,
2456
+ path: ctx.filePath,
2457
+ version: version2
2458
+ });
2166
2459
  }
2167
2460
  const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath);
2168
2461
  if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`);
2169
2462
  writeFileSync4(ctx.absPath, result.html, "utf-8");
2463
+ const version = fileContentVersion(result.html);
2464
+ c.header("ETag", version);
2170
2465
  return c.json({
2171
2466
  ok: true,
2172
2467
  changed: true,
2173
2468
  content: result.html,
2174
2469
  newId: result.newId,
2175
2470
  path: ctx.filePath,
2471
+ version,
2176
2472
  backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath)
2177
2473
  });
2178
2474
  });
@@ -2190,7 +2486,7 @@ function registerFileRoutes(api, adapter) {
2190
2486
  }
2191
2487
  let originalContent;
2192
2488
  try {
2193
- originalContent = readFileSync3(ctx.absPath, "utf-8");
2489
+ originalContent = readFileSync4(ctx.absPath, "utf-8");
2194
2490
  } catch {
2195
2491
  return c.json({ error: "not found" }, 404);
2196
2492
  }
@@ -2280,7 +2576,7 @@ function registerFileRoutes(api, adapter) {
2280
2576
  }
2281
2577
  let originalContent;
2282
2578
  try {
2283
- originalContent = readFileSync3(ctx.absPath, "utf-8");
2579
+ originalContent = readFileSync4(ctx.absPath, "utf-8");
2284
2580
  } catch {
2285
2581
  return c.json({ error: "not found" }, 404);
2286
2582
  }
@@ -2322,7 +2618,7 @@ function registerFileRoutes(api, adapter) {
2322
2618
  if ("error" in parsed) return parsed.error;
2323
2619
  let originalContent;
2324
2620
  try {
2325
- originalContent = readFileSync3(ctx.absPath, "utf-8");
2621
+ originalContent = readFileSync4(ctx.absPath, "utf-8");
2326
2622
  } catch {
2327
2623
  return c.json({ error: "not found" }, 404);
2328
2624
  }
@@ -2351,7 +2647,7 @@ function registerFileRoutes(api, adapter) {
2351
2647
  if ("error" in parsed) return parsed.error;
2352
2648
  let content;
2353
2649
  try {
2354
- content = readFileSync3(ctx.absPath, "utf-8");
2650
+ content = readFileSync4(ctx.absPath, "utf-8");
2355
2651
  } catch {
2356
2652
  return c.json({ exists: false });
2357
2653
  }
@@ -2394,7 +2690,7 @@ function registerFileRoutes(api, adapter) {
2394
2690
  return c.json({ error: "forbidden" }, 403);
2395
2691
  }
2396
2692
  ensureDir(destAbs);
2397
- writeFileSync4(destAbs, readFileSync3(srcAbs));
2693
+ writeFileSync4(destAbs, readFileSync4(srcAbs));
2398
2694
  return c.json({ ok: true, path: copyPath }, 201);
2399
2695
  });
2400
2696
  const MAX_UPLOAD_BYTES = 500 * 1024 * 1024;
@@ -2424,7 +2720,7 @@ function registerFileRoutes(api, adapter) {
2424
2720
  mustExist: true
2425
2721
  });
2426
2722
  if ("error" in res) return res.error;
2427
- const html = readFileSync3(res.absPath, "utf-8");
2723
+ const html = readFileSync4(res.absPath, "utf-8");
2428
2724
  const block = extractGsapScriptBlock(html);
2429
2725
  if (!block) {
2430
2726
  return c.json({
@@ -2483,7 +2779,7 @@ function registerFileRoutes(api, adapter) {
2483
2779
  if (!body || typeof body.expected !== "string" || typeof body.restore !== "string") {
2484
2780
  return c.json({ error: "expected and restore contents required" }, 400);
2485
2781
  }
2486
- const current = readFileSync3(res.absPath, "utf-8");
2782
+ const current = readFileSync4(res.absPath, "utf-8");
2487
2783
  if (current !== body.expected) {
2488
2784
  return c.json({ ok: true, restored: false, conflict: true });
2489
2785
  }
@@ -2495,11 +2791,11 @@ function registerFileRoutes(api, adapter) {
2495
2791
  // src/routes/preview.ts
2496
2792
  import { existsSync as existsSync5, readFileSync as readFileSync7, statSync as statSync2 } from "fs";
2497
2793
  import { join as join8 } from "path";
2498
- import { createHash as createHash2 } from "crypto";
2794
+ import { createHash as createHash3 } from "crypto";
2499
2795
  import { injectScriptsIntoHtml, stripEmbeddedRuntimeScripts as stripEmbeddedRuntimeScripts2 } from "@hyperframes/core/compiler";
2500
2796
 
2501
2797
  // src/helpers/subComposition.ts
2502
- import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
2798
+ import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
2503
2799
  import { join as join7 } from "path";
2504
2800
  import { parseHTML as parseHTML3 } from "linkedom";
2505
2801
  import {
@@ -2608,7 +2904,7 @@ function tagRootCompositionFile(bodyHtml, compPath) {
2608
2904
  function buildSubCompositionHtml(projectDir, compPath, runtimeUrl, baseHref, rawOverride) {
2609
2905
  const compFile = join7(projectDir, compPath);
2610
2906
  if (!existsSync4(compFile)) return null;
2611
- const rawComp = rawOverride ?? readFileSync4(compFile, "utf-8");
2907
+ const rawComp = rawOverride ?? readFileSync5(compFile, "utf-8");
2612
2908
  let compHeadContent = "";
2613
2909
  let rewrittenContent;
2614
2910
  let htmlAttrs = "";
@@ -2641,7 +2937,7 @@ function buildSubCompositionHtml(projectDir, compPath, runtimeUrl, baseHref, raw
2641
2937
  const indexPath = join7(projectDir, "index.html");
2642
2938
  let headContent = "";
2643
2939
  if (existsSync4(indexPath)) {
2644
- const indexHtml = readFileSync4(indexPath, "utf-8");
2940
+ const indexHtml = readFileSync5(indexPath, "utf-8");
2645
2941
  const headMatch = indexHtml.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
2646
2942
  headContent = headMatch?.[1] ?? "";
2647
2943
  }
@@ -2674,145 +2970,6 @@ ${rewrittenContent}
2674
2970
  </html>`;
2675
2971
  }
2676
2972
 
2677
- // src/helpers/projectSignature.ts
2678
- import { createHash } from "crypto";
2679
- import { lstatSync, readFileSync as readFileSync5, readdirSync as readdirSync4 } from "fs";
2680
- import { extname, isAbsolute, relative as relative2, resolve as resolve3 } from "path";
2681
- var SIGNATURE_TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
2682
- ".cjs",
2683
- ".css",
2684
- ".html",
2685
- ".js",
2686
- ".json",
2687
- ".jsx",
2688
- ".mjs",
2689
- ".svg",
2690
- ".ts",
2691
- ".tsx"
2692
- ]);
2693
- var SIGNATURE_EXCLUDED_DIRS = /* @__PURE__ */ new Set([
2694
- ".cache",
2695
- ".git",
2696
- ".hyperframes",
2697
- ".next",
2698
- ".vite",
2699
- "build",
2700
- "coverage",
2701
- "dist",
2702
- "node_modules",
2703
- "outputs",
2704
- "renders"
2705
- ]);
2706
- var MAX_SIGNATURE_TEXT_BYTES = 2e6;
2707
- var STUDIO_SIGNATURE_MANIFEST_PATHS = [
2708
- ".hyperframes/studio-manual-edits.json",
2709
- ".hyperframes/studio-motion.json"
2710
- ];
2711
- var projectSignatureCache = /* @__PURE__ */ new Map();
2712
- function isPathWithin(parentDir, childPath) {
2713
- const childRelativePath = relative2(parentDir, childPath);
2714
- return childRelativePath === "" || !childRelativePath.startsWith("..") && !isAbsolute(childRelativePath);
2715
- }
2716
- function isTextContentEligible(file, size) {
2717
- return SIGNATURE_TEXT_EXTENSIONS.has(extname(file).toLowerCase()) && size <= MAX_SIGNATURE_TEXT_BYTES;
2718
- }
2719
- function collectProjectSignatureFiles(projectDir, dir, files) {
2720
- let entries;
2721
- try {
2722
- entries = readdirSync4(dir).sort();
2723
- } catch {
2724
- return;
2725
- }
2726
- for (const entry of entries) {
2727
- if (SIGNATURE_EXCLUDED_DIRS.has(entry)) continue;
2728
- const file = resolve3(dir, entry);
2729
- if (!isPathWithin(projectDir, file)) continue;
2730
- let stat;
2731
- try {
2732
- stat = lstatSync(file);
2733
- } catch {
2734
- continue;
2735
- }
2736
- if (stat.isSymbolicLink()) continue;
2737
- if (stat.isDirectory()) {
2738
- collectProjectSignatureFiles(projectDir, file, files);
2739
- } else if (stat.isFile()) {
2740
- files.push({
2741
- file,
2742
- mtimeMs: stat.mtimeMs,
2743
- size: stat.size,
2744
- textContentEligible: isTextContentEligible(file, stat.size)
2745
- });
2746
- }
2747
- }
2748
- }
2749
- function collectProjectSignatureManifestFiles(projectDir, files) {
2750
- const seen = new Set(files.map((entry) => entry.file));
2751
- for (const manifestPath of STUDIO_SIGNATURE_MANIFEST_PATHS) {
2752
- const file = resolve3(projectDir, manifestPath);
2753
- if (seen.has(file) || !isPathWithin(projectDir, file)) continue;
2754
- let stat;
2755
- try {
2756
- stat = lstatSync(file);
2757
- } catch {
2758
- continue;
2759
- }
2760
- if (stat.isSymbolicLink() || !stat.isFile()) continue;
2761
- files.push({
2762
- file,
2763
- mtimeMs: stat.mtimeMs,
2764
- size: stat.size,
2765
- textContentEligible: isTextContentEligible(file, stat.size)
2766
- });
2767
- seen.add(file);
2768
- }
2769
- }
2770
- function createProjectFingerprint(projectDir, files) {
2771
- const hash = createHash("sha256");
2772
- for (const entry of files) {
2773
- hash.update(relative2(projectDir, entry.file));
2774
- hash.update("\0");
2775
- hash.update(String(entry.size));
2776
- hash.update("\0");
2777
- hash.update(String(entry.mtimeMs));
2778
- hash.update("\0");
2779
- hash.update(entry.textContentEligible ? "text" : "binary");
2780
- hash.update("\0");
2781
- }
2782
- return hash.digest("hex").slice(0, 24);
2783
- }
2784
- function createProjectSignature(projectDir) {
2785
- const normalizedProjectDir = resolve3(projectDir);
2786
- const files = [];
2787
- collectProjectSignatureFiles(normalizedProjectDir, normalizedProjectDir, files);
2788
- collectProjectSignatureManifestFiles(normalizedProjectDir, files);
2789
- files.sort((a, b) => a.file.localeCompare(b.file));
2790
- const fingerprint = createProjectFingerprint(normalizedProjectDir, files);
2791
- const cached = projectSignatureCache.get(normalizedProjectDir);
2792
- if (cached?.fingerprint === fingerprint) return cached.signature;
2793
- const hash = createHash("sha256");
2794
- for (const entry of files) {
2795
- const relativePath = relative2(normalizedProjectDir, entry.file);
2796
- hash.update(relativePath);
2797
- hash.update("\0");
2798
- hash.update(String(entry.size));
2799
- hash.update("\0");
2800
- if (entry.textContentEligible) {
2801
- try {
2802
- hash.update(readFileSync5(entry.file));
2803
- } catch {
2804
- hash.update(String(entry.mtimeMs));
2805
- }
2806
- } else {
2807
- hash.update(String(entry.mtimeMs));
2808
- }
2809
- hash.update("\0");
2810
- }
2811
- const signature = hash.digest("hex").slice(0, 24);
2812
- projectSignatureCache.set(normalizedProjectDir, { fingerprint, signature });
2813
- return signature;
2814
- }
2815
-
2816
2973
  // src/helpers/studioMotionRenderScript.ts
2817
2974
  var STUDIO_MOTION_PATH = ".hyperframes/studio-motion.json";
2818
2975
  function hasStudioMotionEntries(manifestContent) {
@@ -3006,14 +3163,14 @@ import { ensureHfIds as ensureHfIds3 } from "@hyperframes/parsers/hf-ids";
3006
3163
  // src/helpers/hfIdPersist.ts
3007
3164
  import { ensureHfIds as ensureHfIds2 } from "@hyperframes/parsers/hf-ids";
3008
3165
  import {
3009
- closeSync,
3166
+ closeSync as closeSync2,
3010
3167
  constants,
3011
3168
  fstatSync,
3012
- ftruncateSync,
3013
- openSync,
3169
+ ftruncateSync as ftruncateSync2,
3170
+ openSync as openSync2,
3014
3171
  readFileSync as readFileSync6,
3015
3172
  writeFileSync as writeFileSync5,
3016
- writeSync
3173
+ writeSync as writeSync2
3017
3174
  } from "fs";
3018
3175
  function persistHfIdsIfNeeded(filePath, html) {
3019
3176
  const normalized = ensureHfIds2(html);
@@ -3034,7 +3191,7 @@ function persistHfIdsIfNeeded(filePath, html) {
3034
3191
  function openNoFollow(filePath, flags) {
3035
3192
  const noFollow = constants.O_NOFOLLOW ?? 0;
3036
3193
  try {
3037
- return openSync(filePath, flags | noFollow);
3194
+ return openSync2(filePath, flags | noFollow);
3038
3195
  } catch {
3039
3196
  return null;
3040
3197
  }
@@ -3054,15 +3211,15 @@ function stampFileHfIds(filePath) {
3054
3211
  const idsBefore = (html.match(/\bdata-hf-id=/g) ?? []).length;
3055
3212
  const idsAfter = (normalized.match(/\bdata-hf-id=/g) ?? []).length;
3056
3213
  if (writable && idsAfter > idsBefore) {
3057
- ftruncateSync(fd, 0);
3058
- writeSync(fd, normalized, 0, "utf-8");
3214
+ ftruncateSync2(fd, 0);
3215
+ writeSync2(fd, normalized, 0, "utf-8");
3059
3216
  }
3060
3217
  return normalized;
3061
3218
  } catch (err) {
3062
3219
  console.warn("[hyperframes] stampFileHfIds: failed to stamp ids:", err);
3063
3220
  return null;
3064
3221
  } finally {
3065
- closeSync(fd);
3222
+ closeSync2(fd);
3066
3223
  }
3067
3224
  }
3068
3225
 
@@ -3078,9 +3235,6 @@ var GSAP_CDN_VERSION = "3.15.0";
3078
3235
  var GSAP_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/gsap.min.js"></script>`;
3079
3236
  var GSAP_CUSTOM_EASE_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/CustomEase.min.js"></script>`;
3080
3237
  var GSAP_MOTION_PATH_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/MotionPathPlugin.min.js"></script>`;
3081
- function resolveProjectSignature(adapter, projectDir) {
3082
- return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir);
3083
- }
3084
3238
  function injectProjectSignature(html, signature) {
3085
3239
  const tag = `<meta name="${PROJECT_SIGNATURE_META}" content="${signature}">`;
3086
3240
  if (html.includes(`name="${PROJECT_SIGNATURE_META}"`)) {
@@ -3221,7 +3375,7 @@ function parsePreviewVariablesParam(raw) {
3221
3375
  }
3222
3376
  function variablesEtagSalt(raw) {
3223
3377
  if (!raw) return "";
3224
- return `:vars:${createHash2("sha1").update(raw).digest("hex").slice(0, 12)}`;
3378
+ return `:vars:${createHash3("sha1").update(raw).digest("hex").slice(0, 12)}`;
3225
3379
  }
3226
3380
  function previewVariablesFromRequest(rawVariables) {
3227
3381
  const parse = parsePreviewVariablesParam(rawVariables);
@@ -3269,12 +3423,12 @@ function registerPreviewRoutes(api, adapter) {
3269
3423
  ETag: etag
3270
3424
  });
3271
3425
  api.get("/projects/:id/preview", async (c) => {
3272
- const project = await adapter.resolveProject(c.req.param("id"));
3273
- if (!project) return c.json({ error: "not found" }, 404);
3426
+ const resolved = await resolveProjectAndSignature(adapter, c.req.param("id"));
3427
+ if (!resolved) return c.json({ error: "not found" }, 404);
3428
+ const { project, signature } = resolved;
3274
3429
  const vars = previewVariablesFromRequest(c.req.query("variables"));
3275
3430
  if (vars.error !== void 0) return c.json({ error: vars.error }, 400);
3276
3431
  const previewVariables = vars.values;
3277
- const signature = resolveProjectSignature(adapter, project.dir);
3278
3432
  const etag = `"preview:${signature}${variablesEtagSalt(vars.raw)}"`;
3279
3433
  const ifNoneMatch = c.req.header("If-None-Match");
3280
3434
  if (ifNoneMatch === etag) {
@@ -3334,12 +3488,12 @@ ${runtimeTag}`;
3334
3488
  return stampFileHfIds(compFile);
3335
3489
  }
3336
3490
  api.get("/projects/:id/preview/comp/*", async (c) => {
3337
- const project = await adapter.resolveProject(c.req.param("id"));
3338
- if (!project) return c.json({ error: "not found" }, 404);
3491
+ const resolved = await resolveProjectAndSignature(adapter, c.req.param("id"));
3492
+ if (!resolved) return c.json({ error: "not found" }, 404);
3493
+ const { project, signature } = resolved;
3339
3494
  const vars = previewVariablesFromRequest(c.req.query("variables"));
3340
3495
  if (vars.error !== void 0) return c.json({ error: vars.error }, 400);
3341
3496
  const previewVariables = vars.values;
3342
- const signature = resolveProjectSignature(adapter, project.dir);
3343
3497
  const compPath = decodeURIComponent(
3344
3498
  c.req.path.replace(`/projects/${project.id}/preview/comp/`, "").split("?")[0] ?? ""
3345
3499
  );
@@ -3459,7 +3613,7 @@ import { streamSSE } from "hono/streaming";
3459
3613
  import { existsSync as existsSync6, readFileSync as readFileSync9, mkdirSync as mkdirSync4, unlinkSync as unlinkSync3, readdirSync as readdirSync5, statSync as statSync3 } from "fs";
3460
3614
  import { join as join10 } from "path";
3461
3615
  import { VALID_CANVAS_RESOLUTIONS } from "@hyperframes/parsers";
3462
- import { parseFps } from "@hyperframes/core";
3616
+ import { formatRenderOutputTimestamp, parseFps } from "@hyperframes/core";
3463
3617
  var VALID_RESOLUTIONS = new Set(VALID_CANVAS_RESOLUTIONS);
3464
3618
  function registerRenderRoutes(api, adapter) {
3465
3619
  const renderJobs = /* @__PURE__ */ new Map();
@@ -3513,9 +3667,7 @@ function registerRenderRoutes(api, adapter) {
3513
3667
  variables = body.variables;
3514
3668
  }
3515
3669
  const now = /* @__PURE__ */ new Date();
3516
- const datePart = now.toISOString().slice(0, 10);
3517
- const timePart = now.toTimeString().slice(0, 8).replace(/:/g, "-");
3518
- const jobId = `${project.id}_${datePart}_${timePart}`;
3670
+ const jobId = `${project.id}_${formatRenderOutputTimestamp(now)}`;
3519
3671
  const rendersDir = adapter.rendersDir(project);
3520
3672
  if (!existsSync6(rendersDir)) mkdirSync4(rendersDir, { recursive: true });
3521
3673
  const ext = FORMAT_EXT[format] ?? ".mp4";
@@ -3695,7 +3847,7 @@ function registerRenderRoutes(api, adapter) {
3695
3847
  // src/routes/thumbnail.ts
3696
3848
  import { existsSync as existsSync7, readFileSync as readFileSync10, writeFileSync as writeFileSync6, mkdirSync as mkdirSync5, statSync as statSync4 } from "fs";
3697
3849
  import { join as join11 } from "path";
3698
- import { createHash as createHash3 } from "crypto";
3850
+ import { createHash as createHash4 } from "crypto";
3699
3851
 
3700
3852
  // src/helpers/manualEditsRenderScript.ts
3701
3853
  var STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json";
@@ -4307,7 +4459,7 @@ function registerThumbnailRoutes(api, adapter) {
4307
4459
  const htmlFile = join11(project.dir, compPath);
4308
4460
  if (existsSync7(htmlFile)) {
4309
4461
  const html = readFileSync10(htmlFile, "utf-8");
4310
- sourceKey = `_${createHash3("sha1").update(html).digest("hex").slice(0, 16)}`;
4462
+ sourceKey = `_${createHash4("sha1").update(html).digest("hex").slice(0, 16)}`;
4311
4463
  sourceMtime = Math.round(statSync4(htmlFile).mtimeMs);
4312
4464
  if (!vpWidth) {
4313
4465
  const wMatch = html.match(/data-width=["'](\d+)["']/);
@@ -4320,14 +4472,14 @@ function registerThumbnailRoutes(api, adapter) {
4320
4472
  let manualEditsKey = "";
4321
4473
  if (existsSync7(manualEditsFile)) {
4322
4474
  const manualEditsContent = readFileSync10(manualEditsFile, "utf-8");
4323
- manualEditsKey = `_${createHash3("sha1").update(manualEditsContent).digest("hex").slice(0, 16)}`;
4475
+ manualEditsKey = `_${createHash4("sha1").update(manualEditsContent).digest("hex").slice(0, 16)}`;
4324
4476
  sourceMtime = Math.max(sourceMtime, Math.round(statSync4(manualEditsFile).mtimeMs));
4325
4477
  }
4326
4478
  const motionFile = join11(project.dir, STUDIO_MOTION_PATH);
4327
4479
  let motionKey = "";
4328
4480
  if (existsSync7(motionFile)) {
4329
4481
  const motionContent = readFileSync10(motionFile, "utf-8");
4330
- motionKey = `_${createHash3("sha1").update(motionContent).digest("hex").slice(0, 16)}`;
4482
+ motionKey = `_${createHash4("sha1").update(motionContent).digest("hex").slice(0, 16)}`;
4331
4483
  sourceMtime = Math.max(sourceMtime, Math.round(statSync4(motionFile).mtimeMs));
4332
4484
  }
4333
4485
  const previewUrl = compPath === "index.html" ? `http://${c.req.header("host")}/api/projects/${project.id}/preview` : `http://${c.req.header("host")}/api/projects/${project.id}/preview/comp/${compPath}`;
@@ -4408,7 +4560,7 @@ function registerWaveformRoutes(api, adapter) {
4408
4560
  }
4409
4561
 
4410
4562
  // src/routes/fonts.ts
4411
- import { closeSync as closeSync2, constants as constants2, fstatSync as fstatSync2, openSync as openSync2, readSync } from "fs";
4563
+ import { closeSync as closeSync3, constants as constants2, fstatSync as fstatSync2, openSync as openSync3, readSync } from "fs";
4412
4564
  import {
4413
4565
  collectFontFileEntries,
4414
4566
  fontDirectories,
@@ -4515,7 +4667,7 @@ function registerFontRoutes(api) {
4515
4667
  if (!located) return c.json({ error: "font not found" }, 404);
4516
4668
  let fd;
4517
4669
  try {
4518
- fd = openSync2(located.path, constants2.O_RDONLY | constants2.O_NOFOLLOW);
4670
+ fd = openSync3(located.path, constants2.O_RDONLY | constants2.O_NOFOLLOW);
4519
4671
  } catch {
4520
4672
  return c.json({ error: "font file not accessible" }, 404);
4521
4673
  }
@@ -4537,7 +4689,7 @@ function registerFontRoutes(api) {
4537
4689
  } catch {
4538
4690
  return c.json({ error: "failed to read font file" }, 500);
4539
4691
  } finally {
4540
- closeSync2(fd);
4692
+ closeSync3(fd);
4541
4693
  }
4542
4694
  });
4543
4695
  }
@@ -5071,12 +5223,14 @@ export {
5071
5223
  STUDIO_MANUAL_EDITS_PATH,
5072
5224
  STUDIO_MOTION_PATH,
5073
5225
  buildSubCompositionHtml,
5226
+ consumeFileWriteReceipt,
5074
5227
  createBackgroundRemovalJob,
5075
5228
  createProjectSignature,
5076
5229
  createStudioApi,
5077
5230
  createStudioManualEditsRenderBodyScript,
5078
5231
  createStudioMotionRenderBodyScript,
5079
5232
  createStudioPositionSeekReapplyScript,
5233
+ fileContentVersion,
5080
5234
  getElementScreenshotClip,
5081
5235
  getMimeType,
5082
5236
  isSafePath,