@hyperframes/studio-server 0.8.107 → 0.8.109

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
@@ -9,11 +9,12 @@ import {
9
9
  parseSourceDocument,
10
10
  patchElementInHtml,
11
11
  probeElementInSource,
12
+ relinkSplitHalvesInHtml,
12
13
  removeElementFromHtml,
13
14
  splitElementInHtml,
14
15
  unwrapElementsFromHtml,
15
16
  wrapElementsInHtml
16
- } from "./chunk-W6TFBTP2.js";
17
+ } from "./chunk-Z4DIWF3Z.js";
17
18
  import {
18
19
  isCompositionSource,
19
20
  stampProjectHfIds
@@ -43,10 +44,17 @@ import {
43
44
  resolveProjectAndSignature,
44
45
  resolveProjectSignature,
45
46
  settledFileTag
46
- } from "./chunk-KUEG5MYA.js";
47
+ } from "./chunk-EPL4YKGI.js";
47
48
  import {
48
49
  getElementScreenshotClip
49
50
  } from "./chunk-W2SBTCO2.js";
51
+ import {
52
+ DEFAULT_TARGET_LUFS,
53
+ absoluteLoudnessPlan,
54
+ measureAudio,
55
+ requiredFfmpeg,
56
+ resolveLocalAudioPath
57
+ } from "./chunk-TEYDBVSK.js";
50
58
  import {
51
59
  injectMediaCodecMap,
52
60
  isAutoProxyEnabled,
@@ -1248,7 +1256,7 @@ function bakeGroupTransformIntoMembers(html, groupId, members, groupCenter) {
1248
1256
  const rad = grot * Math.PI / 180;
1249
1257
  const cos = Math.cos(rad);
1250
1258
  const sin = Math.sin(rad);
1251
- const round3 = (n) => Math.round(n * 1e3) / 1e3;
1259
+ const round32 = (n) => Math.round(n * 1e3) / 1e3;
1252
1260
  let script = block.scriptText;
1253
1261
  for (const m of members) {
1254
1262
  const memberSel = `#${m.id}`;
@@ -1265,27 +1273,27 @@ function bakeGroupTransformIntoMembers(html, groupId, members, groupCenter) {
1265
1273
  const visY = groupCenter.cy + gscale * (sin * dx + cos * dy) + gy;
1266
1274
  const newProps = {
1267
1275
  ...mProps,
1268
- x: round3(visX - m.cx),
1269
- y: round3(visY - m.cy)
1276
+ x: round32(visX - m.cx),
1277
+ y: round32(visY - m.cy)
1270
1278
  };
1271
1279
  if (gz !== 0) newProps.z = (typeof mProps.z === "number" ? mProps.z : 0) + gz;
1272
1280
  if (grot !== 0) {
1273
- newProps.rotation = round3(
1281
+ newProps.rotation = round32(
1274
1282
  (typeof mProps.rotation === "number" ? mProps.rotation : 0) + grot
1275
1283
  );
1276
1284
  }
1277
1285
  if (gscale !== 1) {
1278
- newProps.scale = round3((typeof mProps.scale === "number" ? mProps.scale : 1) * gscale);
1286
+ newProps.scale = round32((typeof mProps.scale === "number" ? mProps.scale : 1) * gscale);
1279
1287
  }
1280
1288
  const pivoted = /* @__PURE__ */ new Set(["x", "y", "z", "rotation", "scale"]);
1281
1289
  for (const [k, v] of Object.entries(gt)) {
1282
1290
  if (pivoted.has(k) || typeof v !== "number") continue;
1283
1291
  if (k === "scaleX" || k === "scaleY") {
1284
- if (v !== 1) newProps[k] = round3((typeof mProps[k] === "number" ? mProps[k] : 1) * v);
1292
+ if (v !== 1) newProps[k] = round32((typeof mProps[k] === "number" ? mProps[k] : 1) * v);
1285
1293
  } else if (k === "transformPerspective") {
1286
1294
  if (typeof mProps[k] !== "number") newProps[k] = v;
1287
1295
  } else if (v !== 0) {
1288
- newProps[k] = round3((typeof mProps[k] === "number" ? mProps[k] : 0) + v);
1296
+ newProps[k] = round32((typeof mProps[k] === "number" ? mProps[k] : 0) + v);
1289
1297
  }
1290
1298
  }
1291
1299
  for (const s of [...sets].reverse()) {
@@ -2194,6 +2202,7 @@ async function foldAtomicCutFile(c, file, absPath, before, writer) {
2194
2202
  let after = before;
2195
2203
  let splitCount = 0;
2196
2204
  const skippedSelectors = /* @__PURE__ */ new Set();
2205
+ const rightHalfIds = [];
2197
2206
  const respond = (data, status) => status ? c.json(data, status) : c.json(data);
2198
2207
  const orderedTargets = file.targets.map((cut, index) => ({ cut, index })).sort((left, right) => {
2199
2208
  const locatorKey = (entry) => !entry.target.id && !entry.target.hfId && entry.target.selector ? entry.target.selector : null;
@@ -2224,6 +2233,7 @@ async function foldAtomicCutFile(c, file, absPath, before, writer) {
2224
2233
  }
2225
2234
  after = split.html;
2226
2235
  splitCount++;
2236
+ rightHalfIds.push(split.newId);
2227
2237
  if (!cut.originalId) continue;
2228
2238
  const block = extractGsapScriptBlock(after);
2229
2239
  if (!block) continue;
@@ -2250,6 +2260,7 @@ async function foldAtomicCutFile(c, file, absPath, before, writer) {
2250
2260
  after = block.replaceScript(script);
2251
2261
  }
2252
2262
  }
2263
+ after = relinkSplitHalvesInHtml(after, rightHalfIds);
2253
2264
  return {
2254
2265
  path: file.path,
2255
2266
  absPath,
@@ -4096,7 +4107,8 @@ function registerRenderRoutes(api, adapter) {
4096
4107
  progress: current.progress,
4097
4108
  status: current.status,
4098
4109
  stage: current.stage,
4099
- error: current.error
4110
+ error: current.error,
4111
+ ...current.status === "complete" && current.audioLoweredDb !== void 0 ? { audioLoweredDb: current.audioLoweredDb } : {}
4100
4112
  })
4101
4113
  });
4102
4114
  if (current.status !== "rendering") break;
@@ -4205,11 +4217,13 @@ function registerRenderRoutes(api, adapter) {
4205
4217
  const metaPath = join10(rendersDir, `${rid}.meta.json`);
4206
4218
  let status = "complete";
4207
4219
  let durationMs;
4220
+ let audioLoweredDb;
4208
4221
  if (existsSync6(metaPath)) {
4209
4222
  try {
4210
4223
  const meta = JSON.parse(readFileSync7(metaPath, "utf-8"));
4211
4224
  if (meta.status === "failed" && !existsSync6(fp)) status = "failed";
4212
4225
  if (meta.durationMs) durationMs = meta.durationMs;
4226
+ if (typeof meta.audioLoweredDb === "number") audioLoweredDb = meta.audioLoweredDb;
4213
4227
  } catch {
4214
4228
  }
4215
4229
  }
@@ -4219,7 +4233,8 @@ function registerRenderRoutes(api, adapter) {
4219
4233
  size: stat2.size,
4220
4234
  createdAt: stat2.mtimeMs,
4221
4235
  status,
4222
- durationMs
4236
+ durationMs,
4237
+ ...audioLoweredDb !== void 0 ? { audioLoweredDb } : {}
4223
4238
  };
4224
4239
  }).sort((a, b) => b.createdAt - a.createdAt);
4225
4240
  for (const file of files) {
@@ -4259,10 +4274,10 @@ var ThumbnailGenerationCoordinator = class {
4259
4274
  if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
4260
4275
  let entry = this.entries.get(key);
4261
4276
  if (!entry) {
4262
- let resolve6;
4277
+ let resolve7;
4263
4278
  let reject;
4264
4279
  const promise = new Promise((resolvePromise, rejectPromise) => {
4265
- resolve6 = resolvePromise;
4280
+ resolve7 = resolvePromise;
4266
4281
  reject = rejectPromise;
4267
4282
  });
4268
4283
  entry = {
@@ -4272,7 +4287,7 @@ var ThumbnailGenerationCoordinator = class {
4272
4287
  state: "queued",
4273
4288
  work,
4274
4289
  promise,
4275
- resolve: resolve6,
4290
+ resolve: resolve7,
4276
4291
  reject
4277
4292
  };
4278
4293
  this.entries.set(key, entry);
@@ -4286,7 +4301,7 @@ var ThumbnailGenerationCoordinator = class {
4286
4301
  return /* @__PURE__ */ new Set([...this.entries.keys(), ...[...this.activeEntries].map((entry) => entry.key)]);
4287
4302
  }
4288
4303
  lease(entry, signal) {
4289
- return new Promise((resolve6, reject) => {
4304
+ return new Promise((resolve7, reject) => {
4290
4305
  let released = false;
4291
4306
  const release = () => {
4292
4307
  if (released) return;
@@ -4310,7 +4325,7 @@ var ThumbnailGenerationCoordinator = class {
4310
4325
  entry.promise.then(
4311
4326
  (value) => {
4312
4327
  release();
4313
- resolve6(value);
4328
+ resolve7(value);
4314
4329
  },
4315
4330
  (reason) => {
4316
4331
  release();
@@ -4387,14 +4402,14 @@ async function generateThumbnail(path, signal) {
4387
4402
  async function resolveSource(adapter, id, url) {
4388
4403
  const project = await adapter.resolveProject(id);
4389
4404
  if (!project) throw new HTTPException(404);
4390
- let relative4;
4405
+ let relative5;
4391
4406
  try {
4392
- relative4 = requestSubPath(url, "projects/:id/image-thumbnail");
4407
+ relative5 = requestSubPath(url, "projects/:id/image-thumbnail");
4393
4408
  } catch {
4394
4409
  throw new HTTPException(400);
4395
4410
  }
4396
- if (!/\.jpe?g$/i.test(relative4)) throw new HTTPException(415);
4397
- const path = pinWithinProject(project.dir, relative4);
4411
+ if (!/\.jpe?g$/i.test(relative5)) throw new HTTPException(415);
4412
+ const path = pinWithinProject(project.dir, relative5);
4398
4413
  if (!path) throw new HTTPException(404);
4399
4414
  const info = await stat(path).catch(() => {
4400
4415
  throw new HTTPException(404);
@@ -4762,6 +4777,510 @@ function registerWaveformRoutes(api, adapter) {
4762
4777
  });
4763
4778
  }
4764
4779
 
4780
+ // src/routes/freezeFrame.ts
4781
+ import { execFile } from "child_process";
4782
+ import { existsSync as existsSync8, readFileSync as readFileSync11, statSync as statSync8 } from "fs";
4783
+ import { dirname as dirname5, join as join13, relative as relative4, sep as sep2 } from "path";
4784
+ import { findFfBinary as findFfBinary3 } from "@hyperframes/parsers/ff-binaries";
4785
+ import { replaceFileAtomically as replaceFileAtomically3 } from "@hyperframes/core/atomic-file";
4786
+
4787
+ // src/helpers/freezeFrame.ts
4788
+ import { createHash as createHash4, randomBytes } from "crypto";
4789
+ import { ensureHfIds as ensureHfIds3 } from "@hyperframes/parsers/hf-ids";
4790
+ import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration";
4791
+ import { resolveRateSpec, sourceTimeAt } from "@hyperframes/core/speed-ramp";
4792
+ import { MEDIA_LINK_ATTR as LINK_ATTR, relinkSplitHalves } from "@hyperframes/core/media-link";
4793
+ var FREEZE_HOLD_SECONDS = 2;
4794
+ var EPSILON = 1e-3;
4795
+ var round3 = (value) => Math.round(value * 1e3) / 1e3;
4796
+ function numberAttr(el, name) {
4797
+ const raw = el.getAttribute(name);
4798
+ if (raw === null || raw.trim() === "") return null;
4799
+ const value = Number(raw);
4800
+ return Number.isFinite(value) ? value : null;
4801
+ }
4802
+ function trackOf(el) {
4803
+ return numberAttr(el, "data-track-index") ?? numberAttr(el, "data-layer") ?? 0;
4804
+ }
4805
+ function freezeFrameMediaTime(input) {
4806
+ const rate = resolveRateSpec(input.automation, input.playbackRate);
4807
+ return input.mediaStart + sourceTimeAt(rate, Math.max(0, input.playhead - input.clipStart));
4808
+ }
4809
+ function freezeExtractArgs(src, mediaTime, output) {
4810
+ return ["-n", "-ss", String(round3(mediaTime)), "-i", src, "-frames:v", "1", output];
4811
+ }
4812
+ var randomStillToken = () => randomBytes(4).toString("hex");
4813
+ function freezeStillFileName(clipId, playhead, token = randomStillToken()) {
4814
+ const stem = clipId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 48) || "clip";
4815
+ const idHash = createHash4("sha256").update(clipId).digest("hex").slice(0, 10);
4816
+ return `${stem}-${idHash}-${Math.round(playhead * 1e3)}-${token}.png`;
4817
+ }
4818
+ function readFreezeSource(html, target, playhead) {
4819
+ const { document } = parseSourceDocument(html);
4820
+ const el = findTargetElement(document, target);
4821
+ if (!el || el.tagName.toLowerCase() !== "video") return null;
4822
+ const src = el.getAttribute("src");
4823
+ const start = numberAttr(el, "data-start");
4824
+ const duration = numberAttr(el, "data-duration");
4825
+ if (!src || start === null || duration === null) return null;
4826
+ if (playhead <= start + EPSILON || playhead >= start + duration - EPSILON) return null;
4827
+ return {
4828
+ id: el.getAttribute("id") || "clip",
4829
+ src,
4830
+ mediaTime: freezeFrameMediaTime({
4831
+ clipStart: start,
4832
+ playhead,
4833
+ mediaStart: readMediaOffsetSeconds((name) => el.getAttribute(name)),
4834
+ playbackRate: numberAttr(el, "data-playback-rate") ?? 1,
4835
+ automation: el.getAttribute("data-automation")
4836
+ })
4837
+ };
4838
+ }
4839
+ function targetOf(el) {
4840
+ const hfId = el.getAttribute("data-hf-id");
4841
+ return { ...hfId ? { hfId } : {}, id: el.getAttribute("id") };
4842
+ }
4843
+ function spans(el, time) {
4844
+ const start = numberAttr(el, "data-start");
4845
+ const duration = numberAttr(el, "data-duration");
4846
+ if (start === null || duration === null) return false;
4847
+ return time > start + EPSILON && time < start + duration - EPSILON;
4848
+ }
4849
+ function uniqueId(document, base) {
4850
+ let id = base;
4851
+ for (let n = 2; document.getElementById(id); n++) id = `${base}-${n}`;
4852
+ return id;
4853
+ }
4854
+ function shiftTracks(document, tracks, from, by) {
4855
+ for (const el of document.querySelectorAll("[data-start]")) {
4856
+ if (el.hasAttribute("data-composition-id") || !tracks.has(trackOf(el))) continue;
4857
+ const start = numberAttr(el, "data-start");
4858
+ if (start === null || start < from - EPSILON) continue;
4859
+ el.setAttribute("data-start", String(round3(start + by)));
4860
+ }
4861
+ }
4862
+ function growRootToFit(document) {
4863
+ const root = document.querySelector("[data-composition-id]");
4864
+ const rootDuration = root ? numberAttr(root, "data-duration") : null;
4865
+ if (!root || rootDuration === null) return;
4866
+ let end = rootDuration;
4867
+ for (const el of root.querySelectorAll("[data-start][data-duration]")) {
4868
+ const start = numberAttr(el, "data-start");
4869
+ const duration = numberAttr(el, "data-duration");
4870
+ if (start !== null && duration !== null) end = Math.max(end, start + duration);
4871
+ }
4872
+ if (end > rootDuration + EPSILON) root.setAttribute("data-duration", String(round3(end)));
4873
+ }
4874
+ var COPIED_PICTURE_ATTRS = ["class", "style", "data-color-grading"];
4875
+ function buildStill(document, video, input) {
4876
+ const img = document.createElement("img");
4877
+ img.setAttribute("id", input.id);
4878
+ for (const name of COPIED_PICTURE_ATTRS) {
4879
+ const value = video.getAttribute(name);
4880
+ if (value !== null) img.setAttribute(name, value);
4881
+ }
4882
+ if (!img.classList.contains("clip")) img.classList.add("clip");
4883
+ img.setAttribute("src", input.imageSrc);
4884
+ img.setAttribute("alt", "");
4885
+ img.setAttribute("data-start", String(round3(input.playhead)));
4886
+ img.setAttribute("data-duration", String(input.holdSeconds));
4887
+ img.setAttribute("data-track-index", String(trackOf(video)));
4888
+ img.setAttribute("data-timeline-label", "Freeze");
4889
+ return img;
4890
+ }
4891
+ function linkedPartners(document, video) {
4892
+ const link = video.getAttribute(LINK_ATTR);
4893
+ if (!link) return [];
4894
+ return Array.from(document.querySelectorAll(`[${LINK_ATTR}]`)).filter(
4895
+ (el) => el !== video && el.getAttribute(LINK_ATTR) === link
4896
+ );
4897
+ }
4898
+ function splitAll(html, elements, time) {
4899
+ const rightHalfIds = [];
4900
+ let next = html;
4901
+ for (const el of elements) {
4902
+ const split = splitElementInHtml(
4903
+ next,
4904
+ targetOf(el),
4905
+ time,
4906
+ `${el.getAttribute("id") || "clip"}-split`,
4907
+ {
4908
+ start: numberAttr(el, "data-start") ?? 0,
4909
+ duration: numberAttr(el, "data-duration") ?? 0,
4910
+ track: trackOf(el)
4911
+ }
4912
+ );
4913
+ if (!split.matched || !split.newId) return null;
4914
+ next = split.html;
4915
+ rightHalfIds.push(split.newId);
4916
+ }
4917
+ return { html: next, rightHalfIds };
4918
+ }
4919
+ function applyFreezeFrameToHtml(html, input) {
4920
+ const hold = input.holdSeconds ?? FREEZE_HOLD_SECONDS;
4921
+ const { document: before } = parseSourceDocument(html);
4922
+ const video = findTargetElement(before, input.target);
4923
+ if (!video || !isHTMLElement(video) || !spans(video, input.playhead)) return null;
4924
+ const partners = linkedPartners(before, video);
4925
+ const tracks = /* @__PURE__ */ new Set([trackOf(video), ...partners.map(trackOf)]);
4926
+ const cut = splitAll(
4927
+ html,
4928
+ [video, ...partners.filter((el) => spans(el, input.playhead))],
4929
+ input.playhead
4930
+ );
4931
+ if (!cut) return null;
4932
+ const { document, wrappedFragment } = parseSourceDocument(cut.html);
4933
+ relinkSplitHalves(document, cut.rightHalfIds);
4934
+ shiftTracks(document, tracks, input.playhead, hold);
4935
+ const leftHalf = findTargetElement(document, input.target);
4936
+ if (!leftHalf?.parentElement) return null;
4937
+ const freezeId = uniqueId(document, `${video.getAttribute("id") || "clip"}-freeze`);
4938
+ const still = buildStill(document, leftHalf, {
4939
+ id: freezeId,
4940
+ imageSrc: input.imageSrc,
4941
+ playhead: input.playhead,
4942
+ holdSeconds: hold
4943
+ });
4944
+ leftHalf.parentElement.insertBefore(still, leftHalf.nextSibling);
4945
+ growRootToFit(document);
4946
+ return {
4947
+ html: ensureHfIds3(wrappedFragment ? document.body.innerHTML || "" : document.toString()),
4948
+ freezeId
4949
+ };
4950
+ }
4951
+
4952
+ // src/routes/freezeFrame.ts
4953
+ var EXTRACT_TIMEOUT_MS = 3e4;
4954
+ var ffmpegExtractor = (args) => {
4955
+ const ffmpeg = findFfBinary3("ffmpeg", { configuredMustExist: true });
4956
+ if (!ffmpeg) return Promise.resolve({ ok: false, error: "ffmpeg not found" });
4957
+ return new Promise((resolvePromise) => {
4958
+ execFile(
4959
+ ffmpeg,
4960
+ args,
4961
+ { timeout: EXTRACT_TIMEOUT_MS, windowsHide: true },
4962
+ (error, _o, stderr) => resolvePromise(
4963
+ error ? { ok: false, error: String(stderr || error.message) } : { ok: true }
4964
+ )
4965
+ );
4966
+ });
4967
+ };
4968
+ function isFreezeFrameRequest(value) {
4969
+ if (typeof value !== "object" || value === null) return false;
4970
+ const body = value;
4971
+ 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);
4972
+ }
4973
+ var FREEZE_DIR = ["assets", "freeze"];
4974
+ function readExpected(absPath, expectedVersion) {
4975
+ let content;
4976
+ try {
4977
+ content = readFileSync11(absPath, "utf-8");
4978
+ } catch {
4979
+ return { error: "not found", status: 404 };
4980
+ }
4981
+ return fileContentVersion(content) === expectedVersion ? { content } : { error: "file conflict", status: 409 };
4982
+ }
4983
+ async function extractStill(projectDir, absPath, source, playhead, tools) {
4984
+ const fileDir = dirname5(absPath);
4985
+ const mediaPath = pinWithinProject(projectDir, relative4(projectDir, join13(fileDir, source.src)));
4986
+ if (!mediaPath) return { error: `forbidden media path: ${source.src}`, status: 403 };
4987
+ const freezeDir = join13(projectDir, ...FREEZE_DIR);
4988
+ const fileName = freezeStillFileName(source.id, playhead, tools.stillToken());
4989
+ mkdirWithinProject(projectDir, freezeDir);
4990
+ const imagePath = pinWithinProject(projectDir, join13(...FREEZE_DIR, fileName));
4991
+ if (!imagePath || dirname5(imagePath) !== freezeDir) {
4992
+ return { error: `forbidden freeze path: ${fileName}`, status: 403 };
4993
+ }
4994
+ if (existsSync8(imagePath))
4995
+ return { error: `freeze still already exists: ${fileName}`, status: 409 };
4996
+ const extracted = await tools.extract(freezeExtractArgs(mediaPath, source.mediaTime, imagePath));
4997
+ if (!extracted.ok) {
4998
+ return {
4999
+ error: `Could not extract the frame: ${extracted.error ?? "ffmpeg failed"}`,
5000
+ status: 500
5001
+ };
5002
+ }
5003
+ const depth = relative4(projectDir, fileDir).split(sep2).filter(Boolean).length;
5004
+ return { imageSrc: `${"../".repeat(depth)}${FREEZE_DIR.join("/")}/${fileName}` };
5005
+ }
5006
+ function writeFolded(projectDir, absPath, path, before, after, options) {
5007
+ if (readFileSync11(absPath, "utf-8") !== before) {
5008
+ return { error: `file conflict: ${path}`, status: 409 };
5009
+ }
5010
+ const backup = snapshotBeforeWrite(projectDir, absPath);
5011
+ if (backup.error) return { error: `Failed to create backup: ${backup.error}`, status: 500 };
5012
+ const writeToken = createWriteToken(options.token);
5013
+ replaceFileAtomically3(absPath, after, statSync8(absPath).mode);
5014
+ const version = fileContentVersion(after);
5015
+ recordFileWriteReceipt(absPath, { path, version, writeToken, overwrote: before });
5016
+ return { version, writeToken, backupPath: backupPathForResponse(projectDir, backup.backupPath) };
5017
+ }
5018
+ function registerFreezeFrameRoutes(api, adapter, extract = ffmpegExtractor, stillToken = randomStillToken) {
5019
+ api.post("/projects/:id/file-mutations/freeze-frame", async (c) => {
5020
+ const body = await c.req.json().catch(() => null);
5021
+ if (!isFreezeFrameRequest(body)) {
5022
+ return c.json({ error: "path, expectedVersion, target and playhead required" }, 400);
5023
+ }
5024
+ const project = await adapter.resolveProject(c.req.param("id"));
5025
+ if (!project) return c.json({ error: "not found" }, 404);
5026
+ const absPath = pinWithinProject(project.dir, body.path);
5027
+ if (!absPath) return c.json({ error: `forbidden path: ${body.path}` }, 403);
5028
+ const read = readExpected(absPath, body.expectedVersion);
5029
+ if ("error" in read) return c.json({ error: `${read.error}: ${body.path}` }, read.status);
5030
+ const before = read.content;
5031
+ const source = readFreezeSource(before, body.target, body.playhead);
5032
+ if (!source) return c.json({ error: "Move the playhead inside a video clip to freeze" }, 400);
5033
+ const still = await extractStill(project.dir, absPath, source, body.playhead, {
5034
+ extract,
5035
+ stillToken
5036
+ });
5037
+ if ("error" in still) return c.json({ error: still.error }, still.status);
5038
+ const { imageSrc } = still;
5039
+ const folded = applyFreezeFrameToHtml(before, {
5040
+ target: body.target,
5041
+ playhead: body.playhead,
5042
+ imageSrc
5043
+ });
5044
+ if (!folded) return c.json({ error: "Freeze target was not found in the file" }, 400);
5045
+ const written = writeFolded(project.dir, absPath, body.path, before, folded.html, {
5046
+ token: body.transactionToken ?? c.req.header("X-Hyperframes-Write-Token")
5047
+ });
5048
+ if ("error" in written) return c.json({ error: written.error }, written.status);
5049
+ const { version, writeToken, backupPath } = written;
5050
+ return c.json({
5051
+ ok: true,
5052
+ path: body.path,
5053
+ before,
5054
+ after: folded.html,
5055
+ version,
5056
+ writeToken,
5057
+ backupPath,
5058
+ freezeId: folded.freezeId,
5059
+ imageSrc
5060
+ });
5061
+ });
5062
+ }
5063
+
5064
+ // src/routes/loudness.ts
5065
+ import { existsSync as existsSync9 } from "fs";
5066
+ var defaultMeasure = (file, window) => measureAudio(requiredFfmpeg(), file, window);
5067
+ var InvalidField = class extends Error {
5068
+ };
5069
+ function numberField(body, name, fallback, valid) {
5070
+ const raw = body[name];
5071
+ if (raw === void 0 || raw === null) return fallback;
5072
+ if (typeof raw === "number" && Number.isFinite(raw) && valid(raw)) return raw;
5073
+ throw new InvalidField(`${name} is out of range`);
5074
+ }
5075
+ function readNormalizeRequest(body) {
5076
+ const src = typeof body.src === "string" ? body.src.trim() : "";
5077
+ if (!src) throw new InvalidField("src is required");
5078
+ const duration = numberField(body, "duration", 0, (v) => v > 0);
5079
+ return {
5080
+ id: typeof body.id === "string" && body.id ? body.id : src,
5081
+ src,
5082
+ window: {
5083
+ mediaStart: numberField(body, "mediaStart", 0, (v) => v >= 0),
5084
+ duration: duration > 0 ? duration : null,
5085
+ playbackRate: numberField(body, "playbackRate", 1, (v) => v > 0)
5086
+ },
5087
+ volume: numberField(body, "volume", 1, (v) => v >= 0),
5088
+ targetLufs: numberField(body, "targetLufs", DEFAULT_TARGET_LUFS, (v) => v < 0)
5089
+ };
5090
+ }
5091
+ function parseNormalizeRequest(body) {
5092
+ try {
5093
+ return readNormalizeRequest(body);
5094
+ } catch (error) {
5095
+ if (error instanceof InvalidField) return error.message;
5096
+ throw error;
5097
+ }
5098
+ }
5099
+ function isRecord(value) {
5100
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5101
+ }
5102
+ function registerLoudnessRoutes(api, adapter, measure = defaultMeasure) {
5103
+ api.post("/projects/:id/loudness/normalize", async (c) => {
5104
+ const project = await adapter.resolveProject(c.req.param("id"));
5105
+ if (!project) return c.json({ error: "not found" }, 404);
5106
+ const body = await c.req.json().catch(() => null);
5107
+ if (!isRecord(body)) return c.json({ error: "invalid body" }, 400);
5108
+ const request = parseNormalizeRequest(body);
5109
+ if (typeof request === "string") return c.json({ error: request }, 400);
5110
+ let file;
5111
+ try {
5112
+ file = resolveLocalAudioPath(project.dir, request.src);
5113
+ } catch (error) {
5114
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
5115
+ }
5116
+ if (!existsSync9(file)) return c.json({ error: "file not found" }, 404);
5117
+ try {
5118
+ const measurement = await measure(file, request.window);
5119
+ const plan = absoluteLoudnessPlan(
5120
+ { id: request.id, volume: request.volume, ...measurement },
5121
+ request.targetLufs
5122
+ );
5123
+ return c.json({ plan });
5124
+ } catch (error) {
5125
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 422);
5126
+ }
5127
+ });
5128
+ }
5129
+
5130
+ // src/routes/peaks.ts
5131
+ import { existsSync as existsSync10, readFileSync as readFileSync12, statSync as statSync9 } from "fs";
5132
+ import { join as join14, resolve as resolve6 } from "path";
5133
+ import { isWithinProjectRoot as isWithinProjectRoot3 } from "@hyperframes/parsers/asset-resolution";
5134
+
5135
+ // src/helpers/peakMap.ts
5136
+ import { execFile as execFile2, spawn as spawn2 } from "child_process";
5137
+ import { promisify } from "util";
5138
+ import { findFfBinary as findFfBinary4 } from "@hyperframes/parsers/ff-binaries";
5139
+ var PEAK_BIN_SECONDS = 0.05;
5140
+ var PEAK_SAMPLE_RATE = 48e3;
5141
+ var execFileAsync = promisify(execFile2);
5142
+ var PEAK_MAP_CACHE_VERSION = "peaks-v2";
5143
+ function createPeakAccumulator(sampleRate = PEAK_SAMPLE_RATE, binSeconds = PEAK_BIN_SECONDS, channels = 2) {
5144
+ return {
5145
+ bins: [],
5146
+ frame: 0,
5147
+ carry: Buffer.alloc(0),
5148
+ framesPerBin: Math.max(1, Math.round(sampleRate * binSeconds)),
5149
+ channels: Math.max(1, channels)
5150
+ };
5151
+ }
5152
+ function framePeak(data, offset, channels) {
5153
+ let peak = 0;
5154
+ for (let ch = 0; ch < channels; ch++) {
5155
+ peak = Math.max(peak, Math.abs(data.readFloatLE(offset + ch * 4)));
5156
+ }
5157
+ return peak;
5158
+ }
5159
+ function accumulatePeaks(acc, chunk) {
5160
+ const data = acc.carry.length > 0 ? Buffer.concat([acc.carry, chunk]) : chunk;
5161
+ const frameBytes = 4 * acc.channels;
5162
+ const whole = data.length - data.length % frameBytes;
5163
+ for (let offset = 0; offset < whole; offset += frameBytes) {
5164
+ const peak = framePeak(data, offset, acc.channels);
5165
+ const bin = Math.floor(acc.frame / acc.framesPerBin);
5166
+ if (acc.bins.length <= bin) acc.bins.push(0);
5167
+ if (peak > (acc.bins[bin] ?? 0)) acc.bins[bin] = peak;
5168
+ acc.frame += 1;
5169
+ }
5170
+ acc.carry = Buffer.from(data.subarray(whole));
5171
+ }
5172
+ function finishPeaks(acc) {
5173
+ return acc.bins.map((peak) => Number(peak.toFixed(4)));
5174
+ }
5175
+ async function probeChannels(mediaPath) {
5176
+ const { stdout } = await execFileAsync(
5177
+ findFfBinary4("ffprobe") ?? "ffprobe",
5178
+ [
5179
+ "-v",
5180
+ "error",
5181
+ "-select_streams",
5182
+ "a:0",
5183
+ "-show_entries",
5184
+ "stream=channels",
5185
+ "-of",
5186
+ "csv=p=0",
5187
+ "--",
5188
+ mediaPath
5189
+ ],
5190
+ { encoding: "utf8", timeout: 3e4, windowsHide: true }
5191
+ );
5192
+ const channels = Number.parseInt(stdout.trim(), 10);
5193
+ if (!Number.isFinite(channels) || channels < 1) throw new Error("no audio stream");
5194
+ return channels;
5195
+ }
5196
+ async function decodePeakMap(mediaPath) {
5197
+ const channels = await probeChannels(mediaPath);
5198
+ return new Promise((resolvePromise, reject) => {
5199
+ const proc = spawn2(
5200
+ findFfBinary4("ffmpeg") ?? "ffmpeg",
5201
+ [
5202
+ "-v",
5203
+ "error",
5204
+ "-i",
5205
+ mediaPath,
5206
+ "-map",
5207
+ "0:a:0",
5208
+ "-vn",
5209
+ "-ar",
5210
+ String(PEAK_SAMPLE_RATE),
5211
+ "-f",
5212
+ "f32le",
5213
+ "pipe:1"
5214
+ ],
5215
+ { stdio: ["ignore", "pipe", "ignore"], windowsHide: true }
5216
+ );
5217
+ const acc = createPeakAccumulator(PEAK_SAMPLE_RATE, PEAK_BIN_SECONDS, channels);
5218
+ proc.stdout?.on("data", (chunk) => accumulatePeaks(acc, chunk));
5219
+ proc.on("close", (code) => {
5220
+ if (code !== 0) {
5221
+ reject(new Error(`ffmpeg failed decoding audio (exit ${code})`));
5222
+ return;
5223
+ }
5224
+ if (acc.frame === 0) {
5225
+ reject(new Error("ffmpeg produced no audio samples"));
5226
+ return;
5227
+ }
5228
+ resolvePromise(finishPeaks(acc));
5229
+ });
5230
+ proc.on("error", reject);
5231
+ });
5232
+ }
5233
+ function buildPeakMapCacheKey(assetPath, fingerprint) {
5234
+ const name = assetPath.replace(/[/\\]/g, "_");
5235
+ return `${PEAK_MAP_CACHE_VERSION}_${name}_${fingerprint.size}-${Math.round(fingerprint.mtimeMs)}.json`;
5236
+ }
5237
+
5238
+ // src/routes/peaks.ts
5239
+ var decodesInFlight = /* @__PURE__ */ new Map();
5240
+ function decodeOnce(cachePath, mediaPath, decode) {
5241
+ const pending = decodesInFlight.get(cachePath);
5242
+ if (pending) return pending;
5243
+ const started = decode(mediaPath).finally(() => decodesInFlight.delete(cachePath));
5244
+ decodesInFlight.set(cachePath, started);
5245
+ return started;
5246
+ }
5247
+ function readCachedBins(cacheDir, cachePath) {
5248
+ try {
5249
+ if (!isWaveformCacheDirectory(cacheDir) || !existsSync10(cachePath)) return null;
5250
+ const parsed = JSON.parse(readFileSync12(cachePath, "utf-8"));
5251
+ return Array.isArray(parsed) && parsed.every((n) => typeof n === "number") ? parsed : null;
5252
+ } catch {
5253
+ return null;
5254
+ }
5255
+ }
5256
+ function registerPeakRoutes(api, adapter, decode = decodePeakMap) {
5257
+ api.get("/projects/:id/peaks/*", async (c) => {
5258
+ const project = await adapter.resolveProject(c.req.param("id"));
5259
+ if (!project) return c.json({ error: "not found" }, 404);
5260
+ const assetPath = requestSubPath(c.req.url, "projects/:id/peaks");
5261
+ const mediaPath = resolve6(project.dir, assetPath);
5262
+ if (!isWithinProjectRoot3(project.dir, mediaPath))
5263
+ return c.json({ error: "file not found" }, 404);
5264
+ const stats = statSync9(mediaPath, { throwIfNoEntry: false });
5265
+ if (!stats?.isFile()) return c.json({ error: "file not found" }, 404);
5266
+ const cacheDir = join14(project.dir, ".waveform-cache");
5267
+ const cachePath = join14(cacheDir, buildPeakMapCacheKey(assetPath, stats));
5268
+ const cached = readCachedBins(cacheDir, cachePath);
5269
+ if (cached) return c.json({ binSeconds: PEAK_BIN_SECONDS, bins: cached });
5270
+ let bins;
5271
+ try {
5272
+ bins = await decodeOnce(cachePath, mediaPath, decode);
5273
+ } catch {
5274
+ return c.json({ error: "failed to decode audio" }, 500);
5275
+ }
5276
+ try {
5277
+ writeWaveformCache(cachePath, bins);
5278
+ } catch {
5279
+ }
5280
+ return c.json({ binSeconds: PEAK_BIN_SECONDS, bins });
5281
+ });
5282
+ }
5283
+
4765
5284
  // src/routes/fonts.ts
4766
5285
  import { closeSync as closeSync3, constants, fstatSync as fstatSync3, openSync as openSync3, readSync } from "fs";
4767
5286
  import {
@@ -4798,7 +5317,7 @@ var GOOGLE_FONT_FALLBACKS = [
4798
5317
  "Outfit",
4799
5318
  "JetBrains Mono"
4800
5319
  ];
4801
- function isRecord(value) {
5320
+ function isRecord2(value) {
4802
5321
  return typeof value === "object" && value !== null;
4803
5322
  }
4804
5323
  function collectFontsFromDir(dir) {
@@ -4822,10 +5341,10 @@ function listInstalledFontFamilies() {
4822
5341
  return cachedFonts;
4823
5342
  }
4824
5343
  function parseGoogleFontMetadata(value) {
4825
- if (!isRecord(value) || !Array.isArray(value.familyMetadataList)) return [];
5344
+ if (!isRecord2(value) || !Array.isArray(value.familyMetadataList)) return [];
4826
5345
  const families = [];
4827
5346
  for (const entry of value.familyMetadataList) {
4828
- if (!isRecord(entry) || typeof entry.family !== "string") continue;
5347
+ if (!isRecord2(entry) || typeof entry.family !== "string") continue;
4829
5348
  families.push(entry.family);
4830
5349
  }
4831
5350
  return families;
@@ -4929,14 +5448,14 @@ function registerRegistryRoutes(api, adapter) {
4929
5448
  }
4930
5449
 
4931
5450
  // src/routes/selection.ts
4932
- function isRecord2(value) {
5451
+ function isRecord3(value) {
4933
5452
  return typeof value === "object" && value !== null && !Array.isArray(value);
4934
5453
  }
4935
5454
  function isFiniteNumber(value) {
4936
5455
  return typeof value === "number" && Number.isFinite(value);
4937
5456
  }
4938
5457
  function isStringRecord(value) {
4939
- return isRecord2(value) && Object.values(value).every((v) => typeof v === "string");
5458
+ return isRecord3(value) && Object.values(value).every((v) => typeof v === "string");
4940
5459
  }
4941
5460
  function hasString(value, key) {
4942
5461
  return typeof value[key] === "string";
@@ -4954,20 +5473,20 @@ function hasOptionalNumber(value, key) {
4954
5473
  return value[key] === void 0 || isFiniteNumber(value[key]);
4955
5474
  }
4956
5475
  function isBoundingBox(value) {
4957
- return isRecord2(value) && ["x", "y", "width", "height"].every((key) => isFiniteNumber(value[key]));
5476
+ return isRecord3(value) && ["x", "y", "width", "height"].every((key) => isFiniteNumber(value[key]));
4958
5477
  }
4959
5478
  function isTarget(value) {
4960
- if (!isRecord2(value)) return false;
5479
+ if (!isRecord3(value)) return false;
4961
5480
  return hasOptionalNullableString(value, "id") && hasOptionalString(value, "hfId") && hasOptionalString(value, "selector") && hasOptionalNumber(value, "selectorIndex");
4962
5481
  }
4963
5482
  function isTextField(value) {
4964
- return isRecord2(value) && hasRequiredStrings(value, ["key", "label", "value", "tagName"]) && ["self", "child", "text-node"].includes(value.source);
5483
+ return isRecord3(value) && hasRequiredStrings(value, ["key", "label", "value", "tagName"]) && ["self", "child", "text-node"].includes(value.source);
4965
5484
  }
4966
5485
  function isTextFields(value) {
4967
5486
  return Array.isArray(value) && value.every(isTextField);
4968
5487
  }
4969
5488
  function isSelectionSnapshot(value) {
4970
- if (!isRecord2(value)) return false;
5489
+ if (!isRecord3(value)) return false;
4971
5490
  const checks = [
4972
5491
  value.schemaVersion === 1 && hasRequiredStrings(value, [
4973
5492
  "projectId",
@@ -4985,7 +5504,7 @@ function isSelectionSnapshot(value) {
4985
5504
  isStringRecord(value.inlineStyles),
4986
5505
  isStringRecord(value.computedStyles),
4987
5506
  isTextFields(value.textFields),
4988
- isRecord2(value.capabilities)
5507
+ isRecord3(value.capabilities)
4989
5508
  ];
4990
5509
  return checks.every(Boolean);
4991
5510
  }
@@ -5009,7 +5528,7 @@ function registerSelectionRoutes(api, adapter) {
5009
5528
  } catch {
5010
5529
  return c.json({ error: "invalid json" }, 400);
5011
5530
  }
5012
- if (!isRecord2(body) || !("selection" in body)) {
5531
+ if (!isRecord3(body) || !("selection" in body)) {
5013
5532
  return c.json({ error: "missing selection" }, 400);
5014
5533
  }
5015
5534
  if (body.selection === null) {
@@ -5028,8 +5547,8 @@ function registerSelectionRoutes(api, adapter) {
5028
5547
 
5029
5548
  // src/routes/media.ts
5030
5549
  import { streamSSE as streamSSE2 } from "hono/streaming";
5031
- import { existsSync as existsSync8 } from "fs";
5032
- import { basename as basename2, dirname as dirname5, extname, join as join13, posix } from "path";
5550
+ import { existsSync as existsSync11 } from "fs";
5551
+ import { basename as basename2, dirname as dirname6, extname, join as join15, posix } from "path";
5033
5552
  var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([
5034
5553
  ".mp4",
5035
5554
  ".mov",
@@ -5066,7 +5585,7 @@ function uniqueAssetPath(projectDir, assetPath, taken) {
5066
5585
  const ext = extname(assetPath);
5067
5586
  const withoutExt = assetPath.slice(0, -ext.length);
5068
5587
  let candidate = assetPath;
5069
- for (let index = 2; taken.has(candidate) || existsSync8(join13(projectDir, candidate)); index++) {
5588
+ for (let index = 2; taken.has(candidate) || existsSync11(join15(projectDir, candidate)); index++) {
5070
5589
  candidate = `${withoutExt}-${index}${ext}`;
5071
5590
  }
5072
5591
  return candidate;
@@ -5127,7 +5646,7 @@ function registerMediaRoutes(api, adapter, options = {}) {
5127
5646
  }
5128
5647
  const filePath = resolveWithinProject(project.dir, assetPath);
5129
5648
  if (!filePath) return c.json({ error: "forbidden" }, 403);
5130
- if (!existsSync8(filePath)) return c.json({ error: "media not found" }, 404);
5649
+ if (!existsSync11(filePath)) return c.json({ error: "media not found" }, 404);
5131
5650
  return c.json({ path: assetPath, metadata: await readMediaMetadata(filePath) });
5132
5651
  });
5133
5652
  api.post(
@@ -5149,7 +5668,7 @@ function registerMediaRoutes(api, adapter, options = {}) {
5149
5668
  }
5150
5669
  const inputPath = resolveWithinProject(project.dir, inputAssetPath);
5151
5670
  if (!inputPath) return c.json({ error: "forbidden" }, 403);
5152
- if (!existsSync8(inputPath)) return c.json({ error: "input media not found" }, 404);
5671
+ if (!existsSync11(inputPath)) return c.json({ error: "input media not found" }, 404);
5153
5672
  const inputIsVideo = isVideoPath(inputAssetPath);
5154
5673
  const inputIsImage = isImagePath(inputAssetPath);
5155
5674
  if (!inputIsVideo && !inputIsImage) {
@@ -5185,8 +5704,8 @@ function registerMediaRoutes(api, adapter, options = {}) {
5185
5704
  return c.json({ error: "forbidden" }, 403);
5186
5705
  }
5187
5706
  }
5188
- mkdirWithinProject(project.dir, dirname5(outputPath));
5189
- if (backgroundOutputPath) mkdirWithinProject(project.dir, dirname5(backgroundOutputPath));
5707
+ mkdirWithinProject(project.dir, dirname6(outputPath));
5708
+ if (backgroundOutputPath) mkdirWithinProject(project.dir, dirname6(backgroundOutputPath));
5190
5709
  const jobId = makeJobId(project.id, mediaJobs);
5191
5710
  const state = adapter.startBackgroundRemoval({
5192
5711
  project,
@@ -5243,14 +5762,14 @@ function registerMediaRoutes(api, adapter, options = {}) {
5243
5762
  }
5244
5763
 
5245
5764
  // src/routes/globalAssets.ts
5246
- import { existsSync as existsSync9, readFileSync as readFileSync11 } from "fs";
5765
+ import { existsSync as existsSync12, readFileSync as readFileSync13 } from "fs";
5247
5766
  import { homedir } from "os";
5248
- import { join as join14 } from "path";
5767
+ import { join as join16 } from "path";
5249
5768
  function readGlobalAssets(home = homedir()) {
5250
- const manifestPath = join14(home, ".media", "manifest.jsonl");
5251
- if (!existsSync9(manifestPath)) return [];
5769
+ const manifestPath = join16(home, ".media", "manifest.jsonl");
5770
+ if (!existsSync12(manifestPath)) return [];
5252
5771
  const out = [];
5253
- for (const line of readFileSync11(manifestPath, "utf8").split("\n")) {
5772
+ for (const line of readFileSync13(manifestPath, "utf8").split("\n")) {
5254
5773
  if (!line.trim()) continue;
5255
5774
  try {
5256
5775
  const rec = JSON.parse(line);
@@ -5437,6 +5956,9 @@ function createStudioApi(adapter) {
5437
5956
  registerSelectionRoutes(api, adapter);
5438
5957
  registerMediaRoutes(api, adapter);
5439
5958
  registerWaveformRoutes(api, adapter);
5959
+ registerFreezeFrameRoutes(api, adapter);
5960
+ registerLoudnessRoutes(api, adapter);
5961
+ registerPeakRoutes(api, adapter);
5440
5962
  registerFontRoutes(api);
5441
5963
  registerRegistryRoutes(api, adapter);
5442
5964
  registerGlobalAssetRoutes(api);
@@ -5446,7 +5968,7 @@ function createStudioApi(adapter) {
5446
5968
 
5447
5969
  // src/helpers/duplicateElement.ts
5448
5970
  import { sameInstant } from "@hyperframes/core/clip-facts";
5449
- import { ensureHfIds as ensureHfIds3 } from "@hyperframes/parsers/hf-ids";
5971
+ import { ensureHfIds as ensureHfIds4 } from "@hyperframes/parsers/hf-ids";
5450
5972
  function duplicateElementInHtml(source, target, newId, at) {
5451
5973
  const { document, wrappedFragment } = parseSourceDocument(source);
5452
5974
  const element = findTargetElement(document, target);
@@ -5456,23 +5978,23 @@ function duplicateElementInHtml(source, target, newId, at) {
5456
5978
  if (duration === null || numericAttribute(element, "data-start") === null) {
5457
5979
  return { html: source, matched: false, newId: null };
5458
5980
  }
5459
- const uniqueId = nextUniqueId(document, newId);
5981
+ const uniqueId2 = nextUniqueId(document, newId);
5460
5982
  rippleElements(document, element, at, duration, track);
5461
5983
  const clone = element.cloneNode(true);
5462
5984
  if (!isHTMLElement(clone)) return { html: source, matched: false, newId: null };
5463
- clone.setAttribute("id", uniqueId);
5985
+ clone.setAttribute("id", uniqueId2);
5464
5986
  dedupeClonedCompositionId(document, clone);
5465
5987
  stripStableIds(clone);
5466
5988
  clone.setAttribute("data-start", String(at));
5467
5989
  element.parentElement.insertBefore(clone, element.nextSibling);
5468
5990
  const html = wrappedFragment ? document.body.innerHTML || "" : document.toString();
5469
- return { html: ensureHfIds3(html), matched: true, newId: uniqueId };
5991
+ return { html: ensureHfIds4(html), matched: true, newId: uniqueId2 };
5470
5992
  }
5471
5993
  function nextUniqueId(document, newId) {
5472
- let uniqueId = newId;
5994
+ let uniqueId2 = newId;
5473
5995
  let suffix = 2;
5474
- while (document.getElementById(uniqueId)) uniqueId = `${newId}-${suffix++}`;
5475
- return uniqueId;
5996
+ while (document.getElementById(uniqueId2)) uniqueId2 = `${newId}-${suffix++}`;
5997
+ return uniqueId2;
5476
5998
  }
5477
5999
  function rippleElements(document, element, at, duration, track) {
5478
6000
  for (const candidate of Array.from(document.querySelectorAll("[data-start][data-duration]"))) {
@@ -5498,18 +6020,18 @@ function numericAttribute(element, name) {
5498
6020
  }
5499
6021
 
5500
6022
  // src/helpers/previewDocumentStore.ts
5501
- import { createHash as createHash4 } from "crypto";
5502
- import { readFileSync as readFileSync12, readdirSync as readdirSync5, rmSync as rmSync5 } from "fs";
5503
- import { join as join15 } from "path";
5504
- import { replaceFileAtomically as replaceFileAtomically3 } from "@hyperframes/core/atomic-file";
6023
+ import { createHash as createHash5 } from "crypto";
6024
+ import { readFileSync as readFileSync14, readdirSync as readdirSync5, rmSync as rmSync5 } from "fs";
6025
+ import { join as join17 } from "path";
6026
+ import { replaceFileAtomically as replaceFileAtomically4 } from "@hyperframes/core/atomic-file";
5505
6027
  function createPreviewDocumentStore(projectDir, salt) {
5506
- const dir = join15(projectDir, ".hyperframes", "preview");
5507
- const fileFor = (key) => join15(dir, `${createHash4("sha256").update(`${salt}
6028
+ const dir = join17(projectDir, ".hyperframes", "preview");
6029
+ const fileFor = (key) => join17(dir, `${createHash5("sha256").update(`${salt}
5508
6030
  ${key}`).digest("hex").slice(0, 32)}.html`);
5509
6031
  return {
5510
6032
  read(key) {
5511
6033
  try {
5512
- return readFileSync12(fileFor(key), "utf-8");
6034
+ return readFileSync14(fileFor(key), "utf-8");
5513
6035
  } catch {
5514
6036
  return null;
5515
6037
  }
@@ -5518,9 +6040,9 @@ ${key}`).digest("hex").slice(0, 32)}.html`);
5518
6040
  try {
5519
6041
  mkdirWithinProject(projectDir, dir);
5520
6042
  const file = fileFor(key);
5521
- replaceFileAtomically3(file, html, 420);
6043
+ replaceFileAtomically4(file, html, 420);
5522
6044
  for (const name of readdirSync5(dir)) {
5523
- if (join15(dir, name) !== file) rmSync5(join15(dir, name), { force: true });
6045
+ if (join17(dir, name) !== file) rmSync5(join17(dir, name), { force: true });
5524
6046
  }
5525
6047
  } catch {
5526
6048
  }