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