@hyperframes/studio-server 0.7.58 → 0.7.59

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
  }
@@ -134,16 +289,16 @@ function registerStoryboardRoutes(api, adapter) {
134
289
  import { bodyLimit } from "hono/body-limit";
135
290
  import {
136
291
  existsSync as existsSync3,
137
- readFileSync as readFileSync3,
292
+ readFileSync as readFileSync4,
138
293
  writeFileSync as writeFileSync4,
139
294
  mkdirSync as mkdirSync3,
140
295
  unlinkSync as unlinkSync2,
141
296
  rmSync as rmSync2,
142
297
  statSync,
143
298
  renameSync,
144
- readdirSync as readdirSync3
299
+ readdirSync as readdirSync4
145
300
  } from "fs";
146
- import { resolve as resolve2, dirname, join as join6 } from "path";
301
+ import { resolve as resolve3, dirname, join as join6 } from "path";
147
302
 
148
303
  // src/helpers/mime.ts
149
304
  var MIME_TYPES = {
@@ -188,7 +343,7 @@ function isAudioFile(name) {
188
343
  // src/helpers/waveform.ts
189
344
  import { spawn } from "child_process";
190
345
  import { existsSync as existsSync2, writeFileSync, mkdirSync } from "fs";
191
- import { join as join3, resolve } from "path";
346
+ import { join as join3, resolve as resolve2 } from "path";
192
347
  var SAMPLE_RATE = 4e3;
193
348
  var PEAK_COUNT = 4e3;
194
349
  var WAVEFORM_CACHE_VERSION = "v2";
@@ -213,7 +368,7 @@ function computePeaks(floats, count) {
213
368
  }
214
369
  function ffmpegBinary() {
215
370
  const configured = process.env.HYPERFRAMES_FFMPEG_PATH?.trim();
216
- if (configured) return resolve(configured);
371
+ if (configured) return resolve2(configured);
217
372
  return "ffmpeg";
218
373
  }
219
374
  function decodeAudioPeaks(audioPath) {
@@ -322,9 +477,9 @@ function validateUploadedMediaBuffer(fileName, buffer, runner = spawnSync) {
322
477
  }
323
478
 
324
479
  // src/helpers/backupJournal.ts
325
- import { mkdirSync as mkdirSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
480
+ import { mkdirSync as mkdirSync2, readdirSync as readdirSync3, readFileSync as readFileSync3, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
326
481
  import { Buffer as Buffer2 } from "buffer";
327
- import { join as join5, relative } from "path";
482
+ import { join as join5, relative as relative2 } from "path";
328
483
  var DEFAULT_KEEP_PER_FILE = 10;
329
484
  function backupKeyForPath(path) {
330
485
  return Buffer2.from(path, "utf-8").toString("base64url");
@@ -334,15 +489,15 @@ function timestampPrefix() {
334
489
  }
335
490
  function backupPathForResponse(projectDir, backupPath) {
336
491
  if (!backupPath) return null;
337
- const rel = relative(projectDir, backupPath);
492
+ const rel = relative2(projectDir, backupPath);
338
493
  if (!rel || rel.startsWith("..")) return null;
339
494
  return rel.split("\\").join("/");
340
495
  }
341
496
  function snapshotBeforeWrite(projectDir, absPath, options = {}) {
342
497
  if (!isSafePath(projectDir, absPath)) return { backupPath: null };
343
498
  try {
344
- const content = readFileSync2(absPath);
345
- const relativePath = relative(projectDir, absPath);
499
+ const content = readFileSync3(absPath);
500
+ const relativePath = relative2(projectDir, absPath);
346
501
  const backupDir = join5(projectDir, ".hyperframes", "backup");
347
502
  mkdirSync2(backupDir, { recursive: true });
348
503
  const backupKey = backupKeyForPath(relativePath);
@@ -363,7 +518,7 @@ function nextBackupPath(backupDir, backupKey) {
363
518
  let counter = 2;
364
519
  while (true) {
365
520
  try {
366
- readFileSync2(candidate);
521
+ readFileSync3(candidate);
367
522
  } catch (error) {
368
523
  if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
369
524
  return candidate;
@@ -378,7 +533,7 @@ function pruneBackups(backupDir, backupKey, keepPerFile) {
378
533
  const keep = Math.max(1, Math.floor(keepPerFile));
379
534
  const suffix = `-${backupKey}`;
380
535
  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) => {
536
+ const matches = readdirSync3(backupDir).filter((name) => name.endsWith(suffix) || numberedSuffix.test(name)).map((name) => join5(backupDir, name)).sort((a, b) => {
382
537
  return b.localeCompare(a);
383
538
  });
384
539
  for (const file of matches.slice(keep)) {
@@ -931,7 +1086,7 @@ function commitElementPatchBatches(projectDir, batches, writeFile = writeFileSyn
931
1086
  resolvedPaths.add(absPath);
932
1087
  let before;
933
1088
  try {
934
- before = readFileSync3(absPath, "utf-8");
1089
+ before = readFileSync4(absPath, "utf-8");
935
1090
  } catch {
936
1091
  return { error: "not-found", sourceFile: batch.sourceFile };
937
1092
  }
@@ -1053,7 +1208,7 @@ function generateCopyPath(projectDir, originalPath) {
1053
1208
  const cleanBase = copyMatch ? base.slice(0, -copyMatch[0].length) : base;
1054
1209
  let num = copyMatch ? copyMatch[1] ? parseInt(copyMatch[1]) + 1 : 2 : 1;
1055
1210
  let candidate = num === 1 ? `${cleanBase} (copy)${ext}` : `${cleanBase} (copy ${num})${ext}`;
1056
- while (existsSync3(resolve2(projectDir, candidate))) {
1211
+ while (existsSync3(resolve3(projectDir, candidate))) {
1057
1212
  num++;
1058
1213
  candidate = `${cleanBase} (copy ${num})${ext}`;
1059
1214
  }
@@ -1061,7 +1216,7 @@ function generateCopyPath(projectDir, originalPath) {
1061
1216
  }
1062
1217
  function walkFiles(dir, filter) {
1063
1218
  const results = [];
1064
- for (const entry of readdirSync3(dir, { withFileTypes: true })) {
1219
+ for (const entry of readdirSync4(dir, { withFileTypes: true })) {
1065
1220
  const full = join6(dir, entry.name);
1066
1221
  if (entry.isDirectory()) {
1067
1222
  if (entry.name === "node_modules" || entry.name === ".thumbnails" || entry.name === "renders")
@@ -1080,7 +1235,7 @@ function updateReferences(projectDir, oldPath, newPath) {
1080
1235
  );
1081
1236
  let updatedCount = 0;
1082
1237
  for (const file of textFiles) {
1083
- const content = readFileSync3(file, "utf-8");
1238
+ const content = readFileSync4(file, "utf-8");
1084
1239
  if (!content.includes(oldPath)) continue;
1085
1240
  const updated = content.split(oldPath).join(newPath);
1086
1241
  if (updated !== content) {
@@ -1337,7 +1492,7 @@ function validateGsapMutationRequest(c, body) {
1337
1492
  return null;
1338
1493
  }
1339
1494
  async function prepareGsapMutationScript(c, res, firstMutation) {
1340
- const beforeHtml = readFileSync3(res.absPath, "utf-8");
1495
+ const beforeHtml = readFileSync4(res.absPath, "utf-8");
1341
1496
  let html = beforeHtml;
1342
1497
  let block = extractGsapScriptBlock(html);
1343
1498
  if (!block && (firstMutation.type === "add" || firstMutation.type === "add-with-keyframes")) {
@@ -1397,7 +1552,7 @@ async function applyGsapMutations(c, res, mutations) {
1397
1552
  const changed = block.scriptText !== initialScript;
1398
1553
  const newHtml = changed ? block.replaceScript(block.scriptText) : html;
1399
1554
  let backupPath = null;
1400
- if (readFileSync3(res.absPath, "utf-8") !== beforeHtml) {
1555
+ if (readFileSync4(res.absPath, "utf-8") !== beforeHtml) {
1401
1556
  return c.json({ error: "file changed during GSAP mutation", conflict: true }, 409);
1402
1557
  }
1403
1558
  if (changed) {
@@ -2033,7 +2188,7 @@ async function processUploadedFiles(formData, targetDir, projectDir) {
2033
2188
  skipped.push(name);
2034
2189
  continue;
2035
2190
  }
2036
- const destPath = resolve2(targetDir, name);
2191
+ const destPath = resolve3(targetDir, name);
2037
2192
  if (!isSafePath(projectDir, destPath)) continue;
2038
2193
  let finalPath = destPath;
2039
2194
  let finalName = name;
@@ -2043,13 +2198,13 @@ async function processUploadedFiles(formData, targetDir, projectDir) {
2043
2198
  const base = dotIdx > 0 ? name.slice(0, dotIdx) : name;
2044
2199
  let n = 2;
2045
2200
  const MAX_COPY_INDEX = 1e4;
2046
- while (n < MAX_COPY_INDEX && existsSync3(resolve2(targetDir, `${base} (${n})${ext}`))) n++;
2201
+ while (n < MAX_COPY_INDEX && existsSync3(resolve3(targetDir, `${base} (${n})${ext}`))) n++;
2047
2202
  if (n >= MAX_COPY_INDEX) {
2048
2203
  skipped.push(name);
2049
2204
  continue;
2050
2205
  }
2051
2206
  finalName = `${base} (${n})${ext}`;
2052
- finalPath = resolve2(targetDir, finalName);
2207
+ finalPath = resolve3(targetDir, finalName);
2053
2208
  }
2054
2209
  const buffer = Buffer.from(await value.arrayBuffer());
2055
2210
  const validation = validateUploadedMediaBuffer(finalName, buffer);
@@ -2077,7 +2232,7 @@ function registerFileRoutes(api, adapter) {
2077
2232
  }
2078
2233
  return c.json({ error: "not found" }, 404);
2079
2234
  }
2080
- const content = readFileSync3(res.absPath, "utf-8");
2235
+ const content = readFileSync4(res.absPath, "utf-8");
2081
2236
  return c.json({ filename: res.filePath, content });
2082
2237
  });
2083
2238
  api.put("/projects/:id/files/*", async (c) => {
@@ -2129,7 +2284,7 @@ function registerFileRoutes(api, adapter) {
2129
2284
  }
2130
2285
  const parsed = await parseMutationBody(c);
2131
2286
  if ("error" in parsed) return parsed.error;
2132
- const originalContent = readFileSync3(ctx.absPath, "utf-8");
2287
+ const originalContent = readFileSync4(ctx.absPath, "utf-8");
2133
2288
  return writeIfChanged(
2134
2289
  c,
2135
2290
  ctx.project.dir,
@@ -2150,7 +2305,7 @@ function registerFileRoutes(api, adapter) {
2150
2305
  const fallbackTiming = typeof parsed.body.elementStart === "number" && typeof parsed.body.elementDuration === "number" ? { start: parsed.body.elementStart, duration: parsed.body.elementDuration } : void 0;
2151
2306
  let originalContent;
2152
2307
  try {
2153
- originalContent = readFileSync3(ctx.absPath, "utf-8");
2308
+ originalContent = readFileSync4(ctx.absPath, "utf-8");
2154
2309
  } catch {
2155
2310
  return c.json({ error: "not found" }, 404);
2156
2311
  }
@@ -2190,7 +2345,7 @@ function registerFileRoutes(api, adapter) {
2190
2345
  }
2191
2346
  let originalContent;
2192
2347
  try {
2193
- originalContent = readFileSync3(ctx.absPath, "utf-8");
2348
+ originalContent = readFileSync4(ctx.absPath, "utf-8");
2194
2349
  } catch {
2195
2350
  return c.json({ error: "not found" }, 404);
2196
2351
  }
@@ -2280,7 +2435,7 @@ function registerFileRoutes(api, adapter) {
2280
2435
  }
2281
2436
  let originalContent;
2282
2437
  try {
2283
- originalContent = readFileSync3(ctx.absPath, "utf-8");
2438
+ originalContent = readFileSync4(ctx.absPath, "utf-8");
2284
2439
  } catch {
2285
2440
  return c.json({ error: "not found" }, 404);
2286
2441
  }
@@ -2322,7 +2477,7 @@ function registerFileRoutes(api, adapter) {
2322
2477
  if ("error" in parsed) return parsed.error;
2323
2478
  let originalContent;
2324
2479
  try {
2325
- originalContent = readFileSync3(ctx.absPath, "utf-8");
2480
+ originalContent = readFileSync4(ctx.absPath, "utf-8");
2326
2481
  } catch {
2327
2482
  return c.json({ error: "not found" }, 404);
2328
2483
  }
@@ -2351,7 +2506,7 @@ function registerFileRoutes(api, adapter) {
2351
2506
  if ("error" in parsed) return parsed.error;
2352
2507
  let content;
2353
2508
  try {
2354
- content = readFileSync3(ctx.absPath, "utf-8");
2509
+ content = readFileSync4(ctx.absPath, "utf-8");
2355
2510
  } catch {
2356
2511
  return c.json({ exists: false });
2357
2512
  }
@@ -2394,7 +2549,7 @@ function registerFileRoutes(api, adapter) {
2394
2549
  return c.json({ error: "forbidden" }, 403);
2395
2550
  }
2396
2551
  ensureDir(destAbs);
2397
- writeFileSync4(destAbs, readFileSync3(srcAbs));
2552
+ writeFileSync4(destAbs, readFileSync4(srcAbs));
2398
2553
  return c.json({ ok: true, path: copyPath }, 201);
2399
2554
  });
2400
2555
  const MAX_UPLOAD_BYTES = 500 * 1024 * 1024;
@@ -2424,7 +2579,7 @@ function registerFileRoutes(api, adapter) {
2424
2579
  mustExist: true
2425
2580
  });
2426
2581
  if ("error" in res) return res.error;
2427
- const html = readFileSync3(res.absPath, "utf-8");
2582
+ const html = readFileSync4(res.absPath, "utf-8");
2428
2583
  const block = extractGsapScriptBlock(html);
2429
2584
  if (!block) {
2430
2585
  return c.json({
@@ -2483,7 +2638,7 @@ function registerFileRoutes(api, adapter) {
2483
2638
  if (!body || typeof body.expected !== "string" || typeof body.restore !== "string") {
2484
2639
  return c.json({ error: "expected and restore contents required" }, 400);
2485
2640
  }
2486
- const current = readFileSync3(res.absPath, "utf-8");
2641
+ const current = readFileSync4(res.absPath, "utf-8");
2487
2642
  if (current !== body.expected) {
2488
2643
  return c.json({ ok: true, restored: false, conflict: true });
2489
2644
  }
@@ -2499,7 +2654,7 @@ import { createHash as createHash2 } from "crypto";
2499
2654
  import { injectScriptsIntoHtml, stripEmbeddedRuntimeScripts as stripEmbeddedRuntimeScripts2 } from "@hyperframes/core/compiler";
2500
2655
 
2501
2656
  // src/helpers/subComposition.ts
2502
- import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
2657
+ import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
2503
2658
  import { join as join7 } from "path";
2504
2659
  import { parseHTML as parseHTML3 } from "linkedom";
2505
2660
  import {
@@ -2608,7 +2763,7 @@ function tagRootCompositionFile(bodyHtml, compPath) {
2608
2763
  function buildSubCompositionHtml(projectDir, compPath, runtimeUrl, baseHref, rawOverride) {
2609
2764
  const compFile = join7(projectDir, compPath);
2610
2765
  if (!existsSync4(compFile)) return null;
2611
- const rawComp = rawOverride ?? readFileSync4(compFile, "utf-8");
2766
+ const rawComp = rawOverride ?? readFileSync5(compFile, "utf-8");
2612
2767
  let compHeadContent = "";
2613
2768
  let rewrittenContent;
2614
2769
  let htmlAttrs = "";
@@ -2641,7 +2796,7 @@ function buildSubCompositionHtml(projectDir, compPath, runtimeUrl, baseHref, raw
2641
2796
  const indexPath = join7(projectDir, "index.html");
2642
2797
  let headContent = "";
2643
2798
  if (existsSync4(indexPath)) {
2644
- const indexHtml = readFileSync4(indexPath, "utf-8");
2799
+ const indexHtml = readFileSync5(indexPath, "utf-8");
2645
2800
  const headMatch = indexHtml.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
2646
2801
  headContent = headMatch?.[1] ?? "";
2647
2802
  }
@@ -2674,145 +2829,6 @@ ${rewrittenContent}
2674
2829
  </html>`;
2675
2830
  }
2676
2831
 
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
2832
  // src/helpers/studioMotionRenderScript.ts
2817
2833
  var STUDIO_MOTION_PATH = ".hyperframes/studio-motion.json";
2818
2834
  function hasStudioMotionEntries(manifestContent) {
@@ -3078,9 +3094,6 @@ var GSAP_CDN_VERSION = "3.15.0";
3078
3094
  var GSAP_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/gsap.min.js"></script>`;
3079
3095
  var GSAP_CUSTOM_EASE_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/CustomEase.min.js"></script>`;
3080
3096
  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
3097
  function injectProjectSignature(html, signature) {
3085
3098
  const tag = `<meta name="${PROJECT_SIGNATURE_META}" content="${signature}">`;
3086
3099
  if (html.includes(`name="${PROJECT_SIGNATURE_META}"`)) {
@@ -3269,12 +3282,12 @@ function registerPreviewRoutes(api, adapter) {
3269
3282
  ETag: etag
3270
3283
  });
3271
3284
  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);
3285
+ const resolved = await resolveProjectAndSignature(adapter, c.req.param("id"));
3286
+ if (!resolved) return c.json({ error: "not found" }, 404);
3287
+ const { project, signature } = resolved;
3274
3288
  const vars = previewVariablesFromRequest(c.req.query("variables"));
3275
3289
  if (vars.error !== void 0) return c.json({ error: vars.error }, 400);
3276
3290
  const previewVariables = vars.values;
3277
- const signature = resolveProjectSignature(adapter, project.dir);
3278
3291
  const etag = `"preview:${signature}${variablesEtagSalt(vars.raw)}"`;
3279
3292
  const ifNoneMatch = c.req.header("If-None-Match");
3280
3293
  if (ifNoneMatch === etag) {
@@ -3334,12 +3347,12 @@ ${runtimeTag}`;
3334
3347
  return stampFileHfIds(compFile);
3335
3348
  }
3336
3349
  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);
3350
+ const resolved = await resolveProjectAndSignature(adapter, c.req.param("id"));
3351
+ if (!resolved) return c.json({ error: "not found" }, 404);
3352
+ const { project, signature } = resolved;
3339
3353
  const vars = previewVariablesFromRequest(c.req.query("variables"));
3340
3354
  if (vars.error !== void 0) return c.json({ error: vars.error }, 400);
3341
3355
  const previewVariables = vars.values;
3342
- const signature = resolveProjectSignature(adapter, project.dir);
3343
3356
  const compPath = decodeURIComponent(
3344
3357
  c.req.path.replace(`/projects/${project.id}/preview/comp/`, "").split("?")[0] ?? ""
3345
3358
  );
@@ -3459,7 +3472,7 @@ import { streamSSE } from "hono/streaming";
3459
3472
  import { existsSync as existsSync6, readFileSync as readFileSync9, mkdirSync as mkdirSync4, unlinkSync as unlinkSync3, readdirSync as readdirSync5, statSync as statSync3 } from "fs";
3460
3473
  import { join as join10 } from "path";
3461
3474
  import { VALID_CANVAS_RESOLUTIONS } from "@hyperframes/parsers";
3462
- import { parseFps } from "@hyperframes/core";
3475
+ import { formatRenderOutputTimestamp, parseFps } from "@hyperframes/core";
3463
3476
  var VALID_RESOLUTIONS = new Set(VALID_CANVAS_RESOLUTIONS);
3464
3477
  function registerRenderRoutes(api, adapter) {
3465
3478
  const renderJobs = /* @__PURE__ */ new Map();
@@ -3513,9 +3526,7 @@ function registerRenderRoutes(api, adapter) {
3513
3526
  variables = body.variables;
3514
3527
  }
3515
3528
  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}`;
3529
+ const jobId = `${project.id}_${formatRenderOutputTimestamp(now)}`;
3519
3530
  const rendersDir = adapter.rendersDir(project);
3520
3531
  if (!existsSync6(rendersDir)) mkdirSync4(rendersDir, { recursive: true });
3521
3532
  const ext = FORMAT_EXT[format] ?? ".mp4";