@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/{chunk-IQK4ANAI.js → chunk-KUEG5MYA.js} +7 -4
- package/dist/chunk-KUEG5MYA.js.map +1 -0
- package/dist/chunk-TEYDBVSK.js +361 -0
- package/dist/chunk-TEYDBVSK.js.map +1 -0
- package/dist/helpers/loudness.d.ts +87 -0
- package/dist/helpers/loudness.js +29 -0
- package/dist/helpers/loudness.js.map +1 -0
- package/dist/helpers/mediaProxyPreview.d.ts +1 -1
- package/dist/history/index.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +624 -63
- package/dist/index.js.map +1 -1
- package/dist/{mediaProxyPreview-rleU3r_Q.d.ts → mediaProxyPreview-CCxjB3rf.d.ts} +1 -0
- package/package.json +7 -3
- package/dist/chunk-IQK4ANAI.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -43,10 +43,17 @@ import {
|
|
|
43
43
|
resolveProjectAndSignature,
|
|
44
44
|
resolveProjectSignature,
|
|
45
45
|
settledFileTag
|
|
46
|
-
} from "./chunk-
|
|
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
|
|
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:
|
|
1242
|
-
y:
|
|
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 =
|
|
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 =
|
|
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] =
|
|
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] =
|
|
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
|
|
2782
|
-
|
|
2783
|
-
|
|
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
|
|
4273
|
+
let resolve7;
|
|
4233
4274
|
let reject;
|
|
4234
4275
|
const promise = new Promise((resolvePromise, rejectPromise) => {
|
|
4235
|
-
|
|
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:
|
|
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((
|
|
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
|
-
|
|
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
|
|
4401
|
+
let relative5;
|
|
4361
4402
|
try {
|
|
4362
|
-
|
|
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(
|
|
4367
|
-
const path = pinWithinProject(project.dir,
|
|
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
|
|
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 (!
|
|
5353
|
+
if (!isRecord2(value) || !Array.isArray(value.familyMetadataList)) return [];
|
|
4796
5354
|
const families = [];
|
|
4797
5355
|
for (const entry of value.familyMetadataList) {
|
|
4798
|
-
if (!
|
|
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
|
|
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
|
|
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
|
|
5485
|
+
return isRecord3(value) && ["x", "y", "width", "height"].every((key) => isFiniteNumber(value[key]));
|
|
4928
5486
|
}
|
|
4929
5487
|
function isTarget(value) {
|
|
4930
|
-
if (!
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
5002
|
-
import { basename as basename2, dirname as
|
|
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) ||
|
|
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 (!
|
|
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 (!
|
|
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,
|
|
5159
|
-
if (backgroundOutputPath) mkdirWithinProject(project.dir,
|
|
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
|
|
5774
|
+
import { existsSync as existsSync12, readFileSync as readFileSync13 } from "fs";
|
|
5217
5775
|
import { homedir } from "os";
|
|
5218
|
-
import { join as
|
|
5776
|
+
import { join as join16 } from "path";
|
|
5219
5777
|
function readGlobalAssets(home = homedir()) {
|
|
5220
|
-
const manifestPath =
|
|
5221
|
-
if (!
|
|
5778
|
+
const manifestPath = join16(home, ".media", "manifest.jsonl");
|
|
5779
|
+
if (!existsSync12(manifestPath)) return [];
|
|
5222
5780
|
const out = [];
|
|
5223
|
-
for (const line of
|
|
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
|
|
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
|
|
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",
|
|
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:
|
|
6000
|
+
return { html: ensureHfIds4(html), matched: true, newId: uniqueId2 };
|
|
5440
6001
|
}
|
|
5441
6002
|
function nextUniqueId(document, newId) {
|
|
5442
|
-
let
|
|
6003
|
+
let uniqueId2 = newId;
|
|
5443
6004
|
let suffix = 2;
|
|
5444
|
-
while (document.getElementById(
|
|
5445
|
-
return
|
|
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
|
|
5472
|
-
import { readFileSync as
|
|
5473
|
-
import { join as
|
|
5474
|
-
import { replaceFileAtomically as
|
|
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 =
|
|
5477
|
-
const fileFor = (key) =>
|
|
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
|
|
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
|
-
|
|
6052
|
+
replaceFileAtomically4(file, html, 420);
|
|
5492
6053
|
for (const name of readdirSync5(dir)) {
|
|
5493
|
-
if (
|
|
6054
|
+
if (join17(dir, name) !== file) rmSync5(join17(dir, name), { force: true });
|
|
5494
6055
|
}
|
|
5495
6056
|
} catch {
|
|
5496
6057
|
}
|