@hyperframes/studio-server 0.7.57 → 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)) {
@@ -896,6 +1051,21 @@ function resolveProjectFile(c, adapter, opts) {
896
1051
  function resolveFileMutationContext(c, adapter, operation) {
897
1052
  return resolveProjectPath(c, adapter, (id) => `/projects/${id}/file-mutations/${operation}/`);
898
1053
  }
1054
+ function isElementPatchRequest(value) {
1055
+ if (typeof value !== "object" || value === null) return false;
1056
+ if (!("target" in value) || typeof value.target !== "object" || value.target === null) {
1057
+ return false;
1058
+ }
1059
+ return "operations" in value && Array.isArray(value.operations) && value.operations.length > 0;
1060
+ }
1061
+ function isElementPatchBatchRequest(value) {
1062
+ return typeof value === "object" && value !== null && "sourceFile" in value && typeof value.sourceFile === "string" && value.sourceFile.length > 0 && "patches" in value && Array.isArray(value.patches) && value.patches.length > 0 && value.patches.every(isElementPatchRequest);
1063
+ }
1064
+ function findUnsafeElementPatchBatchValues(batches) {
1065
+ return batches.flatMap(
1066
+ (batch) => batch.patches.flatMap((patch) => findUnsafeDomPatchValues(patch))
1067
+ );
1068
+ }
899
1069
  function foldElementPatches(originalContent, patches) {
900
1070
  let content = originalContent;
901
1071
  const matched = [];
@@ -906,6 +1076,90 @@ function foldElementPatches(originalContent, patches) {
906
1076
  }
907
1077
  return { content, matched };
908
1078
  }
1079
+ function commitElementPatchBatches(projectDir, batches, writeFile = writeFileSync4) {
1080
+ const resolvedPaths = /* @__PURE__ */ new Set();
1081
+ const prepared = [];
1082
+ for (const batch of batches) {
1083
+ const absPath = resolveWithinProject(projectDir, batch.sourceFile);
1084
+ if (!absPath) return { error: "forbidden", sourceFile: batch.sourceFile };
1085
+ if (resolvedPaths.has(absPath)) return { error: "duplicate", sourceFile: batch.sourceFile };
1086
+ resolvedPaths.add(absPath);
1087
+ let before;
1088
+ try {
1089
+ before = readFileSync4(absPath, "utf-8");
1090
+ } catch {
1091
+ return { error: "not-found", sourceFile: batch.sourceFile };
1092
+ }
1093
+ const folded = foldElementPatches(before, batch.patches);
1094
+ prepared.push({
1095
+ sourceFile: batch.sourceFile,
1096
+ absPath,
1097
+ before,
1098
+ matched: folded.matched,
1099
+ after: folded.content
1100
+ });
1101
+ }
1102
+ const durable = prepared.every((file) => file.matched.every(Boolean));
1103
+ if (!durable) {
1104
+ return {
1105
+ durable: false,
1106
+ files: prepared.map((file) => ({
1107
+ sourceFile: file.sourceFile,
1108
+ changed: false,
1109
+ matched: file.matched,
1110
+ before: file.before,
1111
+ after: file.before
1112
+ }))
1113
+ };
1114
+ }
1115
+ const files = [];
1116
+ const attemptedWrites = [];
1117
+ try {
1118
+ for (const file of prepared) {
1119
+ if (file.after === file.before) {
1120
+ files.push({
1121
+ sourceFile: file.sourceFile,
1122
+ changed: false,
1123
+ matched: file.matched,
1124
+ before: file.before,
1125
+ after: file.before
1126
+ });
1127
+ continue;
1128
+ }
1129
+ const backup = snapshotBeforeWrite(projectDir, file.absPath);
1130
+ if (backup.error) {
1131
+ throw new Error(`Failed to create backup for ${file.sourceFile}: ${backup.error}`);
1132
+ }
1133
+ attemptedWrites.push(file);
1134
+ writeFile(file.absPath, file.after, "utf-8");
1135
+ files.push({
1136
+ sourceFile: file.sourceFile,
1137
+ changed: true,
1138
+ matched: file.matched,
1139
+ before: file.before,
1140
+ after: file.after,
1141
+ backupPath: backupPathForResponse(projectDir, backup.backupPath)
1142
+ });
1143
+ }
1144
+ } catch (error) {
1145
+ const rollbackErrors = [];
1146
+ for (const file of attemptedWrites.reverse()) {
1147
+ try {
1148
+ writeFile(file.absPath, file.before, "utf-8");
1149
+ } catch (rollbackError) {
1150
+ rollbackErrors.push(rollbackError);
1151
+ }
1152
+ }
1153
+ if (rollbackErrors.length > 0) {
1154
+ throw new AggregateError(
1155
+ [error, ...rollbackErrors],
1156
+ "Element patch batch failed and rollback did not complete"
1157
+ );
1158
+ }
1159
+ throw error;
1160
+ }
1161
+ return { durable: true, files };
1162
+ }
909
1163
  function writeIfChanged(c, projectDir, filePath, absPath, original, next) {
910
1164
  if (next === original) {
911
1165
  return c.json({ ok: true, changed: false, content: original, path: filePath });
@@ -931,6 +1185,11 @@ function rejectUnsafeMutationValues(c, unsafeFields) {
931
1185
  400
932
1186
  );
933
1187
  }
1188
+ function elementPatchBatchCommitErrorResponse(c, error, sourceFile) {
1189
+ if (error === "not-found") return c.json({ error, sourceFile }, 404);
1190
+ if (error === "forbidden") return c.json({ error, sourceFile }, 403);
1191
+ return c.json({ error: "duplicate source file", sourceFile }, 400);
1192
+ }
934
1193
  async function parseMutationBody(c) {
935
1194
  const body = await c.req.json().catch(() => null);
936
1195
  if (!body?.target) {
@@ -949,7 +1208,7 @@ function generateCopyPath(projectDir, originalPath) {
949
1208
  const cleanBase = copyMatch ? base.slice(0, -copyMatch[0].length) : base;
950
1209
  let num = copyMatch ? copyMatch[1] ? parseInt(copyMatch[1]) + 1 : 2 : 1;
951
1210
  let candidate = num === 1 ? `${cleanBase} (copy)${ext}` : `${cleanBase} (copy ${num})${ext}`;
952
- while (existsSync3(resolve2(projectDir, candidate))) {
1211
+ while (existsSync3(resolve3(projectDir, candidate))) {
953
1212
  num++;
954
1213
  candidate = `${cleanBase} (copy ${num})${ext}`;
955
1214
  }
@@ -957,7 +1216,7 @@ function generateCopyPath(projectDir, originalPath) {
957
1216
  }
958
1217
  function walkFiles(dir, filter) {
959
1218
  const results = [];
960
- for (const entry of readdirSync3(dir, { withFileTypes: true })) {
1219
+ for (const entry of readdirSync4(dir, { withFileTypes: true })) {
961
1220
  const full = join6(dir, entry.name);
962
1221
  if (entry.isDirectory()) {
963
1222
  if (entry.name === "node_modules" || entry.name === ".thumbnails" || entry.name === "renders")
@@ -976,7 +1235,7 @@ function updateReferences(projectDir, oldPath, newPath) {
976
1235
  );
977
1236
  let updatedCount = 0;
978
1237
  for (const file of textFiles) {
979
- const content = readFileSync3(file, "utf-8");
1238
+ const content = readFileSync4(file, "utf-8");
980
1239
  if (!content.includes(oldPath)) continue;
981
1240
  const updated = content.split(oldPath).join(newPath);
982
1241
  if (updated !== content) {
@@ -1233,7 +1492,8 @@ function validateGsapMutationRequest(c, body) {
1233
1492
  return null;
1234
1493
  }
1235
1494
  async function prepareGsapMutationScript(c, res, firstMutation) {
1236
- let html = readFileSync3(res.absPath, "utf-8");
1495
+ const beforeHtml = readFileSync4(res.absPath, "utf-8");
1496
+ let html = beforeHtml;
1237
1497
  let block = extractGsapScriptBlock(html);
1238
1498
  if (!block && (firstMutation.type === "add" || firstMutation.type === "add-with-keyframes")) {
1239
1499
  const compId = html.match(/data-composition-id="([^"]+)"/)?.[1] ?? "main";
@@ -1265,14 +1525,14 @@ ${bootstrap}`;
1265
1525
  });
1266
1526
  }
1267
1527
  if (!block) return c.json({ error: "no GSAP script found in file" }, 400);
1268
- return { html, block };
1528
+ return { html, beforeHtml, block };
1269
1529
  }
1270
1530
  async function applyGsapMutations(c, res, mutations) {
1271
1531
  const firstMutation = mutations[0];
1272
1532
  if (!firstMutation) return c.json({ error: "mutations array required" }, 400);
1273
1533
  const prepared = await prepareGsapMutationScript(c, res, firstMutation);
1274
1534
  if (prepared instanceof Response) return prepared;
1275
- const { html, block } = prepared;
1535
+ const { html, beforeHtml, block } = prepared;
1276
1536
  const initialScript = block.scriptText;
1277
1537
  const skippedSelectors = /* @__PURE__ */ new Set();
1278
1538
  const respond = (data, status) => status ? c.json(data, status) : c.json(data);
@@ -1292,6 +1552,9 @@ async function applyGsapMutations(c, res, mutations) {
1292
1552
  const changed = block.scriptText !== initialScript;
1293
1553
  const newHtml = changed ? block.replaceScript(block.scriptText) : html;
1294
1554
  let backupPath = null;
1555
+ if (readFileSync4(res.absPath, "utf-8") !== beforeHtml) {
1556
+ return c.json({ error: "file changed during GSAP mutation", conflict: true }, 409);
1557
+ }
1295
1558
  if (changed) {
1296
1559
  const backup = snapshotBeforeWrite(res.project.dir, res.absPath);
1297
1560
  if (backup.error) console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`);
@@ -1303,7 +1566,7 @@ async function applyGsapMutations(c, res, mutations) {
1303
1566
  changed,
1304
1567
  mutated: changed,
1305
1568
  parsed: parseGsapScriptAcorn(block.scriptText),
1306
- before: html,
1569
+ before: beforeHtml,
1307
1570
  after: newHtml,
1308
1571
  scriptText: block.scriptText,
1309
1572
  path: res.filePath,
@@ -1925,7 +2188,7 @@ async function processUploadedFiles(formData, targetDir, projectDir) {
1925
2188
  skipped.push(name);
1926
2189
  continue;
1927
2190
  }
1928
- const destPath = resolve2(targetDir, name);
2191
+ const destPath = resolve3(targetDir, name);
1929
2192
  if (!isSafePath(projectDir, destPath)) continue;
1930
2193
  let finalPath = destPath;
1931
2194
  let finalName = name;
@@ -1935,13 +2198,13 @@ async function processUploadedFiles(formData, targetDir, projectDir) {
1935
2198
  const base = dotIdx > 0 ? name.slice(0, dotIdx) : name;
1936
2199
  let n = 2;
1937
2200
  const MAX_COPY_INDEX = 1e4;
1938
- 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++;
1939
2202
  if (n >= MAX_COPY_INDEX) {
1940
2203
  skipped.push(name);
1941
2204
  continue;
1942
2205
  }
1943
2206
  finalName = `${base} (${n})${ext}`;
1944
- finalPath = resolve2(targetDir, finalName);
2207
+ finalPath = resolve3(targetDir, finalName);
1945
2208
  }
1946
2209
  const buffer = Buffer.from(await value.arrayBuffer());
1947
2210
  const validation = validateUploadedMediaBuffer(finalName, buffer);
@@ -1969,7 +2232,7 @@ function registerFileRoutes(api, adapter) {
1969
2232
  }
1970
2233
  return c.json({ error: "not found" }, 404);
1971
2234
  }
1972
- const content = readFileSync3(res.absPath, "utf-8");
2235
+ const content = readFileSync4(res.absPath, "utf-8");
1973
2236
  return c.json({ filename: res.filePath, content });
1974
2237
  });
1975
2238
  api.put("/projects/:id/files/*", async (c) => {
@@ -2021,7 +2284,7 @@ function registerFileRoutes(api, adapter) {
2021
2284
  }
2022
2285
  const parsed = await parseMutationBody(c);
2023
2286
  if ("error" in parsed) return parsed.error;
2024
- const originalContent = readFileSync3(ctx.absPath, "utf-8");
2287
+ const originalContent = readFileSync4(ctx.absPath, "utf-8");
2025
2288
  return writeIfChanged(
2026
2289
  c,
2027
2290
  ctx.project.dir,
@@ -2042,7 +2305,7 @@ function registerFileRoutes(api, adapter) {
2042
2305
  const fallbackTiming = typeof parsed.body.elementStart === "number" && typeof parsed.body.elementDuration === "number" ? { start: parsed.body.elementStart, duration: parsed.body.elementDuration } : void 0;
2043
2306
  let originalContent;
2044
2307
  try {
2045
- originalContent = readFileSync3(ctx.absPath, "utf-8");
2308
+ originalContent = readFileSync4(ctx.absPath, "utf-8");
2046
2309
  } catch {
2047
2310
  return c.json({ error: "not found" }, 404);
2048
2311
  }
@@ -2082,7 +2345,7 @@ function registerFileRoutes(api, adapter) {
2082
2345
  }
2083
2346
  let originalContent;
2084
2347
  try {
2085
- originalContent = readFileSync3(ctx.absPath, "utf-8");
2348
+ originalContent = readFileSync4(ctx.absPath, "utf-8");
2086
2349
  } catch {
2087
2350
  return c.json({ error: "not found" }, 404);
2088
2351
  }
@@ -2112,45 +2375,46 @@ function registerFileRoutes(api, adapter) {
2112
2375
  backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath)
2113
2376
  });
2114
2377
  });
2378
+ api.post("/projects/:id/file-mutations/patch-element-batches", async (c) => {
2379
+ const project = await adapter.resolveProject(c.req.param("id"));
2380
+ if (!project) return c.json({ error: "not found" }, 404);
2381
+ const body = await c.req.json().catch(() => null);
2382
+ if (typeof body !== "object" || body === null || !("batches" in body) || !Array.isArray(body.batches) || body.batches.length === 0 || !body.batches.every(isElementPatchBatchRequest)) {
2383
+ return c.json({ error: "batches with sourceFile and patches required" }, 400);
2384
+ }
2385
+ const unsafeFields = findUnsafeElementPatchBatchValues(body.batches);
2386
+ if (unsafeFields.length > 0) return rejectUnsafeMutationValues(c, unsafeFields);
2387
+ const result = commitElementPatchBatches(project.dir, body.batches);
2388
+ if ("error" in result) {
2389
+ return elementPatchBatchCommitErrorResponse(c, result.error, result.sourceFile);
2390
+ }
2391
+ return c.json(result);
2392
+ });
2115
2393
  api.post("/projects/:id/file-mutations/patch-elements-batch/*", async (c) => {
2116
2394
  const ctx = await resolveFileMutationContext(c, adapter, "patch-elements-batch");
2117
2395
  if ("error" in ctx) return ctx.error;
2118
2396
  const body = await c.req.json().catch(() => null);
2119
- if (!body || !Array.isArray(body.patches) || body.patches.length === 0 || body.patches.some(
2120
- (patch) => !patch?.target || !Array.isArray(patch.operations) || patch.operations.length === 0
2121
- )) {
2397
+ if (!body || !Array.isArray(body.patches) || body.patches.length === 0 || !body.patches.every(isElementPatchRequest)) {
2122
2398
  return c.json({ error: "patches with target and operations required" }, 400);
2123
2399
  }
2124
- const unsafeFields = body.patches.flatMap((patch) => findUnsafeDomPatchValues(patch));
2400
+ const batch = { sourceFile: ctx.filePath, patches: body.patches };
2401
+ const unsafeFields = findUnsafeElementPatchBatchValues([batch]);
2125
2402
  if (unsafeFields.length > 0) {
2126
2403
  return rejectUnsafeMutationValues(c, unsafeFields);
2127
2404
  }
2128
- let originalContent;
2129
- try {
2130
- originalContent = readFileSync3(ctx.absPath, "utf-8");
2131
- } catch {
2132
- return c.json({ error: "not found" }, 404);
2133
- }
2134
- const result = foldElementPatches(originalContent, body.patches);
2135
- if (result.content === originalContent) {
2136
- return c.json({
2137
- ok: true,
2138
- changed: false,
2139
- matched: result.matched,
2140
- content: originalContent,
2141
- path: ctx.filePath
2142
- });
2405
+ const result = commitElementPatchBatches(ctx.project.dir, [batch]);
2406
+ if ("error" in result) {
2407
+ return elementPatchBatchCommitErrorResponse(c, result.error, result.sourceFile);
2143
2408
  }
2144
- const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath);
2145
- if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`);
2146
- writeFileSync4(ctx.absPath, result.content, "utf-8");
2409
+ const file = result.files[0];
2410
+ if (!file) return c.json({ error: "empty element patch result" }, 500);
2147
2411
  return c.json({
2148
2412
  ok: true,
2149
- changed: true,
2150
- matched: result.matched,
2151
- content: result.content,
2152
- path: ctx.filePath,
2153
- backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath)
2413
+ changed: file.changed,
2414
+ matched: file.matched,
2415
+ content: file.after,
2416
+ path: file.sourceFile,
2417
+ backupPath: file.backupPath
2154
2418
  });
2155
2419
  });
2156
2420
  api.post("/projects/:id/file-mutations/wrap-elements/*", async (c) => {
@@ -2171,7 +2435,7 @@ function registerFileRoutes(api, adapter) {
2171
2435
  }
2172
2436
  let originalContent;
2173
2437
  try {
2174
- originalContent = readFileSync3(ctx.absPath, "utf-8");
2438
+ originalContent = readFileSync4(ctx.absPath, "utf-8");
2175
2439
  } catch {
2176
2440
  return c.json({ error: "not found" }, 404);
2177
2441
  }
@@ -2213,7 +2477,7 @@ function registerFileRoutes(api, adapter) {
2213
2477
  if ("error" in parsed) return parsed.error;
2214
2478
  let originalContent;
2215
2479
  try {
2216
- originalContent = readFileSync3(ctx.absPath, "utf-8");
2480
+ originalContent = readFileSync4(ctx.absPath, "utf-8");
2217
2481
  } catch {
2218
2482
  return c.json({ error: "not found" }, 404);
2219
2483
  }
@@ -2242,7 +2506,7 @@ function registerFileRoutes(api, adapter) {
2242
2506
  if ("error" in parsed) return parsed.error;
2243
2507
  let content;
2244
2508
  try {
2245
- content = readFileSync3(ctx.absPath, "utf-8");
2509
+ content = readFileSync4(ctx.absPath, "utf-8");
2246
2510
  } catch {
2247
2511
  return c.json({ exists: false });
2248
2512
  }
@@ -2285,7 +2549,7 @@ function registerFileRoutes(api, adapter) {
2285
2549
  return c.json({ error: "forbidden" }, 403);
2286
2550
  }
2287
2551
  ensureDir(destAbs);
2288
- writeFileSync4(destAbs, readFileSync3(srcAbs));
2552
+ writeFileSync4(destAbs, readFileSync4(srcAbs));
2289
2553
  return c.json({ ok: true, path: copyPath }, 201);
2290
2554
  });
2291
2555
  const MAX_UPLOAD_BYTES = 500 * 1024 * 1024;
@@ -2315,7 +2579,7 @@ function registerFileRoutes(api, adapter) {
2315
2579
  mustExist: true
2316
2580
  });
2317
2581
  if ("error" in res) return res.error;
2318
- const html = readFileSync3(res.absPath, "utf-8");
2582
+ const html = readFileSync4(res.absPath, "utf-8");
2319
2583
  const block = extractGsapScriptBlock(html);
2320
2584
  if (!block) {
2321
2585
  return c.json({
@@ -2328,6 +2592,11 @@ function registerFileRoutes(api, adapter) {
2328
2592
  const parsed = parseGsapScriptAcorn(block.scriptText);
2329
2593
  return c.json(parsed);
2330
2594
  });
2595
+ api.get("/projects/:id/gsap-mutation-capabilities", async (c) => {
2596
+ const project = await adapter.resolveProject(c.req.param("id"));
2597
+ if (!project) return c.json({ error: "not found" }, 404);
2598
+ return c.json({ atomicOwnershipPairs: true });
2599
+ });
2331
2600
  api.post("/projects/:id/gsap-mutations/*", async (c) => {
2332
2601
  const res = await resolveProjectPath(c, adapter, (id) => `/projects/${id}/gsap-mutations/`, {
2333
2602
  mustExist: true
@@ -2357,6 +2626,25 @@ function registerFileRoutes(api, adapter) {
2357
2626
  }
2358
2627
  return applyGsapMutations(c, res, body.mutations);
2359
2628
  });
2629
+ api.post("/projects/:id/gsap-mutation-rollback/*", async (c) => {
2630
+ const res = await resolveProjectPath(
2631
+ c,
2632
+ adapter,
2633
+ (id) => `/projects/${id}/gsap-mutation-rollback/`,
2634
+ { mustExist: true }
2635
+ );
2636
+ if ("error" in res) return res.error;
2637
+ const body = await c.req.json().catch(() => null);
2638
+ if (!body || typeof body.expected !== "string" || typeof body.restore !== "string") {
2639
+ return c.json({ error: "expected and restore contents required" }, 400);
2640
+ }
2641
+ const current = readFileSync4(res.absPath, "utf-8");
2642
+ if (current !== body.expected) {
2643
+ return c.json({ ok: true, restored: false, conflict: true });
2644
+ }
2645
+ writeFileSync4(res.absPath, body.restore, "utf-8");
2646
+ return c.json({ ok: true, restored: true, conflict: false });
2647
+ });
2360
2648
  }
2361
2649
 
2362
2650
  // src/routes/preview.ts
@@ -2366,7 +2654,7 @@ import { createHash as createHash2 } from "crypto";
2366
2654
  import { injectScriptsIntoHtml, stripEmbeddedRuntimeScripts as stripEmbeddedRuntimeScripts2 } from "@hyperframes/core/compiler";
2367
2655
 
2368
2656
  // src/helpers/subComposition.ts
2369
- import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
2657
+ import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
2370
2658
  import { join as join7 } from "path";
2371
2659
  import { parseHTML as parseHTML3 } from "linkedom";
2372
2660
  import {
@@ -2475,7 +2763,7 @@ function tagRootCompositionFile(bodyHtml, compPath) {
2475
2763
  function buildSubCompositionHtml(projectDir, compPath, runtimeUrl, baseHref, rawOverride) {
2476
2764
  const compFile = join7(projectDir, compPath);
2477
2765
  if (!existsSync4(compFile)) return null;
2478
- const rawComp = rawOverride ?? readFileSync4(compFile, "utf-8");
2766
+ const rawComp = rawOverride ?? readFileSync5(compFile, "utf-8");
2479
2767
  let compHeadContent = "";
2480
2768
  let rewrittenContent;
2481
2769
  let htmlAttrs = "";
@@ -2508,7 +2796,7 @@ function buildSubCompositionHtml(projectDir, compPath, runtimeUrl, baseHref, raw
2508
2796
  const indexPath = join7(projectDir, "index.html");
2509
2797
  let headContent = "";
2510
2798
  if (existsSync4(indexPath)) {
2511
- const indexHtml = readFileSync4(indexPath, "utf-8");
2799
+ const indexHtml = readFileSync5(indexPath, "utf-8");
2512
2800
  const headMatch = indexHtml.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
2513
2801
  headContent = headMatch?.[1] ?? "";
2514
2802
  }
@@ -2541,145 +2829,6 @@ ${rewrittenContent}
2541
2829
  </html>`;
2542
2830
  }
2543
2831
 
2544
- // src/helpers/projectSignature.ts
2545
- import { createHash } from "crypto";
2546
- import { lstatSync, readFileSync as readFileSync5, readdirSync as readdirSync4 } from "fs";
2547
- import { extname, isAbsolute, relative as relative2, resolve as resolve3 } from "path";
2548
- var SIGNATURE_TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
2549
- ".cjs",
2550
- ".css",
2551
- ".html",
2552
- ".js",
2553
- ".json",
2554
- ".jsx",
2555
- ".mjs",
2556
- ".svg",
2557
- ".ts",
2558
- ".tsx"
2559
- ]);
2560
- var SIGNATURE_EXCLUDED_DIRS = /* @__PURE__ */ new Set([
2561
- ".cache",
2562
- ".git",
2563
- ".hyperframes",
2564
- ".next",
2565
- ".vite",
2566
- "build",
2567
- "coverage",
2568
- "dist",
2569
- "node_modules",
2570
- "outputs",
2571
- "renders"
2572
- ]);
2573
- var MAX_SIGNATURE_TEXT_BYTES = 2e6;
2574
- var STUDIO_SIGNATURE_MANIFEST_PATHS = [
2575
- ".hyperframes/studio-manual-edits.json",
2576
- ".hyperframes/studio-motion.json"
2577
- ];
2578
- var projectSignatureCache = /* @__PURE__ */ new Map();
2579
- function isPathWithin(parentDir, childPath) {
2580
- const childRelativePath = relative2(parentDir, childPath);
2581
- return childRelativePath === "" || !childRelativePath.startsWith("..") && !isAbsolute(childRelativePath);
2582
- }
2583
- function isTextContentEligible(file, size) {
2584
- return SIGNATURE_TEXT_EXTENSIONS.has(extname(file).toLowerCase()) && size <= MAX_SIGNATURE_TEXT_BYTES;
2585
- }
2586
- function collectProjectSignatureFiles(projectDir, dir, files) {
2587
- let entries;
2588
- try {
2589
- entries = readdirSync4(dir).sort();
2590
- } catch {
2591
- return;
2592
- }
2593
- for (const entry of entries) {
2594
- if (SIGNATURE_EXCLUDED_DIRS.has(entry)) continue;
2595
- const file = resolve3(dir, entry);
2596
- if (!isPathWithin(projectDir, file)) continue;
2597
- let stat;
2598
- try {
2599
- stat = lstatSync(file);
2600
- } catch {
2601
- continue;
2602
- }
2603
- if (stat.isSymbolicLink()) continue;
2604
- if (stat.isDirectory()) {
2605
- collectProjectSignatureFiles(projectDir, file, files);
2606
- } else if (stat.isFile()) {
2607
- files.push({
2608
- file,
2609
- mtimeMs: stat.mtimeMs,
2610
- size: stat.size,
2611
- textContentEligible: isTextContentEligible(file, stat.size)
2612
- });
2613
- }
2614
- }
2615
- }
2616
- function collectProjectSignatureManifestFiles(projectDir, files) {
2617
- const seen = new Set(files.map((entry) => entry.file));
2618
- for (const manifestPath of STUDIO_SIGNATURE_MANIFEST_PATHS) {
2619
- const file = resolve3(projectDir, manifestPath);
2620
- if (seen.has(file) || !isPathWithin(projectDir, file)) continue;
2621
- let stat;
2622
- try {
2623
- stat = lstatSync(file);
2624
- } catch {
2625
- continue;
2626
- }
2627
- if (stat.isSymbolicLink() || !stat.isFile()) continue;
2628
- files.push({
2629
- file,
2630
- mtimeMs: stat.mtimeMs,
2631
- size: stat.size,
2632
- textContentEligible: isTextContentEligible(file, stat.size)
2633
- });
2634
- seen.add(file);
2635
- }
2636
- }
2637
- function createProjectFingerprint(projectDir, files) {
2638
- const hash = createHash("sha256");
2639
- for (const entry of files) {
2640
- hash.update(relative2(projectDir, entry.file));
2641
- hash.update("\0");
2642
- hash.update(String(entry.size));
2643
- hash.update("\0");
2644
- hash.update(String(entry.mtimeMs));
2645
- hash.update("\0");
2646
- hash.update(entry.textContentEligible ? "text" : "binary");
2647
- hash.update("\0");
2648
- }
2649
- return hash.digest("hex").slice(0, 24);
2650
- }
2651
- function createProjectSignature(projectDir) {
2652
- const normalizedProjectDir = resolve3(projectDir);
2653
- const files = [];
2654
- collectProjectSignatureFiles(normalizedProjectDir, normalizedProjectDir, files);
2655
- collectProjectSignatureManifestFiles(normalizedProjectDir, files);
2656
- files.sort((a, b) => a.file.localeCompare(b.file));
2657
- const fingerprint = createProjectFingerprint(normalizedProjectDir, files);
2658
- const cached = projectSignatureCache.get(normalizedProjectDir);
2659
- if (cached?.fingerprint === fingerprint) return cached.signature;
2660
- const hash = createHash("sha256");
2661
- for (const entry of files) {
2662
- const relativePath = relative2(normalizedProjectDir, entry.file);
2663
- hash.update(relativePath);
2664
- hash.update("\0");
2665
- hash.update(String(entry.size));
2666
- hash.update("\0");
2667
- if (entry.textContentEligible) {
2668
- try {
2669
- hash.update(readFileSync5(entry.file));
2670
- } catch {
2671
- hash.update(String(entry.mtimeMs));
2672
- }
2673
- } else {
2674
- hash.update(String(entry.mtimeMs));
2675
- }
2676
- hash.update("\0");
2677
- }
2678
- const signature = hash.digest("hex").slice(0, 24);
2679
- projectSignatureCache.set(normalizedProjectDir, { fingerprint, signature });
2680
- return signature;
2681
- }
2682
-
2683
2832
  // src/helpers/studioMotionRenderScript.ts
2684
2833
  var STUDIO_MOTION_PATH = ".hyperframes/studio-motion.json";
2685
2834
  function hasStudioMotionEntries(manifestContent) {
@@ -2945,9 +3094,6 @@ var GSAP_CDN_VERSION = "3.15.0";
2945
3094
  var GSAP_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/gsap.min.js"></script>`;
2946
3095
  var GSAP_CUSTOM_EASE_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/CustomEase.min.js"></script>`;
2947
3096
  var GSAP_MOTION_PATH_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/MotionPathPlugin.min.js"></script>`;
2948
- function resolveProjectSignature(adapter, projectDir) {
2949
- return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir);
2950
- }
2951
3097
  function injectProjectSignature(html, signature) {
2952
3098
  const tag = `<meta name="${PROJECT_SIGNATURE_META}" content="${signature}">`;
2953
3099
  if (html.includes(`name="${PROJECT_SIGNATURE_META}"`)) {
@@ -3136,12 +3282,12 @@ function registerPreviewRoutes(api, adapter) {
3136
3282
  ETag: etag
3137
3283
  });
3138
3284
  api.get("/projects/:id/preview", async (c) => {
3139
- const project = await adapter.resolveProject(c.req.param("id"));
3140
- 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;
3141
3288
  const vars = previewVariablesFromRequest(c.req.query("variables"));
3142
3289
  if (vars.error !== void 0) return c.json({ error: vars.error }, 400);
3143
3290
  const previewVariables = vars.values;
3144
- const signature = resolveProjectSignature(adapter, project.dir);
3145
3291
  const etag = `"preview:${signature}${variablesEtagSalt(vars.raw)}"`;
3146
3292
  const ifNoneMatch = c.req.header("If-None-Match");
3147
3293
  if (ifNoneMatch === etag) {
@@ -3201,12 +3347,12 @@ ${runtimeTag}`;
3201
3347
  return stampFileHfIds(compFile);
3202
3348
  }
3203
3349
  api.get("/projects/:id/preview/comp/*", async (c) => {
3204
- const project = await adapter.resolveProject(c.req.param("id"));
3205
- 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;
3206
3353
  const vars = previewVariablesFromRequest(c.req.query("variables"));
3207
3354
  if (vars.error !== void 0) return c.json({ error: vars.error }, 400);
3208
3355
  const previewVariables = vars.values;
3209
- const signature = resolveProjectSignature(adapter, project.dir);
3210
3356
  const compPath = decodeURIComponent(
3211
3357
  c.req.path.replace(`/projects/${project.id}/preview/comp/`, "").split("?")[0] ?? ""
3212
3358
  );
@@ -3326,7 +3472,7 @@ import { streamSSE } from "hono/streaming";
3326
3472
  import { existsSync as existsSync6, readFileSync as readFileSync9, mkdirSync as mkdirSync4, unlinkSync as unlinkSync3, readdirSync as readdirSync5, statSync as statSync3 } from "fs";
3327
3473
  import { join as join10 } from "path";
3328
3474
  import { VALID_CANVAS_RESOLUTIONS } from "@hyperframes/parsers";
3329
- import { parseFps } from "@hyperframes/core";
3475
+ import { formatRenderOutputTimestamp, parseFps } from "@hyperframes/core";
3330
3476
  var VALID_RESOLUTIONS = new Set(VALID_CANVAS_RESOLUTIONS);
3331
3477
  function registerRenderRoutes(api, adapter) {
3332
3478
  const renderJobs = /* @__PURE__ */ new Map();
@@ -3380,9 +3526,7 @@ function registerRenderRoutes(api, adapter) {
3380
3526
  variables = body.variables;
3381
3527
  }
3382
3528
  const now = /* @__PURE__ */ new Date();
3383
- const datePart = now.toISOString().slice(0, 10);
3384
- const timePart = now.toTimeString().slice(0, 8).replace(/:/g, "-");
3385
- const jobId = `${project.id}_${datePart}_${timePart}`;
3529
+ const jobId = `${project.id}_${formatRenderOutputTimestamp(now)}`;
3386
3530
  const rendersDir = adapter.rendersDir(project);
3387
3531
  if (!existsSync6(rendersDir)) mkdirSync4(rendersDir, { recursive: true });
3388
3532
  const ext = FORMAT_EXT[format] ?? ".mp4";