@hyperframes/studio-server 0.8.106 → 0.8.108

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
@@ -43,10 +43,17 @@ import {
43
43
  resolveProjectAndSignature,
44
44
  resolveProjectSignature,
45
45
  settledFileTag
46
- } from "./chunk-IQK4ANAI.js";
46
+ } from "./chunk-KUEG5MYA.js";
47
47
  import {
48
48
  getElementScreenshotClip
49
49
  } from "./chunk-W2SBTCO2.js";
50
+ import {
51
+ DEFAULT_TARGET_LUFS,
52
+ absoluteLoudnessPlan,
53
+ measureAudio,
54
+ requiredFfmpeg,
55
+ resolveLocalAudioPath
56
+ } from "./chunk-TEYDBVSK.js";
50
57
  import {
51
58
  injectMediaCodecMap,
52
59
  isAutoProxyEnabled,
@@ -587,6 +594,33 @@ import {
587
594
  syncPositionHoldsBeforeKeyframes,
588
595
  clipQueryRoot
589
596
  } from "@hyperframes/parsers/gsap-writer-acorn";
597
+
598
+ // src/helpers/studioFontFace.ts
599
+ var FONT_STYLE_RE = /<style\b[^>]*data-hf-studio-fonts=(["'])true\1[^>]*>([\s\S]*?)<\/style>/i;
600
+ var ONE_FONT_FACE_RULE = /^@font-face \{(?:[^{}<"'\\]|"(?:[^"\\<\n\r\f]|\\[^<\n\r\f])*"|'(?:[^'\\<\n\r\f]|\\[^<\n\r\f])*')*\}$/;
601
+ function isStudioFontFaceCss(css) {
602
+ return typeof css === "string" && ONE_FONT_FACE_RULE.test(css);
603
+ }
604
+ function ensureStudioFontFaceCss(html, css) {
605
+ if (html.includes(css)) return html;
606
+ const block = FONT_STYLE_RE.exec(html);
607
+ if (block) {
608
+ const next = `${(block[2] ?? "").trim()}
609
+ ${css}`.trim();
610
+ return html.replace(block[0], () => `<style data-hf-studio-fonts="true">
611
+ ${next}
612
+ </style>`);
613
+ }
614
+ const styleTag = `<style data-hf-studio-fonts="true">
615
+ ${css}
616
+ </style>`;
617
+ if (/<\/head>/i.test(html)) return html.replace(/<\/head>/i, () => ` ${styleTag}
618
+ </head>`);
619
+ return `${styleTag}
620
+ ${html}`;
621
+ }
622
+
623
+ // src/routes/files.ts
590
624
  import { parseHTML as parseHTML2 } from "linkedom";
591
625
  import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
592
626
 
@@ -1221,7 +1255,7 @@ function bakeGroupTransformIntoMembers(html, groupId, members, groupCenter) {
1221
1255
  const rad = grot * Math.PI / 180;
1222
1256
  const cos = Math.cos(rad);
1223
1257
  const sin = Math.sin(rad);
1224
- const round3 = (n) => Math.round(n * 1e3) / 1e3;
1258
+ const round32 = (n) => Math.round(n * 1e3) / 1e3;
1225
1259
  let script = block.scriptText;
1226
1260
  for (const m of members) {
1227
1261
  const memberSel = `#${m.id}`;
@@ -1238,27 +1272,27 @@ function bakeGroupTransformIntoMembers(html, groupId, members, groupCenter) {
1238
1272
  const visY = groupCenter.cy + gscale * (sin * dx + cos * dy) + gy;
1239
1273
  const newProps = {
1240
1274
  ...mProps,
1241
- x: round3(visX - m.cx),
1242
- y: round3(visY - m.cy)
1275
+ x: round32(visX - m.cx),
1276
+ y: round32(visY - m.cy)
1243
1277
  };
1244
1278
  if (gz !== 0) newProps.z = (typeof mProps.z === "number" ? mProps.z : 0) + gz;
1245
1279
  if (grot !== 0) {
1246
- newProps.rotation = round3(
1280
+ newProps.rotation = round32(
1247
1281
  (typeof mProps.rotation === "number" ? mProps.rotation : 0) + grot
1248
1282
  );
1249
1283
  }
1250
1284
  if (gscale !== 1) {
1251
- newProps.scale = round3((typeof mProps.scale === "number" ? mProps.scale : 1) * gscale);
1285
+ newProps.scale = round32((typeof mProps.scale === "number" ? mProps.scale : 1) * gscale);
1252
1286
  }
1253
1287
  const pivoted = /* @__PURE__ */ new Set(["x", "y", "z", "rotation", "scale"]);
1254
1288
  for (const [k, v] of Object.entries(gt)) {
1255
1289
  if (pivoted.has(k) || typeof v !== "number") continue;
1256
1290
  if (k === "scaleX" || k === "scaleY") {
1257
- if (v !== 1) newProps[k] = round3((typeof mProps[k] === "number" ? mProps[k] : 1) * v);
1291
+ if (v !== 1) newProps[k] = round32((typeof mProps[k] === "number" ? mProps[k] : 1) * v);
1258
1292
  } else if (k === "transformPerspective") {
1259
1293
  if (typeof mProps[k] !== "number") newProps[k] = v;
1260
1294
  } else if (v !== 0) {
1261
- newProps[k] = round3((typeof mProps[k] === "number" ? mProps[k] : 0) + v);
1295
+ newProps[k] = round32((typeof mProps[k] === "number" ? mProps[k] : 0) + v);
1262
1296
  }
1263
1297
  }
1264
1298
  for (const s of [...sets].reverse()) {
@@ -1439,6 +1473,7 @@ async function prepareGsapMutationScript(c, res, firstMutation) {
1439
1473
  "</script>"
1440
1474
  ].join("\n");
1441
1475
  html = insertBeforeCloseTag(html, "body", `${bootstrap}
1476
+ `) ?? insertBeforeCloseTag(html, "template", `${bootstrap}
1442
1477
  `) ?? `${html}
1443
1478
  ${bootstrap}`;
1444
1479
  block = extractGsapScriptBlock(html);
@@ -2767,6 +2802,10 @@ function registerFileRoutes(api, adapter) {
2767
2802
  if (!Array.isArray(parsed.body.operations) || parsed.body.operations.length === 0) {
2768
2803
  return c.json({ error: "target and operations required" }, 400);
2769
2804
  }
2805
+ const { fontFaceCss } = parsed.body;
2806
+ if (fontFaceCss !== void 0 && !isStudioFontFaceCss(fontFaceCss)) {
2807
+ return c.json({ error: "fontFaceCss must be one @font-face rule" }, 400);
2808
+ }
2770
2809
  const unsafeFields = findUnsafeDomPatchValues(parsed.body);
2771
2810
  if (unsafeFields.length > 0) {
2772
2811
  return rejectUnsafeMutationValues(c, unsafeFields);
@@ -2778,11 +2817,9 @@ function registerFileRoutes(api, adapter) {
2778
2817
  } catch {
2779
2818
  return c.json({ error: "not found" }, 404);
2780
2819
  }
2781
- const { html: patched, matched } = patchElementInHtml(
2782
- originalContent,
2783
- parsed.target,
2784
- parsed.body.operations
2785
- );
2820
+ const element = patchElementInHtml(originalContent, parsed.target, parsed.body.operations);
2821
+ const { matched } = element;
2822
+ const patched = matched && isStudioFontFaceCss(fontFaceCss) ? ensureStudioFontFaceCss(element.html, fontFaceCss) : element.html;
2786
2823
  if (patched === originalContent) {
2787
2824
  const version2 = fileContentVersion(originalContent);
2788
2825
  c.header("ETag", version2);
@@ -4066,7 +4103,8 @@ function registerRenderRoutes(api, adapter) {
4066
4103
  progress: current.progress,
4067
4104
  status: current.status,
4068
4105
  stage: current.stage,
4069
- error: current.error
4106
+ error: current.error,
4107
+ ...current.status === "complete" && current.audioLoweredDb !== void 0 ? { audioLoweredDb: current.audioLoweredDb } : {}
4070
4108
  })
4071
4109
  });
4072
4110
  if (current.status !== "rendering") break;
@@ -4175,11 +4213,13 @@ function registerRenderRoutes(api, adapter) {
4175
4213
  const metaPath = join10(rendersDir, `${rid}.meta.json`);
4176
4214
  let status = "complete";
4177
4215
  let durationMs;
4216
+ let audioLoweredDb;
4178
4217
  if (existsSync6(metaPath)) {
4179
4218
  try {
4180
4219
  const meta = JSON.parse(readFileSync7(metaPath, "utf-8"));
4181
4220
  if (meta.status === "failed" && !existsSync6(fp)) status = "failed";
4182
4221
  if (meta.durationMs) durationMs = meta.durationMs;
4222
+ if (typeof meta.audioLoweredDb === "number") audioLoweredDb = meta.audioLoweredDb;
4183
4223
  } catch {
4184
4224
  }
4185
4225
  }
@@ -4189,7 +4229,8 @@ function registerRenderRoutes(api, adapter) {
4189
4229
  size: stat2.size,
4190
4230
  createdAt: stat2.mtimeMs,
4191
4231
  status,
4192
- durationMs
4232
+ durationMs,
4233
+ ...audioLoweredDb !== void 0 ? { audioLoweredDb } : {}
4193
4234
  };
4194
4235
  }).sort((a, b) => b.createdAt - a.createdAt);
4195
4236
  for (const file of files) {
@@ -4229,10 +4270,10 @@ var ThumbnailGenerationCoordinator = class {
4229
4270
  if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
4230
4271
  let entry = this.entries.get(key);
4231
4272
  if (!entry) {
4232
- let resolve6;
4273
+ let resolve7;
4233
4274
  let reject;
4234
4275
  const promise = new Promise((resolvePromise, rejectPromise) => {
4235
- resolve6 = resolvePromise;
4276
+ resolve7 = resolvePromise;
4236
4277
  reject = rejectPromise;
4237
4278
  });
4238
4279
  entry = {
@@ -4242,7 +4283,7 @@ var ThumbnailGenerationCoordinator = class {
4242
4283
  state: "queued",
4243
4284
  work,
4244
4285
  promise,
4245
- resolve: resolve6,
4286
+ resolve: resolve7,
4246
4287
  reject
4247
4288
  };
4248
4289
  this.entries.set(key, entry);
@@ -4256,7 +4297,7 @@ var ThumbnailGenerationCoordinator = class {
4256
4297
  return /* @__PURE__ */ new Set([...this.entries.keys(), ...[...this.activeEntries].map((entry) => entry.key)]);
4257
4298
  }
4258
4299
  lease(entry, signal) {
4259
- return new Promise((resolve6, reject) => {
4300
+ return new Promise((resolve7, reject) => {
4260
4301
  let released = false;
4261
4302
  const release = () => {
4262
4303
  if (released) return;
@@ -4280,7 +4321,7 @@ var ThumbnailGenerationCoordinator = class {
4280
4321
  entry.promise.then(
4281
4322
  (value) => {
4282
4323
  release();
4283
- resolve6(value);
4324
+ resolve7(value);
4284
4325
  },
4285
4326
  (reason) => {
4286
4327
  release();
@@ -4357,14 +4398,14 @@ async function generateThumbnail(path, signal) {
4357
4398
  async function resolveSource(adapter, id, url) {
4358
4399
  const project = await adapter.resolveProject(id);
4359
4400
  if (!project) throw new HTTPException(404);
4360
- let relative4;
4401
+ let relative5;
4361
4402
  try {
4362
- relative4 = requestSubPath(url, "projects/:id/image-thumbnail");
4403
+ relative5 = requestSubPath(url, "projects/:id/image-thumbnail");
4363
4404
  } catch {
4364
4405
  throw new HTTPException(400);
4365
4406
  }
4366
- if (!/\.jpe?g$/i.test(relative4)) throw new HTTPException(415);
4367
- const path = pinWithinProject(project.dir, relative4);
4407
+ if (!/\.jpe?g$/i.test(relative5)) throw new HTTPException(415);
4408
+ const path = pinWithinProject(project.dir, relative5);
4368
4409
  if (!path) throw new HTTPException(404);
4369
4410
  const info = await stat(path).catch(() => {
4370
4411
  throw new HTTPException(404);
@@ -4732,6 +4773,523 @@ function registerWaveformRoutes(api, adapter) {
4732
4773
  });
4733
4774
  }
4734
4775
 
4776
+ // src/routes/freezeFrame.ts
4777
+ import { execFile } from "child_process";
4778
+ import { existsSync as existsSync8, readFileSync as readFileSync11, statSync as statSync8 } from "fs";
4779
+ import { dirname as dirname5, join as join13, relative as relative4, sep as sep2 } from "path";
4780
+ import { findFfBinary as findFfBinary3 } from "@hyperframes/parsers/ff-binaries";
4781
+ import { replaceFileAtomically as replaceFileAtomically3 } from "@hyperframes/core/atomic-file";
4782
+
4783
+ // src/helpers/freezeFrame.ts
4784
+ import { createHash as createHash4, randomBytes } from "crypto";
4785
+ import { ensureHfIds as ensureHfIds3 } from "@hyperframes/parsers/hf-ids";
4786
+ import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
4787
+ import { resolveRateSpec, sourceTimeAt } from "@hyperframes/core/speed-ramp";
4788
+ var FREEZE_HOLD_SECONDS = 2;
4789
+ var LINK_ATTR = "data-link";
4790
+ var EPSILON = 1e-3;
4791
+ var round3 = (value) => Math.round(value * 1e3) / 1e3;
4792
+ function numberAttr(el, name) {
4793
+ const raw = el.getAttribute(name);
4794
+ if (raw === null || raw.trim() === "") return null;
4795
+ const value = Number(raw);
4796
+ return Number.isFinite(value) ? value : null;
4797
+ }
4798
+ function trackOf(el) {
4799
+ return numberAttr(el, "data-track-index") ?? numberAttr(el, "data-layer") ?? 0;
4800
+ }
4801
+ function freezeFrameMediaTime(input) {
4802
+ const rate = resolveRateSpec(input.automation, input.playbackRate);
4803
+ return input.mediaStart + sourceTimeAt(rate, Math.max(0, input.playhead - input.clipStart));
4804
+ }
4805
+ function freezeExtractArgs(src, mediaTime, output) {
4806
+ return ["-n", "-ss", String(round3(mediaTime)), "-i", src, "-frames:v", "1", output];
4807
+ }
4808
+ var randomStillToken = () => randomBytes(4).toString("hex");
4809
+ function freezeStillFileName(clipId, playhead, token = randomStillToken()) {
4810
+ const stem = clipId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 48) || "clip";
4811
+ const idHash = createHash4("sha256").update(clipId).digest("hex").slice(0, 10);
4812
+ return `${stem}-${idHash}-${Math.round(playhead * 1e3)}-${token}.png`;
4813
+ }
4814
+ function readFreezeSource(html, target, playhead) {
4815
+ const { document } = parseSourceDocument(html);
4816
+ const el = findTargetElement(document, target);
4817
+ if (!el || el.tagName.toLowerCase() !== "video") return null;
4818
+ const src = el.getAttribute("src");
4819
+ const start = numberAttr(el, "data-start");
4820
+ const duration = numberAttr(el, "data-duration");
4821
+ if (!src || start === null || duration === null) return null;
4822
+ if (playhead <= start + EPSILON || playhead >= start + duration - EPSILON) return null;
4823
+ return {
4824
+ id: el.getAttribute("id") || "clip",
4825
+ src,
4826
+ mediaTime: freezeFrameMediaTime({
4827
+ clipStart: start,
4828
+ playhead,
4829
+ mediaStart: readMediaOffsetSeconds((name) => el.getAttribute(name)),
4830
+ playbackRate: numberAttr(el, "data-playback-rate") ?? 1,
4831
+ automation: el.getAttribute("data-automation")
4832
+ })
4833
+ };
4834
+ }
4835
+ function targetOf(el) {
4836
+ const hfId = el.getAttribute("data-hf-id");
4837
+ return { ...hfId ? { hfId } : {}, id: el.getAttribute("id") };
4838
+ }
4839
+ function spans(el, time) {
4840
+ const start = numberAttr(el, "data-start");
4841
+ const duration = numberAttr(el, "data-duration");
4842
+ if (start === null || duration === null) return false;
4843
+ return time > start + EPSILON && time < start + duration - EPSILON;
4844
+ }
4845
+ function uniqueId(document, base) {
4846
+ let id = base;
4847
+ for (let n = 2; document.getElementById(id); n++) id = `${base}-${n}`;
4848
+ return id;
4849
+ }
4850
+ function shiftTracks(document, tracks, from, by) {
4851
+ for (const el of document.querySelectorAll("[data-start]")) {
4852
+ if (el.hasAttribute("data-composition-id") || !tracks.has(trackOf(el))) continue;
4853
+ const start = numberAttr(el, "data-start");
4854
+ if (start === null || start < from - EPSILON) continue;
4855
+ el.setAttribute("data-start", String(round3(start + by)));
4856
+ }
4857
+ }
4858
+ function growRootToFit(document) {
4859
+ const root = document.querySelector("[data-composition-id]");
4860
+ const rootDuration = root ? numberAttr(root, "data-duration") : null;
4861
+ if (!root || rootDuration === null) return;
4862
+ let end = rootDuration;
4863
+ for (const el of root.querySelectorAll("[data-start][data-duration]")) {
4864
+ const start = numberAttr(el, "data-start");
4865
+ const duration = numberAttr(el, "data-duration");
4866
+ if (start !== null && duration !== null) end = Math.max(end, start + duration);
4867
+ }
4868
+ if (end > rootDuration + EPSILON) root.setAttribute("data-duration", String(round3(end)));
4869
+ }
4870
+ var COPIED_PICTURE_ATTRS = ["class", "style", "data-color-grading"];
4871
+ function buildStill(document, video, input) {
4872
+ const img = document.createElement("img");
4873
+ img.setAttribute("id", input.id);
4874
+ for (const name of COPIED_PICTURE_ATTRS) {
4875
+ const value = video.getAttribute(name);
4876
+ if (value !== null) img.setAttribute(name, value);
4877
+ }
4878
+ if (!img.classList.contains("clip")) img.classList.add("clip");
4879
+ img.setAttribute("src", input.imageSrc);
4880
+ img.setAttribute("alt", "");
4881
+ img.setAttribute("data-start", String(round3(input.playhead)));
4882
+ img.setAttribute("data-duration", String(input.holdSeconds));
4883
+ img.setAttribute("data-track-index", String(trackOf(video)));
4884
+ img.setAttribute("data-timeline-label", "Freeze");
4885
+ return img;
4886
+ }
4887
+ function linkedPartners(document, video) {
4888
+ const link = video.getAttribute(LINK_ATTR);
4889
+ if (!link) return [];
4890
+ return Array.from(document.querySelectorAll(`[${LINK_ATTR}]`)).filter(
4891
+ (el) => el !== video && el.getAttribute(LINK_ATTR) === link
4892
+ );
4893
+ }
4894
+ function splitAll(html, elements, time) {
4895
+ const rightHalfIds = [];
4896
+ let next = html;
4897
+ for (const el of elements) {
4898
+ const split = splitElementInHtml(
4899
+ next,
4900
+ targetOf(el),
4901
+ time,
4902
+ `${el.getAttribute("id") || "clip"}-split`,
4903
+ {
4904
+ start: numberAttr(el, "data-start") ?? 0,
4905
+ duration: numberAttr(el, "data-duration") ?? 0,
4906
+ track: trackOf(el)
4907
+ }
4908
+ );
4909
+ if (!split.matched || !split.newId) return null;
4910
+ next = split.html;
4911
+ rightHalfIds.push(split.newId);
4912
+ }
4913
+ return { html: next, rightHalfIds };
4914
+ }
4915
+ function relinkRightHalves(document, link, ids) {
4916
+ if (!link) return;
4917
+ const rightLink = uniqueLink(document, link);
4918
+ for (const id of ids) document.getElementById(id)?.setAttribute(LINK_ATTR, rightLink);
4919
+ }
4920
+ function applyFreezeFrameToHtml(html, input) {
4921
+ const hold = input.holdSeconds ?? FREEZE_HOLD_SECONDS;
4922
+ const { document: before } = parseSourceDocument(html);
4923
+ const video = findTargetElement(before, input.target);
4924
+ if (!video || !isHTMLElement(video) || !spans(video, input.playhead)) return null;
4925
+ const partners = linkedPartners(before, video);
4926
+ const tracks = /* @__PURE__ */ new Set([trackOf(video), ...partners.map(trackOf)]);
4927
+ const cut = splitAll(
4928
+ html,
4929
+ [video, ...partners.filter((el) => spans(el, input.playhead))],
4930
+ input.playhead
4931
+ );
4932
+ if (!cut) return null;
4933
+ const { document, wrappedFragment } = parseSourceDocument(cut.html);
4934
+ relinkRightHalves(document, video.getAttribute(LINK_ATTR), cut.rightHalfIds);
4935
+ shiftTracks(document, tracks, input.playhead, hold);
4936
+ const leftHalf = findTargetElement(document, input.target);
4937
+ if (!leftHalf?.parentElement) return null;
4938
+ const freezeId = uniqueId(document, `${video.getAttribute("id") || "clip"}-freeze`);
4939
+ const still = buildStill(document, leftHalf, {
4940
+ id: freezeId,
4941
+ imageSrc: input.imageSrc,
4942
+ playhead: input.playhead,
4943
+ holdSeconds: hold
4944
+ });
4945
+ leftHalf.parentElement.insertBefore(still, leftHalf.nextSibling);
4946
+ growRootToFit(document);
4947
+ return {
4948
+ html: ensureHfIds3(wrappedFragment ? document.body.innerHTML || "" : document.toString()),
4949
+ freezeId
4950
+ };
4951
+ }
4952
+ function uniqueLink(document, base) {
4953
+ const taken = new Set(
4954
+ Array.from(document.querySelectorAll(`[${LINK_ATTR}]`)).map((el) => el.getAttribute(LINK_ATTR))
4955
+ );
4956
+ let candidate = `${base}-2`;
4957
+ for (let n = 3; taken.has(candidate); n++) candidate = `${base}-${n}`;
4958
+ return candidate;
4959
+ }
4960
+
4961
+ // src/routes/freezeFrame.ts
4962
+ var EXTRACT_TIMEOUT_MS = 3e4;
4963
+ var ffmpegExtractor = (args) => {
4964
+ const ffmpeg = findFfBinary3("ffmpeg", { configuredMustExist: true });
4965
+ if (!ffmpeg) return Promise.resolve({ ok: false, error: "ffmpeg not found" });
4966
+ return new Promise((resolvePromise) => {
4967
+ execFile(
4968
+ ffmpeg,
4969
+ args,
4970
+ { timeout: EXTRACT_TIMEOUT_MS, windowsHide: true },
4971
+ (error, _o, stderr) => resolvePromise(
4972
+ error ? { ok: false, error: String(stderr || error.message) } : { ok: true }
4973
+ )
4974
+ );
4975
+ });
4976
+ };
4977
+ function isFreezeFrameRequest(value) {
4978
+ if (typeof value !== "object" || value === null) return false;
4979
+ const body = value;
4980
+ return typeof body.path === "string" && body.path.length > 0 && typeof body.expectedVersion === "string" && typeof body.target === "object" && body.target !== null && typeof body.playhead === "number" && Number.isFinite(body.playhead);
4981
+ }
4982
+ var FREEZE_DIR = ["assets", "freeze"];
4983
+ function readExpected(absPath, expectedVersion) {
4984
+ let content;
4985
+ try {
4986
+ content = readFileSync11(absPath, "utf-8");
4987
+ } catch {
4988
+ return { error: "not found", status: 404 };
4989
+ }
4990
+ return fileContentVersion(content) === expectedVersion ? { content } : { error: "file conflict", status: 409 };
4991
+ }
4992
+ async function extractStill(projectDir, absPath, source, playhead, tools) {
4993
+ const fileDir = dirname5(absPath);
4994
+ const mediaPath = pinWithinProject(projectDir, relative4(projectDir, join13(fileDir, source.src)));
4995
+ if (!mediaPath) return { error: `forbidden media path: ${source.src}`, status: 403 };
4996
+ const freezeDir = join13(projectDir, ...FREEZE_DIR);
4997
+ const fileName = freezeStillFileName(source.id, playhead, tools.stillToken());
4998
+ mkdirWithinProject(projectDir, freezeDir);
4999
+ const imagePath = pinWithinProject(projectDir, join13(...FREEZE_DIR, fileName));
5000
+ if (!imagePath || dirname5(imagePath) !== freezeDir) {
5001
+ return { error: `forbidden freeze path: ${fileName}`, status: 403 };
5002
+ }
5003
+ if (existsSync8(imagePath))
5004
+ return { error: `freeze still already exists: ${fileName}`, status: 409 };
5005
+ const extracted = await tools.extract(freezeExtractArgs(mediaPath, source.mediaTime, imagePath));
5006
+ if (!extracted.ok) {
5007
+ return {
5008
+ error: `Could not extract the frame: ${extracted.error ?? "ffmpeg failed"}`,
5009
+ status: 500
5010
+ };
5011
+ }
5012
+ const depth = relative4(projectDir, fileDir).split(sep2).filter(Boolean).length;
5013
+ return { imageSrc: `${"../".repeat(depth)}${FREEZE_DIR.join("/")}/${fileName}` };
5014
+ }
5015
+ function writeFolded(projectDir, absPath, path, before, after, options) {
5016
+ if (readFileSync11(absPath, "utf-8") !== before) {
5017
+ return { error: `file conflict: ${path}`, status: 409 };
5018
+ }
5019
+ const backup = snapshotBeforeWrite(projectDir, absPath);
5020
+ if (backup.error) return { error: `Failed to create backup: ${backup.error}`, status: 500 };
5021
+ const writeToken = createWriteToken(options.token);
5022
+ replaceFileAtomically3(absPath, after, statSync8(absPath).mode);
5023
+ const version = fileContentVersion(after);
5024
+ recordFileWriteReceipt(absPath, { path, version, writeToken, overwrote: before });
5025
+ return { version, writeToken, backupPath: backupPathForResponse(projectDir, backup.backupPath) };
5026
+ }
5027
+ function registerFreezeFrameRoutes(api, adapter, extract = ffmpegExtractor, stillToken = randomStillToken) {
5028
+ api.post("/projects/:id/file-mutations/freeze-frame", async (c) => {
5029
+ const body = await c.req.json().catch(() => null);
5030
+ if (!isFreezeFrameRequest(body)) {
5031
+ return c.json({ error: "path, expectedVersion, target and playhead required" }, 400);
5032
+ }
5033
+ const project = await adapter.resolveProject(c.req.param("id"));
5034
+ if (!project) return c.json({ error: "not found" }, 404);
5035
+ const absPath = pinWithinProject(project.dir, body.path);
5036
+ if (!absPath) return c.json({ error: `forbidden path: ${body.path}` }, 403);
5037
+ const read = readExpected(absPath, body.expectedVersion);
5038
+ if ("error" in read) return c.json({ error: `${read.error}: ${body.path}` }, read.status);
5039
+ const before = read.content;
5040
+ const source = readFreezeSource(before, body.target, body.playhead);
5041
+ if (!source) return c.json({ error: "Move the playhead inside a video clip to freeze" }, 400);
5042
+ const still = await extractStill(project.dir, absPath, source, body.playhead, {
5043
+ extract,
5044
+ stillToken
5045
+ });
5046
+ if ("error" in still) return c.json({ error: still.error }, still.status);
5047
+ const { imageSrc } = still;
5048
+ const folded = applyFreezeFrameToHtml(before, {
5049
+ target: body.target,
5050
+ playhead: body.playhead,
5051
+ imageSrc
5052
+ });
5053
+ if (!folded) return c.json({ error: "Freeze target was not found in the file" }, 400);
5054
+ const written = writeFolded(project.dir, absPath, body.path, before, folded.html, {
5055
+ token: body.transactionToken ?? c.req.header("X-Hyperframes-Write-Token")
5056
+ });
5057
+ if ("error" in written) return c.json({ error: written.error }, written.status);
5058
+ const { version, writeToken, backupPath } = written;
5059
+ return c.json({
5060
+ ok: true,
5061
+ path: body.path,
5062
+ before,
5063
+ after: folded.html,
5064
+ version,
5065
+ writeToken,
5066
+ backupPath,
5067
+ freezeId: folded.freezeId,
5068
+ imageSrc
5069
+ });
5070
+ });
5071
+ }
5072
+
5073
+ // src/routes/loudness.ts
5074
+ import { existsSync as existsSync9 } from "fs";
5075
+ var defaultMeasure = (file, window) => measureAudio(requiredFfmpeg(), file, window);
5076
+ var InvalidField = class extends Error {
5077
+ };
5078
+ function numberField(body, name, fallback, valid) {
5079
+ const raw = body[name];
5080
+ if (raw === void 0 || raw === null) return fallback;
5081
+ if (typeof raw === "number" && Number.isFinite(raw) && valid(raw)) return raw;
5082
+ throw new InvalidField(`${name} is out of range`);
5083
+ }
5084
+ function readNormalizeRequest(body) {
5085
+ const src = typeof body.src === "string" ? body.src.trim() : "";
5086
+ if (!src) throw new InvalidField("src is required");
5087
+ const duration = numberField(body, "duration", 0, (v) => v > 0);
5088
+ return {
5089
+ id: typeof body.id === "string" && body.id ? body.id : src,
5090
+ src,
5091
+ window: {
5092
+ mediaStart: numberField(body, "mediaStart", 0, (v) => v >= 0),
5093
+ duration: duration > 0 ? duration : null,
5094
+ playbackRate: numberField(body, "playbackRate", 1, (v) => v > 0)
5095
+ },
5096
+ volume: numberField(body, "volume", 1, (v) => v >= 0),
5097
+ targetLufs: numberField(body, "targetLufs", DEFAULT_TARGET_LUFS, (v) => v < 0)
5098
+ };
5099
+ }
5100
+ function parseNormalizeRequest(body) {
5101
+ try {
5102
+ return readNormalizeRequest(body);
5103
+ } catch (error) {
5104
+ if (error instanceof InvalidField) return error.message;
5105
+ throw error;
5106
+ }
5107
+ }
5108
+ function isRecord(value) {
5109
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5110
+ }
5111
+ function registerLoudnessRoutes(api, adapter, measure = defaultMeasure) {
5112
+ api.post("/projects/:id/loudness/normalize", async (c) => {
5113
+ const project = await adapter.resolveProject(c.req.param("id"));
5114
+ if (!project) return c.json({ error: "not found" }, 404);
5115
+ const body = await c.req.json().catch(() => null);
5116
+ if (!isRecord(body)) return c.json({ error: "invalid body" }, 400);
5117
+ const request = parseNormalizeRequest(body);
5118
+ if (typeof request === "string") return c.json({ error: request }, 400);
5119
+ let file;
5120
+ try {
5121
+ file = resolveLocalAudioPath(project.dir, request.src);
5122
+ } catch (error) {
5123
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
5124
+ }
5125
+ if (!existsSync9(file)) return c.json({ error: "file not found" }, 404);
5126
+ try {
5127
+ const measurement = await measure(file, request.window);
5128
+ const plan = absoluteLoudnessPlan(
5129
+ { id: request.id, volume: request.volume, ...measurement },
5130
+ request.targetLufs
5131
+ );
5132
+ return c.json({ plan });
5133
+ } catch (error) {
5134
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 422);
5135
+ }
5136
+ });
5137
+ }
5138
+
5139
+ // src/routes/peaks.ts
5140
+ import { existsSync as existsSync10, readFileSync as readFileSync12, statSync as statSync9 } from "fs";
5141
+ import { join as join14, resolve as resolve6 } from "path";
5142
+ import { isWithinProjectRoot as isWithinProjectRoot3 } from "@hyperframes/parsers/asset-resolution";
5143
+
5144
+ // src/helpers/peakMap.ts
5145
+ import { execFile as execFile2, spawn as spawn2 } from "child_process";
5146
+ import { promisify } from "util";
5147
+ import { findFfBinary as findFfBinary4 } from "@hyperframes/parsers/ff-binaries";
5148
+ var PEAK_BIN_SECONDS = 0.05;
5149
+ var PEAK_SAMPLE_RATE = 48e3;
5150
+ var execFileAsync = promisify(execFile2);
5151
+ var PEAK_MAP_CACHE_VERSION = "peaks-v2";
5152
+ function createPeakAccumulator(sampleRate = PEAK_SAMPLE_RATE, binSeconds = PEAK_BIN_SECONDS, channels = 2) {
5153
+ return {
5154
+ bins: [],
5155
+ frame: 0,
5156
+ carry: Buffer.alloc(0),
5157
+ framesPerBin: Math.max(1, Math.round(sampleRate * binSeconds)),
5158
+ channels: Math.max(1, channels)
5159
+ };
5160
+ }
5161
+ function framePeak(data, offset, channels) {
5162
+ let peak = 0;
5163
+ for (let ch = 0; ch < channels; ch++) {
5164
+ peak = Math.max(peak, Math.abs(data.readFloatLE(offset + ch * 4)));
5165
+ }
5166
+ return peak;
5167
+ }
5168
+ function accumulatePeaks(acc, chunk) {
5169
+ const data = acc.carry.length > 0 ? Buffer.concat([acc.carry, chunk]) : chunk;
5170
+ const frameBytes = 4 * acc.channels;
5171
+ const whole = data.length - data.length % frameBytes;
5172
+ for (let offset = 0; offset < whole; offset += frameBytes) {
5173
+ const peak = framePeak(data, offset, acc.channels);
5174
+ const bin = Math.floor(acc.frame / acc.framesPerBin);
5175
+ if (acc.bins.length <= bin) acc.bins.push(0);
5176
+ if (peak > (acc.bins[bin] ?? 0)) acc.bins[bin] = peak;
5177
+ acc.frame += 1;
5178
+ }
5179
+ acc.carry = Buffer.from(data.subarray(whole));
5180
+ }
5181
+ function finishPeaks(acc) {
5182
+ return acc.bins.map((peak) => Number(peak.toFixed(4)));
5183
+ }
5184
+ async function probeChannels(mediaPath) {
5185
+ const { stdout } = await execFileAsync(
5186
+ findFfBinary4("ffprobe") ?? "ffprobe",
5187
+ [
5188
+ "-v",
5189
+ "error",
5190
+ "-select_streams",
5191
+ "a:0",
5192
+ "-show_entries",
5193
+ "stream=channels",
5194
+ "-of",
5195
+ "csv=p=0",
5196
+ "--",
5197
+ mediaPath
5198
+ ],
5199
+ { encoding: "utf8", timeout: 3e4, windowsHide: true }
5200
+ );
5201
+ const channels = Number.parseInt(stdout.trim(), 10);
5202
+ if (!Number.isFinite(channels) || channels < 1) throw new Error("no audio stream");
5203
+ return channels;
5204
+ }
5205
+ async function decodePeakMap(mediaPath) {
5206
+ const channels = await probeChannels(mediaPath);
5207
+ return new Promise((resolvePromise, reject) => {
5208
+ const proc = spawn2(
5209
+ findFfBinary4("ffmpeg") ?? "ffmpeg",
5210
+ [
5211
+ "-v",
5212
+ "error",
5213
+ "-i",
5214
+ mediaPath,
5215
+ "-map",
5216
+ "0:a:0",
5217
+ "-vn",
5218
+ "-ar",
5219
+ String(PEAK_SAMPLE_RATE),
5220
+ "-f",
5221
+ "f32le",
5222
+ "pipe:1"
5223
+ ],
5224
+ { stdio: ["ignore", "pipe", "ignore"], windowsHide: true }
5225
+ );
5226
+ const acc = createPeakAccumulator(PEAK_SAMPLE_RATE, PEAK_BIN_SECONDS, channels);
5227
+ proc.stdout?.on("data", (chunk) => accumulatePeaks(acc, chunk));
5228
+ proc.on("close", (code) => {
5229
+ if (code !== 0) {
5230
+ reject(new Error(`ffmpeg failed decoding audio (exit ${code})`));
5231
+ return;
5232
+ }
5233
+ if (acc.frame === 0) {
5234
+ reject(new Error("ffmpeg produced no audio samples"));
5235
+ return;
5236
+ }
5237
+ resolvePromise(finishPeaks(acc));
5238
+ });
5239
+ proc.on("error", reject);
5240
+ });
5241
+ }
5242
+ function buildPeakMapCacheKey(assetPath, fingerprint) {
5243
+ const name = assetPath.replace(/[/\\]/g, "_");
5244
+ return `${PEAK_MAP_CACHE_VERSION}_${name}_${fingerprint.size}-${Math.round(fingerprint.mtimeMs)}.json`;
5245
+ }
5246
+
5247
+ // src/routes/peaks.ts
5248
+ var decodesInFlight = /* @__PURE__ */ new Map();
5249
+ function decodeOnce(cachePath, mediaPath, decode) {
5250
+ const pending = decodesInFlight.get(cachePath);
5251
+ if (pending) return pending;
5252
+ const started = decode(mediaPath).finally(() => decodesInFlight.delete(cachePath));
5253
+ decodesInFlight.set(cachePath, started);
5254
+ return started;
5255
+ }
5256
+ function readCachedBins(cacheDir, cachePath) {
5257
+ try {
5258
+ if (!isWaveformCacheDirectory(cacheDir) || !existsSync10(cachePath)) return null;
5259
+ const parsed = JSON.parse(readFileSync12(cachePath, "utf-8"));
5260
+ return Array.isArray(parsed) && parsed.every((n) => typeof n === "number") ? parsed : null;
5261
+ } catch {
5262
+ return null;
5263
+ }
5264
+ }
5265
+ function registerPeakRoutes(api, adapter, decode = decodePeakMap) {
5266
+ api.get("/projects/:id/peaks/*", async (c) => {
5267
+ const project = await adapter.resolveProject(c.req.param("id"));
5268
+ if (!project) return c.json({ error: "not found" }, 404);
5269
+ const assetPath = requestSubPath(c.req.url, "projects/:id/peaks");
5270
+ const mediaPath = resolve6(project.dir, assetPath);
5271
+ if (!isWithinProjectRoot3(project.dir, mediaPath))
5272
+ return c.json({ error: "file not found" }, 404);
5273
+ const stats = statSync9(mediaPath, { throwIfNoEntry: false });
5274
+ if (!stats?.isFile()) return c.json({ error: "file not found" }, 404);
5275
+ const cacheDir = join14(project.dir, ".waveform-cache");
5276
+ const cachePath = join14(cacheDir, buildPeakMapCacheKey(assetPath, stats));
5277
+ const cached = readCachedBins(cacheDir, cachePath);
5278
+ if (cached) return c.json({ binSeconds: PEAK_BIN_SECONDS, bins: cached });
5279
+ let bins;
5280
+ try {
5281
+ bins = await decodeOnce(cachePath, mediaPath, decode);
5282
+ } catch {
5283
+ return c.json({ error: "failed to decode audio" }, 500);
5284
+ }
5285
+ try {
5286
+ writeWaveformCache(cachePath, bins);
5287
+ } catch {
5288
+ }
5289
+ return c.json({ binSeconds: PEAK_BIN_SECONDS, bins });
5290
+ });
5291
+ }
5292
+
4735
5293
  // src/routes/fonts.ts
4736
5294
  import { closeSync as closeSync3, constants, fstatSync as fstatSync3, openSync as openSync3, readSync } from "fs";
4737
5295
  import {
@@ -4768,7 +5326,7 @@ var GOOGLE_FONT_FALLBACKS = [
4768
5326
  "Outfit",
4769
5327
  "JetBrains Mono"
4770
5328
  ];
4771
- function isRecord(value) {
5329
+ function isRecord2(value) {
4772
5330
  return typeof value === "object" && value !== null;
4773
5331
  }
4774
5332
  function collectFontsFromDir(dir) {
@@ -4792,10 +5350,10 @@ function listInstalledFontFamilies() {
4792
5350
  return cachedFonts;
4793
5351
  }
4794
5352
  function parseGoogleFontMetadata(value) {
4795
- if (!isRecord(value) || !Array.isArray(value.familyMetadataList)) return [];
5353
+ if (!isRecord2(value) || !Array.isArray(value.familyMetadataList)) return [];
4796
5354
  const families = [];
4797
5355
  for (const entry of value.familyMetadataList) {
4798
- if (!isRecord(entry) || typeof entry.family !== "string") continue;
5356
+ if (!isRecord2(entry) || typeof entry.family !== "string") continue;
4799
5357
  families.push(entry.family);
4800
5358
  }
4801
5359
  return families;
@@ -4899,14 +5457,14 @@ function registerRegistryRoutes(api, adapter) {
4899
5457
  }
4900
5458
 
4901
5459
  // src/routes/selection.ts
4902
- function isRecord2(value) {
5460
+ function isRecord3(value) {
4903
5461
  return typeof value === "object" && value !== null && !Array.isArray(value);
4904
5462
  }
4905
5463
  function isFiniteNumber(value) {
4906
5464
  return typeof value === "number" && Number.isFinite(value);
4907
5465
  }
4908
5466
  function isStringRecord(value) {
4909
- return isRecord2(value) && Object.values(value).every((v) => typeof v === "string");
5467
+ return isRecord3(value) && Object.values(value).every((v) => typeof v === "string");
4910
5468
  }
4911
5469
  function hasString(value, key) {
4912
5470
  return typeof value[key] === "string";
@@ -4924,20 +5482,20 @@ function hasOptionalNumber(value, key) {
4924
5482
  return value[key] === void 0 || isFiniteNumber(value[key]);
4925
5483
  }
4926
5484
  function isBoundingBox(value) {
4927
- return isRecord2(value) && ["x", "y", "width", "height"].every((key) => isFiniteNumber(value[key]));
5485
+ return isRecord3(value) && ["x", "y", "width", "height"].every((key) => isFiniteNumber(value[key]));
4928
5486
  }
4929
5487
  function isTarget(value) {
4930
- if (!isRecord2(value)) return false;
5488
+ if (!isRecord3(value)) return false;
4931
5489
  return hasOptionalNullableString(value, "id") && hasOptionalString(value, "hfId") && hasOptionalString(value, "selector") && hasOptionalNumber(value, "selectorIndex");
4932
5490
  }
4933
5491
  function isTextField(value) {
4934
- return isRecord2(value) && hasRequiredStrings(value, ["key", "label", "value", "tagName"]) && ["self", "child", "text-node"].includes(value.source);
5492
+ return isRecord3(value) && hasRequiredStrings(value, ["key", "label", "value", "tagName"]) && ["self", "child", "text-node"].includes(value.source);
4935
5493
  }
4936
5494
  function isTextFields(value) {
4937
5495
  return Array.isArray(value) && value.every(isTextField);
4938
5496
  }
4939
5497
  function isSelectionSnapshot(value) {
4940
- if (!isRecord2(value)) return false;
5498
+ if (!isRecord3(value)) return false;
4941
5499
  const checks = [
4942
5500
  value.schemaVersion === 1 && hasRequiredStrings(value, [
4943
5501
  "projectId",
@@ -4955,7 +5513,7 @@ function isSelectionSnapshot(value) {
4955
5513
  isStringRecord(value.inlineStyles),
4956
5514
  isStringRecord(value.computedStyles),
4957
5515
  isTextFields(value.textFields),
4958
- isRecord2(value.capabilities)
5516
+ isRecord3(value.capabilities)
4959
5517
  ];
4960
5518
  return checks.every(Boolean);
4961
5519
  }
@@ -4979,7 +5537,7 @@ function registerSelectionRoutes(api, adapter) {
4979
5537
  } catch {
4980
5538
  return c.json({ error: "invalid json" }, 400);
4981
5539
  }
4982
- if (!isRecord2(body) || !("selection" in body)) {
5540
+ if (!isRecord3(body) || !("selection" in body)) {
4983
5541
  return c.json({ error: "missing selection" }, 400);
4984
5542
  }
4985
5543
  if (body.selection === null) {
@@ -4998,8 +5556,8 @@ function registerSelectionRoutes(api, adapter) {
4998
5556
 
4999
5557
  // src/routes/media.ts
5000
5558
  import { streamSSE as streamSSE2 } from "hono/streaming";
5001
- import { existsSync as existsSync8 } from "fs";
5002
- import { basename as basename2, dirname as dirname5, extname, join as join13, posix } from "path";
5559
+ import { existsSync as existsSync11 } from "fs";
5560
+ import { basename as basename2, dirname as dirname6, extname, join as join15, posix } from "path";
5003
5561
  var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([
5004
5562
  ".mp4",
5005
5563
  ".mov",
@@ -5036,7 +5594,7 @@ function uniqueAssetPath(projectDir, assetPath, taken) {
5036
5594
  const ext = extname(assetPath);
5037
5595
  const withoutExt = assetPath.slice(0, -ext.length);
5038
5596
  let candidate = assetPath;
5039
- for (let index = 2; taken.has(candidate) || existsSync8(join13(projectDir, candidate)); index++) {
5597
+ for (let index = 2; taken.has(candidate) || existsSync11(join15(projectDir, candidate)); index++) {
5040
5598
  candidate = `${withoutExt}-${index}${ext}`;
5041
5599
  }
5042
5600
  return candidate;
@@ -5097,7 +5655,7 @@ function registerMediaRoutes(api, adapter, options = {}) {
5097
5655
  }
5098
5656
  const filePath = resolveWithinProject(project.dir, assetPath);
5099
5657
  if (!filePath) return c.json({ error: "forbidden" }, 403);
5100
- if (!existsSync8(filePath)) return c.json({ error: "media not found" }, 404);
5658
+ if (!existsSync11(filePath)) return c.json({ error: "media not found" }, 404);
5101
5659
  return c.json({ path: assetPath, metadata: await readMediaMetadata(filePath) });
5102
5660
  });
5103
5661
  api.post(
@@ -5119,7 +5677,7 @@ function registerMediaRoutes(api, adapter, options = {}) {
5119
5677
  }
5120
5678
  const inputPath = resolveWithinProject(project.dir, inputAssetPath);
5121
5679
  if (!inputPath) return c.json({ error: "forbidden" }, 403);
5122
- if (!existsSync8(inputPath)) return c.json({ error: "input media not found" }, 404);
5680
+ if (!existsSync11(inputPath)) return c.json({ error: "input media not found" }, 404);
5123
5681
  const inputIsVideo = isVideoPath(inputAssetPath);
5124
5682
  const inputIsImage = isImagePath(inputAssetPath);
5125
5683
  if (!inputIsVideo && !inputIsImage) {
@@ -5155,8 +5713,8 @@ function registerMediaRoutes(api, adapter, options = {}) {
5155
5713
  return c.json({ error: "forbidden" }, 403);
5156
5714
  }
5157
5715
  }
5158
- mkdirWithinProject(project.dir, dirname5(outputPath));
5159
- if (backgroundOutputPath) mkdirWithinProject(project.dir, dirname5(backgroundOutputPath));
5716
+ mkdirWithinProject(project.dir, dirname6(outputPath));
5717
+ if (backgroundOutputPath) mkdirWithinProject(project.dir, dirname6(backgroundOutputPath));
5160
5718
  const jobId = makeJobId(project.id, mediaJobs);
5161
5719
  const state = adapter.startBackgroundRemoval({
5162
5720
  project,
@@ -5213,14 +5771,14 @@ function registerMediaRoutes(api, adapter, options = {}) {
5213
5771
  }
5214
5772
 
5215
5773
  // src/routes/globalAssets.ts
5216
- import { existsSync as existsSync9, readFileSync as readFileSync11 } from "fs";
5774
+ import { existsSync as existsSync12, readFileSync as readFileSync13 } from "fs";
5217
5775
  import { homedir } from "os";
5218
- import { join as join14 } from "path";
5776
+ import { join as join16 } from "path";
5219
5777
  function readGlobalAssets(home = homedir()) {
5220
- const manifestPath = join14(home, ".media", "manifest.jsonl");
5221
- if (!existsSync9(manifestPath)) return [];
5778
+ const manifestPath = join16(home, ".media", "manifest.jsonl");
5779
+ if (!existsSync12(manifestPath)) return [];
5222
5780
  const out = [];
5223
- for (const line of readFileSync11(manifestPath, "utf8").split("\n")) {
5781
+ for (const line of readFileSync13(manifestPath, "utf8").split("\n")) {
5224
5782
  if (!line.trim()) continue;
5225
5783
  try {
5226
5784
  const rec = JSON.parse(line);
@@ -5407,6 +5965,9 @@ function createStudioApi(adapter) {
5407
5965
  registerSelectionRoutes(api, adapter);
5408
5966
  registerMediaRoutes(api, adapter);
5409
5967
  registerWaveformRoutes(api, adapter);
5968
+ registerFreezeFrameRoutes(api, adapter);
5969
+ registerLoudnessRoutes(api, adapter);
5970
+ registerPeakRoutes(api, adapter);
5410
5971
  registerFontRoutes(api);
5411
5972
  registerRegistryRoutes(api, adapter);
5412
5973
  registerGlobalAssetRoutes(api);
@@ -5416,7 +5977,7 @@ function createStudioApi(adapter) {
5416
5977
 
5417
5978
  // src/helpers/duplicateElement.ts
5418
5979
  import { sameInstant } from "@hyperframes/core/clip-facts";
5419
- import { ensureHfIds as ensureHfIds3 } from "@hyperframes/parsers/hf-ids";
5980
+ import { ensureHfIds as ensureHfIds4 } from "@hyperframes/parsers/hf-ids";
5420
5981
  function duplicateElementInHtml(source, target, newId, at) {
5421
5982
  const { document, wrappedFragment } = parseSourceDocument(source);
5422
5983
  const element = findTargetElement(document, target);
@@ -5426,23 +5987,23 @@ function duplicateElementInHtml(source, target, newId, at) {
5426
5987
  if (duration === null || numericAttribute(element, "data-start") === null) {
5427
5988
  return { html: source, matched: false, newId: null };
5428
5989
  }
5429
- const uniqueId = nextUniqueId(document, newId);
5990
+ const uniqueId2 = nextUniqueId(document, newId);
5430
5991
  rippleElements(document, element, at, duration, track);
5431
5992
  const clone = element.cloneNode(true);
5432
5993
  if (!isHTMLElement(clone)) return { html: source, matched: false, newId: null };
5433
- clone.setAttribute("id", uniqueId);
5994
+ clone.setAttribute("id", uniqueId2);
5434
5995
  dedupeClonedCompositionId(document, clone);
5435
5996
  stripStableIds(clone);
5436
5997
  clone.setAttribute("data-start", String(at));
5437
5998
  element.parentElement.insertBefore(clone, element.nextSibling);
5438
5999
  const html = wrappedFragment ? document.body.innerHTML || "" : document.toString();
5439
- return { html: ensureHfIds3(html), matched: true, newId: uniqueId };
6000
+ return { html: ensureHfIds4(html), matched: true, newId: uniqueId2 };
5440
6001
  }
5441
6002
  function nextUniqueId(document, newId) {
5442
- let uniqueId = newId;
6003
+ let uniqueId2 = newId;
5443
6004
  let suffix = 2;
5444
- while (document.getElementById(uniqueId)) uniqueId = `${newId}-${suffix++}`;
5445
- return uniqueId;
6005
+ while (document.getElementById(uniqueId2)) uniqueId2 = `${newId}-${suffix++}`;
6006
+ return uniqueId2;
5446
6007
  }
5447
6008
  function rippleElements(document, element, at, duration, track) {
5448
6009
  for (const candidate of Array.from(document.querySelectorAll("[data-start][data-duration]"))) {
@@ -5468,18 +6029,18 @@ function numericAttribute(element, name) {
5468
6029
  }
5469
6030
 
5470
6031
  // src/helpers/previewDocumentStore.ts
5471
- import { createHash as createHash4 } from "crypto";
5472
- import { readFileSync as readFileSync12, readdirSync as readdirSync5, rmSync as rmSync5 } from "fs";
5473
- import { join as join15 } from "path";
5474
- import { replaceFileAtomically as replaceFileAtomically3 } from "@hyperframes/core/atomic-file";
6032
+ import { createHash as createHash5 } from "crypto";
6033
+ import { readFileSync as readFileSync14, readdirSync as readdirSync5, rmSync as rmSync5 } from "fs";
6034
+ import { join as join17 } from "path";
6035
+ import { replaceFileAtomically as replaceFileAtomically4 } from "@hyperframes/core/atomic-file";
5475
6036
  function createPreviewDocumentStore(projectDir, salt) {
5476
- const dir = join15(projectDir, ".hyperframes", "preview");
5477
- const fileFor = (key) => join15(dir, `${createHash4("sha256").update(`${salt}
6037
+ const dir = join17(projectDir, ".hyperframes", "preview");
6038
+ const fileFor = (key) => join17(dir, `${createHash5("sha256").update(`${salt}
5478
6039
  ${key}`).digest("hex").slice(0, 32)}.html`);
5479
6040
  return {
5480
6041
  read(key) {
5481
6042
  try {
5482
- return readFileSync12(fileFor(key), "utf-8");
6043
+ return readFileSync14(fileFor(key), "utf-8");
5483
6044
  } catch {
5484
6045
  return null;
5485
6046
  }
@@ -5488,9 +6049,9 @@ ${key}`).digest("hex").slice(0, 32)}.html`);
5488
6049
  try {
5489
6050
  mkdirWithinProject(projectDir, dir);
5490
6051
  const file = fileFor(key);
5491
- replaceFileAtomically3(file, html, 420);
6052
+ replaceFileAtomically4(file, html, 420);
5492
6053
  for (const name of readdirSync5(dir)) {
5493
- if (join15(dir, name) !== file) rmSync5(join15(dir, name), { force: true });
6054
+ if (join17(dir, name) !== file) rmSync5(join17(dir, name), { force: true });
5494
6055
  }
5495
6056
  } catch {
5496
6057
  }