@hyperframes/studio-server 0.8.107 → 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
@@ -47,6 +47,13 @@ import {
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,
@@ -1248,7 +1255,7 @@ function bakeGroupTransformIntoMembers(html, groupId, members, groupCenter) {
1248
1255
  const rad = grot * Math.PI / 180;
1249
1256
  const cos = Math.cos(rad);
1250
1257
  const sin = Math.sin(rad);
1251
- const round3 = (n) => Math.round(n * 1e3) / 1e3;
1258
+ const round32 = (n) => Math.round(n * 1e3) / 1e3;
1252
1259
  let script = block.scriptText;
1253
1260
  for (const m of members) {
1254
1261
  const memberSel = `#${m.id}`;
@@ -1265,27 +1272,27 @@ function bakeGroupTransformIntoMembers(html, groupId, members, groupCenter) {
1265
1272
  const visY = groupCenter.cy + gscale * (sin * dx + cos * dy) + gy;
1266
1273
  const newProps = {
1267
1274
  ...mProps,
1268
- x: round3(visX - m.cx),
1269
- y: round3(visY - m.cy)
1275
+ x: round32(visX - m.cx),
1276
+ y: round32(visY - m.cy)
1270
1277
  };
1271
1278
  if (gz !== 0) newProps.z = (typeof mProps.z === "number" ? mProps.z : 0) + gz;
1272
1279
  if (grot !== 0) {
1273
- newProps.rotation = round3(
1280
+ newProps.rotation = round32(
1274
1281
  (typeof mProps.rotation === "number" ? mProps.rotation : 0) + grot
1275
1282
  );
1276
1283
  }
1277
1284
  if (gscale !== 1) {
1278
- newProps.scale = round3((typeof mProps.scale === "number" ? mProps.scale : 1) * gscale);
1285
+ newProps.scale = round32((typeof mProps.scale === "number" ? mProps.scale : 1) * gscale);
1279
1286
  }
1280
1287
  const pivoted = /* @__PURE__ */ new Set(["x", "y", "z", "rotation", "scale"]);
1281
1288
  for (const [k, v] of Object.entries(gt)) {
1282
1289
  if (pivoted.has(k) || typeof v !== "number") continue;
1283
1290
  if (k === "scaleX" || k === "scaleY") {
1284
- 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);
1285
1292
  } else if (k === "transformPerspective") {
1286
1293
  if (typeof mProps[k] !== "number") newProps[k] = v;
1287
1294
  } else if (v !== 0) {
1288
- newProps[k] = round3((typeof mProps[k] === "number" ? mProps[k] : 0) + v);
1295
+ newProps[k] = round32((typeof mProps[k] === "number" ? mProps[k] : 0) + v);
1289
1296
  }
1290
1297
  }
1291
1298
  for (const s of [...sets].reverse()) {
@@ -4096,7 +4103,8 @@ function registerRenderRoutes(api, adapter) {
4096
4103
  progress: current.progress,
4097
4104
  status: current.status,
4098
4105
  stage: current.stage,
4099
- error: current.error
4106
+ error: current.error,
4107
+ ...current.status === "complete" && current.audioLoweredDb !== void 0 ? { audioLoweredDb: current.audioLoweredDb } : {}
4100
4108
  })
4101
4109
  });
4102
4110
  if (current.status !== "rendering") break;
@@ -4205,11 +4213,13 @@ function registerRenderRoutes(api, adapter) {
4205
4213
  const metaPath = join10(rendersDir, `${rid}.meta.json`);
4206
4214
  let status = "complete";
4207
4215
  let durationMs;
4216
+ let audioLoweredDb;
4208
4217
  if (existsSync6(metaPath)) {
4209
4218
  try {
4210
4219
  const meta = JSON.parse(readFileSync7(metaPath, "utf-8"));
4211
4220
  if (meta.status === "failed" && !existsSync6(fp)) status = "failed";
4212
4221
  if (meta.durationMs) durationMs = meta.durationMs;
4222
+ if (typeof meta.audioLoweredDb === "number") audioLoweredDb = meta.audioLoweredDb;
4213
4223
  } catch {
4214
4224
  }
4215
4225
  }
@@ -4219,7 +4229,8 @@ function registerRenderRoutes(api, adapter) {
4219
4229
  size: stat2.size,
4220
4230
  createdAt: stat2.mtimeMs,
4221
4231
  status,
4222
- durationMs
4232
+ durationMs,
4233
+ ...audioLoweredDb !== void 0 ? { audioLoweredDb } : {}
4223
4234
  };
4224
4235
  }).sort((a, b) => b.createdAt - a.createdAt);
4225
4236
  for (const file of files) {
@@ -4259,10 +4270,10 @@ var ThumbnailGenerationCoordinator = class {
4259
4270
  if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
4260
4271
  let entry = this.entries.get(key);
4261
4272
  if (!entry) {
4262
- let resolve6;
4273
+ let resolve7;
4263
4274
  let reject;
4264
4275
  const promise = new Promise((resolvePromise, rejectPromise) => {
4265
- resolve6 = resolvePromise;
4276
+ resolve7 = resolvePromise;
4266
4277
  reject = rejectPromise;
4267
4278
  });
4268
4279
  entry = {
@@ -4272,7 +4283,7 @@ var ThumbnailGenerationCoordinator = class {
4272
4283
  state: "queued",
4273
4284
  work,
4274
4285
  promise,
4275
- resolve: resolve6,
4286
+ resolve: resolve7,
4276
4287
  reject
4277
4288
  };
4278
4289
  this.entries.set(key, entry);
@@ -4286,7 +4297,7 @@ var ThumbnailGenerationCoordinator = class {
4286
4297
  return /* @__PURE__ */ new Set([...this.entries.keys(), ...[...this.activeEntries].map((entry) => entry.key)]);
4287
4298
  }
4288
4299
  lease(entry, signal) {
4289
- return new Promise((resolve6, reject) => {
4300
+ return new Promise((resolve7, reject) => {
4290
4301
  let released = false;
4291
4302
  const release = () => {
4292
4303
  if (released) return;
@@ -4310,7 +4321,7 @@ var ThumbnailGenerationCoordinator = class {
4310
4321
  entry.promise.then(
4311
4322
  (value) => {
4312
4323
  release();
4313
- resolve6(value);
4324
+ resolve7(value);
4314
4325
  },
4315
4326
  (reason) => {
4316
4327
  release();
@@ -4387,14 +4398,14 @@ async function generateThumbnail(path, signal) {
4387
4398
  async function resolveSource(adapter, id, url) {
4388
4399
  const project = await adapter.resolveProject(id);
4389
4400
  if (!project) throw new HTTPException(404);
4390
- let relative4;
4401
+ let relative5;
4391
4402
  try {
4392
- relative4 = requestSubPath(url, "projects/:id/image-thumbnail");
4403
+ relative5 = requestSubPath(url, "projects/:id/image-thumbnail");
4393
4404
  } catch {
4394
4405
  throw new HTTPException(400);
4395
4406
  }
4396
- if (!/\.jpe?g$/i.test(relative4)) throw new HTTPException(415);
4397
- const path = pinWithinProject(project.dir, relative4);
4407
+ if (!/\.jpe?g$/i.test(relative5)) throw new HTTPException(415);
4408
+ const path = pinWithinProject(project.dir, relative5);
4398
4409
  if (!path) throw new HTTPException(404);
4399
4410
  const info = await stat(path).catch(() => {
4400
4411
  throw new HTTPException(404);
@@ -4762,6 +4773,523 @@ function registerWaveformRoutes(api, adapter) {
4762
4773
  });
4763
4774
  }
4764
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
+
4765
5293
  // src/routes/fonts.ts
4766
5294
  import { closeSync as closeSync3, constants, fstatSync as fstatSync3, openSync as openSync3, readSync } from "fs";
4767
5295
  import {
@@ -4798,7 +5326,7 @@ var GOOGLE_FONT_FALLBACKS = [
4798
5326
  "Outfit",
4799
5327
  "JetBrains Mono"
4800
5328
  ];
4801
- function isRecord(value) {
5329
+ function isRecord2(value) {
4802
5330
  return typeof value === "object" && value !== null;
4803
5331
  }
4804
5332
  function collectFontsFromDir(dir) {
@@ -4822,10 +5350,10 @@ function listInstalledFontFamilies() {
4822
5350
  return cachedFonts;
4823
5351
  }
4824
5352
  function parseGoogleFontMetadata(value) {
4825
- if (!isRecord(value) || !Array.isArray(value.familyMetadataList)) return [];
5353
+ if (!isRecord2(value) || !Array.isArray(value.familyMetadataList)) return [];
4826
5354
  const families = [];
4827
5355
  for (const entry of value.familyMetadataList) {
4828
- if (!isRecord(entry) || typeof entry.family !== "string") continue;
5356
+ if (!isRecord2(entry) || typeof entry.family !== "string") continue;
4829
5357
  families.push(entry.family);
4830
5358
  }
4831
5359
  return families;
@@ -4929,14 +5457,14 @@ function registerRegistryRoutes(api, adapter) {
4929
5457
  }
4930
5458
 
4931
5459
  // src/routes/selection.ts
4932
- function isRecord2(value) {
5460
+ function isRecord3(value) {
4933
5461
  return typeof value === "object" && value !== null && !Array.isArray(value);
4934
5462
  }
4935
5463
  function isFiniteNumber(value) {
4936
5464
  return typeof value === "number" && Number.isFinite(value);
4937
5465
  }
4938
5466
  function isStringRecord(value) {
4939
- return isRecord2(value) && Object.values(value).every((v) => typeof v === "string");
5467
+ return isRecord3(value) && Object.values(value).every((v) => typeof v === "string");
4940
5468
  }
4941
5469
  function hasString(value, key) {
4942
5470
  return typeof value[key] === "string";
@@ -4954,20 +5482,20 @@ function hasOptionalNumber(value, key) {
4954
5482
  return value[key] === void 0 || isFiniteNumber(value[key]);
4955
5483
  }
4956
5484
  function isBoundingBox(value) {
4957
- 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]));
4958
5486
  }
4959
5487
  function isTarget(value) {
4960
- if (!isRecord2(value)) return false;
5488
+ if (!isRecord3(value)) return false;
4961
5489
  return hasOptionalNullableString(value, "id") && hasOptionalString(value, "hfId") && hasOptionalString(value, "selector") && hasOptionalNumber(value, "selectorIndex");
4962
5490
  }
4963
5491
  function isTextField(value) {
4964
- 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);
4965
5493
  }
4966
5494
  function isTextFields(value) {
4967
5495
  return Array.isArray(value) && value.every(isTextField);
4968
5496
  }
4969
5497
  function isSelectionSnapshot(value) {
4970
- if (!isRecord2(value)) return false;
5498
+ if (!isRecord3(value)) return false;
4971
5499
  const checks = [
4972
5500
  value.schemaVersion === 1 && hasRequiredStrings(value, [
4973
5501
  "projectId",
@@ -4985,7 +5513,7 @@ function isSelectionSnapshot(value) {
4985
5513
  isStringRecord(value.inlineStyles),
4986
5514
  isStringRecord(value.computedStyles),
4987
5515
  isTextFields(value.textFields),
4988
- isRecord2(value.capabilities)
5516
+ isRecord3(value.capabilities)
4989
5517
  ];
4990
5518
  return checks.every(Boolean);
4991
5519
  }
@@ -5009,7 +5537,7 @@ function registerSelectionRoutes(api, adapter) {
5009
5537
  } catch {
5010
5538
  return c.json({ error: "invalid json" }, 400);
5011
5539
  }
5012
- if (!isRecord2(body) || !("selection" in body)) {
5540
+ if (!isRecord3(body) || !("selection" in body)) {
5013
5541
  return c.json({ error: "missing selection" }, 400);
5014
5542
  }
5015
5543
  if (body.selection === null) {
@@ -5028,8 +5556,8 @@ function registerSelectionRoutes(api, adapter) {
5028
5556
 
5029
5557
  // src/routes/media.ts
5030
5558
  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";
5559
+ import { existsSync as existsSync11 } from "fs";
5560
+ import { basename as basename2, dirname as dirname6, extname, join as join15, posix } from "path";
5033
5561
  var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([
5034
5562
  ".mp4",
5035
5563
  ".mov",
@@ -5066,7 +5594,7 @@ function uniqueAssetPath(projectDir, assetPath, taken) {
5066
5594
  const ext = extname(assetPath);
5067
5595
  const withoutExt = assetPath.slice(0, -ext.length);
5068
5596
  let candidate = assetPath;
5069
- 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++) {
5070
5598
  candidate = `${withoutExt}-${index}${ext}`;
5071
5599
  }
5072
5600
  return candidate;
@@ -5127,7 +5655,7 @@ function registerMediaRoutes(api, adapter, options = {}) {
5127
5655
  }
5128
5656
  const filePath = resolveWithinProject(project.dir, assetPath);
5129
5657
  if (!filePath) return c.json({ error: "forbidden" }, 403);
5130
- if (!existsSync8(filePath)) return c.json({ error: "media not found" }, 404);
5658
+ if (!existsSync11(filePath)) return c.json({ error: "media not found" }, 404);
5131
5659
  return c.json({ path: assetPath, metadata: await readMediaMetadata(filePath) });
5132
5660
  });
5133
5661
  api.post(
@@ -5149,7 +5677,7 @@ function registerMediaRoutes(api, adapter, options = {}) {
5149
5677
  }
5150
5678
  const inputPath = resolveWithinProject(project.dir, inputAssetPath);
5151
5679
  if (!inputPath) return c.json({ error: "forbidden" }, 403);
5152
- 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);
5153
5681
  const inputIsVideo = isVideoPath(inputAssetPath);
5154
5682
  const inputIsImage = isImagePath(inputAssetPath);
5155
5683
  if (!inputIsVideo && !inputIsImage) {
@@ -5185,8 +5713,8 @@ function registerMediaRoutes(api, adapter, options = {}) {
5185
5713
  return c.json({ error: "forbidden" }, 403);
5186
5714
  }
5187
5715
  }
5188
- mkdirWithinProject(project.dir, dirname5(outputPath));
5189
- if (backgroundOutputPath) mkdirWithinProject(project.dir, dirname5(backgroundOutputPath));
5716
+ mkdirWithinProject(project.dir, dirname6(outputPath));
5717
+ if (backgroundOutputPath) mkdirWithinProject(project.dir, dirname6(backgroundOutputPath));
5190
5718
  const jobId = makeJobId(project.id, mediaJobs);
5191
5719
  const state = adapter.startBackgroundRemoval({
5192
5720
  project,
@@ -5243,14 +5771,14 @@ function registerMediaRoutes(api, adapter, options = {}) {
5243
5771
  }
5244
5772
 
5245
5773
  // src/routes/globalAssets.ts
5246
- import { existsSync as existsSync9, readFileSync as readFileSync11 } from "fs";
5774
+ import { existsSync as existsSync12, readFileSync as readFileSync13 } from "fs";
5247
5775
  import { homedir } from "os";
5248
- import { join as join14 } from "path";
5776
+ import { join as join16 } from "path";
5249
5777
  function readGlobalAssets(home = homedir()) {
5250
- const manifestPath = join14(home, ".media", "manifest.jsonl");
5251
- if (!existsSync9(manifestPath)) return [];
5778
+ const manifestPath = join16(home, ".media", "manifest.jsonl");
5779
+ if (!existsSync12(manifestPath)) return [];
5252
5780
  const out = [];
5253
- for (const line of readFileSync11(manifestPath, "utf8").split("\n")) {
5781
+ for (const line of readFileSync13(manifestPath, "utf8").split("\n")) {
5254
5782
  if (!line.trim()) continue;
5255
5783
  try {
5256
5784
  const rec = JSON.parse(line);
@@ -5437,6 +5965,9 @@ function createStudioApi(adapter) {
5437
5965
  registerSelectionRoutes(api, adapter);
5438
5966
  registerMediaRoutes(api, adapter);
5439
5967
  registerWaveformRoutes(api, adapter);
5968
+ registerFreezeFrameRoutes(api, adapter);
5969
+ registerLoudnessRoutes(api, adapter);
5970
+ registerPeakRoutes(api, adapter);
5440
5971
  registerFontRoutes(api);
5441
5972
  registerRegistryRoutes(api, adapter);
5442
5973
  registerGlobalAssetRoutes(api);
@@ -5446,7 +5977,7 @@ function createStudioApi(adapter) {
5446
5977
 
5447
5978
  // src/helpers/duplicateElement.ts
5448
5979
  import { sameInstant } from "@hyperframes/core/clip-facts";
5449
- import { ensureHfIds as ensureHfIds3 } from "@hyperframes/parsers/hf-ids";
5980
+ import { ensureHfIds as ensureHfIds4 } from "@hyperframes/parsers/hf-ids";
5450
5981
  function duplicateElementInHtml(source, target, newId, at) {
5451
5982
  const { document, wrappedFragment } = parseSourceDocument(source);
5452
5983
  const element = findTargetElement(document, target);
@@ -5456,23 +5987,23 @@ function duplicateElementInHtml(source, target, newId, at) {
5456
5987
  if (duration === null || numericAttribute(element, "data-start") === null) {
5457
5988
  return { html: source, matched: false, newId: null };
5458
5989
  }
5459
- const uniqueId = nextUniqueId(document, newId);
5990
+ const uniqueId2 = nextUniqueId(document, newId);
5460
5991
  rippleElements(document, element, at, duration, track);
5461
5992
  const clone = element.cloneNode(true);
5462
5993
  if (!isHTMLElement(clone)) return { html: source, matched: false, newId: null };
5463
- clone.setAttribute("id", uniqueId);
5994
+ clone.setAttribute("id", uniqueId2);
5464
5995
  dedupeClonedCompositionId(document, clone);
5465
5996
  stripStableIds(clone);
5466
5997
  clone.setAttribute("data-start", String(at));
5467
5998
  element.parentElement.insertBefore(clone, element.nextSibling);
5468
5999
  const html = wrappedFragment ? document.body.innerHTML || "" : document.toString();
5469
- return { html: ensureHfIds3(html), matched: true, newId: uniqueId };
6000
+ return { html: ensureHfIds4(html), matched: true, newId: uniqueId2 };
5470
6001
  }
5471
6002
  function nextUniqueId(document, newId) {
5472
- let uniqueId = newId;
6003
+ let uniqueId2 = newId;
5473
6004
  let suffix = 2;
5474
- while (document.getElementById(uniqueId)) uniqueId = `${newId}-${suffix++}`;
5475
- return uniqueId;
6005
+ while (document.getElementById(uniqueId2)) uniqueId2 = `${newId}-${suffix++}`;
6006
+ return uniqueId2;
5476
6007
  }
5477
6008
  function rippleElements(document, element, at, duration, track) {
5478
6009
  for (const candidate of Array.from(document.querySelectorAll("[data-start][data-duration]"))) {
@@ -5498,18 +6029,18 @@ function numericAttribute(element, name) {
5498
6029
  }
5499
6030
 
5500
6031
  // 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";
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";
5505
6036
  function createPreviewDocumentStore(projectDir, salt) {
5506
- const dir = join15(projectDir, ".hyperframes", "preview");
5507
- 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}
5508
6039
  ${key}`).digest("hex").slice(0, 32)}.html`);
5509
6040
  return {
5510
6041
  read(key) {
5511
6042
  try {
5512
- return readFileSync12(fileFor(key), "utf-8");
6043
+ return readFileSync14(fileFor(key), "utf-8");
5513
6044
  } catch {
5514
6045
  return null;
5515
6046
  }
@@ -5518,9 +6049,9 @@ ${key}`).digest("hex").slice(0, 32)}.html`);
5518
6049
  try {
5519
6050
  mkdirWithinProject(projectDir, dir);
5520
6051
  const file = fileFor(key);
5521
- replaceFileAtomically3(file, html, 420);
6052
+ replaceFileAtomically4(file, html, 420);
5522
6053
  for (const name of readdirSync5(dir)) {
5523
- if (join15(dir, name) !== file) rmSync5(join15(dir, name), { force: true });
6054
+ if (join17(dir, name) !== file) rmSync5(join17(dir, name), { force: true });
5524
6055
  }
5525
6056
  } catch {
5526
6057
  }