@hyperframes/studio-server 0.8.80 → 0.8.82
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-YGANSFUU.js → chunk-FTDTO3QP.js} +3 -3
- package/dist/{chunk-56JREE33.js → chunk-G6IIFKXY.js} +7 -14
- package/dist/{chunk-56JREE33.js.map → chunk-G6IIFKXY.js.map} +1 -1
- package/dist/{chunk-KOB22VPG.js → chunk-TJQ76IEA.js} +71 -5
- package/dist/chunk-TJQ76IEA.js.map +1 -0
- package/dist/{chunk-P5EJPIH3.js → chunk-W6TFBTP2.js} +7 -6
- package/dist/chunk-W6TFBTP2.js.map +1 -0
- package/dist/helpers/mediaCodecMap.js +1 -1
- package/dist/helpers/mediaProxyPreview.d.ts +1 -1
- package/dist/helpers/mediaProxyPreview.js +3 -3
- package/dist/helpers/proxyTranscoder.js +2 -2
- package/dist/helpers/sourceMutation.js +1 -1
- package/dist/index.d.ts +26 -3
- package/dist/index.js +455 -250
- package/dist/index.js.map +1 -1
- package/dist/{mediaProxyPreview-B7ckRI-l.d.ts → mediaProxyPreview-BMHd9G3N.d.ts} +5 -3
- package/package.json +3 -3
- package/dist/chunk-KOB22VPG.js.map +0 -1
- package/dist/chunk-P5EJPIH3.js.map +0 -1
- /package/dist/{chunk-YGANSFUU.js.map → chunk-FTDTO3QP.js.map} +0 -0
package/dist/index.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
splitElementInHtml,
|
|
14
14
|
unwrapElementsFromHtml,
|
|
15
15
|
wrapElementsInHtml
|
|
16
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-W6TFBTP2.js";
|
|
17
17
|
import {
|
|
18
18
|
getElementScreenshotClip
|
|
19
19
|
} from "./chunk-W2SBTCO2.js";
|
|
@@ -22,21 +22,28 @@ import {
|
|
|
22
22
|
isAutoProxyEnabled,
|
|
23
23
|
proxyEtagSalt,
|
|
24
24
|
resolvePreviewMediaCodecProbeCache
|
|
25
|
-
} from "./chunk-
|
|
25
|
+
} from "./chunk-FTDTO3QP.js";
|
|
26
26
|
import {
|
|
27
27
|
ProxyCapacityError,
|
|
28
28
|
ProxyTranscodeError,
|
|
29
29
|
resolveProxy
|
|
30
|
-
} from "./chunk-
|
|
30
|
+
} from "./chunk-G6IIFKXY.js";
|
|
31
31
|
import {
|
|
32
32
|
PROXY_VARIANT_CONFIG,
|
|
33
33
|
decideMediaProxyEligibility,
|
|
34
|
+
isInHiddenOrVendorDir,
|
|
34
35
|
isProxyVariantRequest,
|
|
36
|
+
isSafePath,
|
|
37
|
+
pinWithinProject,
|
|
35
38
|
probeAssetCodec,
|
|
36
39
|
probeMediaMetadata,
|
|
40
|
+
realFilePath,
|
|
41
|
+
realpath,
|
|
37
42
|
recordProxyRequest,
|
|
38
|
-
resolveProxyVariantRequest
|
|
39
|
-
|
|
43
|
+
resolveProxyVariantRequest,
|
|
44
|
+
resolveWithinProject,
|
|
45
|
+
walkDir
|
|
46
|
+
} from "./chunk-TJQ76IEA.js";
|
|
40
47
|
import {
|
|
41
48
|
STUDIO_MANUAL_EDITS_PATH,
|
|
42
49
|
createStudioManualEditsRenderBodyScript,
|
|
@@ -52,64 +59,7 @@ import { Hono } from "hono";
|
|
|
52
59
|
|
|
53
60
|
// src/routes/projects.ts
|
|
54
61
|
import { readFile } from "fs/promises";
|
|
55
|
-
import { join as
|
|
56
|
-
|
|
57
|
-
// src/helpers/safePath.ts
|
|
58
|
-
import { basename, dirname, isAbsolute, join, relative, sep } from "path";
|
|
59
|
-
import { readdirSync, realpathSync } from "fs";
|
|
60
|
-
import { resolveWithinProject } from "@hyperframes/core";
|
|
61
|
-
import { isSafePath, resolveWithinProject as resolveWithinProject2 } from "@hyperframes/core";
|
|
62
|
-
function realFilePath(filePath) {
|
|
63
|
-
try {
|
|
64
|
-
return realpathSync(filePath);
|
|
65
|
-
} catch {
|
|
66
|
-
const dir = dirname(filePath);
|
|
67
|
-
return dir === filePath ? filePath : join(realFilePath(dir), basename(filePath));
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
function pinWithinProject(base, relativePath) {
|
|
71
|
-
const checked = resolveWithinProject(base, relativePath);
|
|
72
|
-
if (!checked) return null;
|
|
73
|
-
const inside = relative(realFilePath(base), realFilePath(checked));
|
|
74
|
-
const escapes = inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside);
|
|
75
|
-
return escapes ? null : join(base, inside);
|
|
76
|
-
}
|
|
77
|
-
var IGNORE_DIRS = /* @__PURE__ */ new Set([
|
|
78
|
-
".thumbnails",
|
|
79
|
-
".transcode-cache",
|
|
80
|
-
".waveform-cache",
|
|
81
|
-
"node_modules",
|
|
82
|
-
".git"
|
|
83
|
-
]);
|
|
84
|
-
function shouldIgnoreDir(rel) {
|
|
85
|
-
return rel === ".hyperframes/backup";
|
|
86
|
-
}
|
|
87
|
-
function isInHiddenOrVendorDir(relPath) {
|
|
88
|
-
const segments = relPath.split("/");
|
|
89
|
-
return segments.slice(0, -1).some((seg) => seg.startsWith(".") || seg === "node_modules");
|
|
90
|
-
}
|
|
91
|
-
var UNREADABLE_DIR_CODES = /* @__PURE__ */ new Set(["EACCES", "EPERM", "ENOENT", "ENOTDIR"]);
|
|
92
|
-
function walkDir(dir, prefix = "") {
|
|
93
|
-
let entries;
|
|
94
|
-
try {
|
|
95
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
96
|
-
} catch (err) {
|
|
97
|
-
const code = err.code;
|
|
98
|
-
if (!prefix || !code || !UNREADABLE_DIR_CODES.has(code)) throw err;
|
|
99
|
-
return [];
|
|
100
|
-
}
|
|
101
|
-
const files = [];
|
|
102
|
-
for (const entry of entries) {
|
|
103
|
-
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
104
|
-
if (IGNORE_DIRS.has(entry.name) || shouldIgnoreDir(rel)) continue;
|
|
105
|
-
if (entry.isDirectory()) {
|
|
106
|
-
files.push(...walkDir(join(dir, entry.name), rel));
|
|
107
|
-
} else {
|
|
108
|
-
files.push(rel);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
return files;
|
|
112
|
-
}
|
|
62
|
+
import { join as join2 } from "path";
|
|
113
63
|
|
|
114
64
|
// src/helpers/hfIdPersist.ts
|
|
115
65
|
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
|
|
@@ -123,7 +73,7 @@ import {
|
|
|
123
73
|
readFileSync,
|
|
124
74
|
writeFileSync
|
|
125
75
|
} from "fs";
|
|
126
|
-
import { dirname
|
|
76
|
+
import { dirname, join } from "path";
|
|
127
77
|
|
|
128
78
|
// src/helpers/atomicFile.ts
|
|
129
79
|
import * as fs from "fs";
|
|
@@ -145,10 +95,10 @@ function replaceFileAtomically(filePath, content, mode, operations = fs) {
|
|
|
145
95
|
|
|
146
96
|
// src/helpers/hfIdPersist.ts
|
|
147
97
|
var isCompositionSource = (html) => /data-composition-id\s*=/.test(html);
|
|
148
|
-
var STAMP_RECORD =
|
|
98
|
+
var STAMP_RECORD = join(".hyperframes", "hf-ids-stamped.json");
|
|
149
99
|
var contentHash = (text2) => createHash("sha1").update(text2).digest("base64url");
|
|
150
100
|
function stampProjectHfIds(projectDir) {
|
|
151
|
-
const recordPath =
|
|
101
|
+
const recordPath = join(projectDir, STAMP_RECORD);
|
|
152
102
|
let last = {};
|
|
153
103
|
try {
|
|
154
104
|
last = JSON.parse(readFileSync(recordPath, "utf-8"));
|
|
@@ -157,7 +107,7 @@ function stampProjectHfIds(projectDir) {
|
|
|
157
107
|
const next = {};
|
|
158
108
|
for (const file of walkDir(projectDir)) {
|
|
159
109
|
if (!file.endsWith(".html") || isInHiddenOrVendorDir(file)) continue;
|
|
160
|
-
const absPath =
|
|
110
|
+
const absPath = join(projectDir, file);
|
|
161
111
|
let html;
|
|
162
112
|
try {
|
|
163
113
|
html = readFileSync(absPath, "utf-8");
|
|
@@ -173,7 +123,7 @@ function stampProjectHfIds(projectDir) {
|
|
|
173
123
|
next[file] = hash;
|
|
174
124
|
}
|
|
175
125
|
try {
|
|
176
|
-
mkdirSync(
|
|
126
|
+
mkdirSync(dirname(recordPath), { recursive: true });
|
|
177
127
|
writeFileSync(recordPath, JSON.stringify(next));
|
|
178
128
|
} catch {
|
|
179
129
|
}
|
|
@@ -226,8 +176,8 @@ function stampFileHfIds(filePath) {
|
|
|
226
176
|
|
|
227
177
|
// src/helpers/projectSignature.ts
|
|
228
178
|
import { createHash as createHash2 } from "crypto";
|
|
229
|
-
import { lstatSync, readFileSync as readFileSync2, readdirSync
|
|
230
|
-
import { extname, isAbsolute
|
|
179
|
+
import { lstatSync, readFileSync as readFileSync2, readdirSync } from "fs";
|
|
180
|
+
import { extname, isAbsolute, relative, resolve, sep } from "path";
|
|
231
181
|
var SIGNATURE_TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
232
182
|
".cjs",
|
|
233
183
|
".css",
|
|
@@ -262,18 +212,18 @@ var STUDIO_SIGNATURE_MANIFEST_PATHS = [
|
|
|
262
212
|
".hyperframes/studio-motion.json"
|
|
263
213
|
];
|
|
264
214
|
function affectsProjectSignature(projectDir, changedPath) {
|
|
265
|
-
const relativePath =
|
|
266
|
-
if (relativePath === "" || relativePath.startsWith("..") ||
|
|
215
|
+
const relativePath = relative(resolve(projectDir), resolve(changedPath));
|
|
216
|
+
if (relativePath === "" || relativePath.startsWith("..") || isAbsolute(relativePath)) {
|
|
267
217
|
return false;
|
|
268
218
|
}
|
|
269
|
-
const segments = relativePath.split(
|
|
219
|
+
const segments = relativePath.split(sep);
|
|
270
220
|
if (STUDIO_SIGNATURE_MANIFEST_PATHS.includes(segments.join("/"))) return true;
|
|
271
221
|
return !segments.some((segment) => SIGNATURE_EXCLUDED_DIRS.has(segment));
|
|
272
222
|
}
|
|
273
223
|
var projectSignatureCache = /* @__PURE__ */ new Map();
|
|
274
224
|
function isPathWithin(parentDir, childPath) {
|
|
275
|
-
const childRelativePath =
|
|
276
|
-
return childRelativePath === "" || !childRelativePath.startsWith("..") && !
|
|
225
|
+
const childRelativePath = relative(parentDir, childPath);
|
|
226
|
+
return childRelativePath === "" || !childRelativePath.startsWith("..") && !isAbsolute(childRelativePath);
|
|
277
227
|
}
|
|
278
228
|
function isTextContentEligible(file, size) {
|
|
279
229
|
return SIGNATURE_TEXT_EXTENSIONS.has(extname(file).toLowerCase()) && size <= MAX_SIGNATURE_TEXT_BYTES;
|
|
@@ -281,7 +231,7 @@ function isTextContentEligible(file, size) {
|
|
|
281
231
|
function collectProjectSignatureFiles(projectDir, dir, files) {
|
|
282
232
|
let entries;
|
|
283
233
|
try {
|
|
284
|
-
entries =
|
|
234
|
+
entries = readdirSync(dir).sort();
|
|
285
235
|
} catch {
|
|
286
236
|
return;
|
|
287
237
|
}
|
|
@@ -334,7 +284,7 @@ function collectProjectSignatureManifestFiles(projectDir, files) {
|
|
|
334
284
|
function createProjectFingerprint(projectDir, files) {
|
|
335
285
|
const hash = createHash2("sha256");
|
|
336
286
|
for (const entry of files) {
|
|
337
|
-
hash.update(
|
|
287
|
+
hash.update(relative(projectDir, entry.file));
|
|
338
288
|
hash.update("\0");
|
|
339
289
|
hash.update(String(entry.size));
|
|
340
290
|
hash.update("\0");
|
|
@@ -356,7 +306,7 @@ function collectProjectFiles(normalizedProjectDir) {
|
|
|
356
306
|
function listProjectFiles(projectDir) {
|
|
357
307
|
const normalizedProjectDir = resolve(projectDir);
|
|
358
308
|
return collectProjectFiles(normalizedProjectDir).map((entry) => ({
|
|
359
|
-
path:
|
|
309
|
+
path: relative(normalizedProjectDir, entry.file).split(sep).join("/"),
|
|
360
310
|
size: entry.size,
|
|
361
311
|
mtimeMs: entry.mtimeMs,
|
|
362
312
|
ctimeMs: entry.ctimeMs
|
|
@@ -374,7 +324,7 @@ function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set(
|
|
|
374
324
|
const normalizedProjectDir = resolve(projectDir);
|
|
375
325
|
const collected = collectProjectFiles(normalizedProjectDir);
|
|
376
326
|
const files = collected.filter(
|
|
377
|
-
(entry) => !excluding.has(
|
|
327
|
+
(entry) => !excluding.has(relative(normalizedProjectDir, entry.file).split(sep).join("/"))
|
|
378
328
|
);
|
|
379
329
|
files.sort((a, b) => a.file.localeCompare(b.file));
|
|
380
330
|
const fingerprint = createProjectFingerprint(normalizedProjectDir, files);
|
|
@@ -383,7 +333,7 @@ function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set(
|
|
|
383
333
|
if (cached?.fingerprint === fingerprint) return cached.signature;
|
|
384
334
|
const hash = createHash2("sha256");
|
|
385
335
|
for (const entry of files) {
|
|
386
|
-
const relativePath =
|
|
336
|
+
const relativePath = relative(normalizedProjectDir, entry.file);
|
|
387
337
|
hash.update(relativePath);
|
|
388
338
|
hash.update("\0");
|
|
389
339
|
hash.update(String(entry.size));
|
|
@@ -410,7 +360,7 @@ async function filterCompositionFiles(projectDir, files) {
|
|
|
410
360
|
const checks = await Promise.all(
|
|
411
361
|
htmlFiles.map(async (f) => {
|
|
412
362
|
try {
|
|
413
|
-
const content = await readFile(
|
|
363
|
+
const content = await readFile(join2(projectDir, f), "utf-8");
|
|
414
364
|
return isCompositionSource(content);
|
|
415
365
|
} catch {
|
|
416
366
|
return false;
|
|
@@ -464,9 +414,9 @@ import {
|
|
|
464
414
|
statSync as statSync3,
|
|
465
415
|
fstatSync as fstatSync2,
|
|
466
416
|
renameSync as renameSync2,
|
|
467
|
-
readdirSync as
|
|
417
|
+
readdirSync as readdirSync3
|
|
468
418
|
} from "fs";
|
|
469
|
-
import { resolve as resolve3, dirname as
|
|
419
|
+
import { resolve as resolve3, dirname as dirname4, join as join6 } from "path";
|
|
470
420
|
|
|
471
421
|
// src/helpers/mime.ts
|
|
472
422
|
var MIME_TYPES = {
|
|
@@ -526,7 +476,7 @@ import {
|
|
|
526
476
|
rmSync,
|
|
527
477
|
statSync
|
|
528
478
|
} from "fs";
|
|
529
|
-
import { dirname as
|
|
479
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
530
480
|
import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
|
|
531
481
|
var SAMPLE_RATE = 4e3;
|
|
532
482
|
var PEAK_COUNT = 4e3;
|
|
@@ -593,11 +543,11 @@ function decodeAudioPeaks(audioPath) {
|
|
|
593
543
|
});
|
|
594
544
|
}
|
|
595
545
|
async function generateWaveformCache(projectDir, assetPath) {
|
|
596
|
-
const audioPath =
|
|
546
|
+
const audioPath = join3(projectDir, assetPath);
|
|
597
547
|
if (!existsSync(audioPath)) return;
|
|
598
548
|
const stats = statSync(audioPath);
|
|
599
|
-
const cacheDir =
|
|
600
|
-
const cachePath =
|
|
549
|
+
const cacheDir = join3(projectDir, ".waveform-cache");
|
|
550
|
+
const cachePath = join3(cacheDir, buildWaveformCacheKey(assetPath, stats));
|
|
601
551
|
if (isWaveformCacheDirectory(cacheDir) && existsSync(cachePath)) return;
|
|
602
552
|
const peaks = await decodeAudioPeaks(audioPath);
|
|
603
553
|
writeWaveformCache(cachePath, peaks);
|
|
@@ -606,7 +556,7 @@ function isWaveformCacheDirectory(cacheDir) {
|
|
|
606
556
|
return lstatSync2(cacheDir, { throwIfNoEntry: false })?.isDirectory() ?? false;
|
|
607
557
|
}
|
|
608
558
|
function writeWaveformCache(cachePath, peaks) {
|
|
609
|
-
const cacheDir =
|
|
559
|
+
const cacheDir = dirname2(cachePath);
|
|
610
560
|
try {
|
|
611
561
|
mkdirSync2(cacheDir, { mode: 448 });
|
|
612
562
|
} catch (error) {
|
|
@@ -615,9 +565,9 @@ function writeWaveformCache(cachePath, peaks) {
|
|
|
615
565
|
if (!isWaveformCacheDirectory(cacheDir)) {
|
|
616
566
|
throw new Error("Waveform cache must be a directory, not a symlink");
|
|
617
567
|
}
|
|
618
|
-
const stagingDir = mkdtempSync(
|
|
568
|
+
const stagingDir = mkdtempSync(join3(cacheDir, ".waveform-"));
|
|
619
569
|
try {
|
|
620
|
-
const stagingPath =
|
|
570
|
+
const stagingPath = join3(stagingDir, "peaks.json");
|
|
621
571
|
writeFileSync2(stagingPath, JSON.stringify(peaks), { flag: "wx" });
|
|
622
572
|
renameSync(stagingPath, cachePath);
|
|
623
573
|
} finally {
|
|
@@ -632,28 +582,42 @@ function writeWaveformCache(cachePath, peaks) {
|
|
|
632
582
|
import { spawnSync } from "child_process";
|
|
633
583
|
import { mkdtempSync as mkdtempSync2, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
634
584
|
import { tmpdir } from "os";
|
|
635
|
-
import { basename
|
|
585
|
+
import { basename, join as join4 } from "path";
|
|
586
|
+
import { FFPROBE_PATH_ENV, findFfBinary as findFfBinary2 } from "@hyperframes/parsers/ff-binaries";
|
|
636
587
|
var VIDEO_EXT = /\.(mp4|webm|mov|mkv|avi|m4v|mxf|mts|m2ts|ts)$/i;
|
|
637
588
|
var AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
|
|
638
|
-
|
|
589
|
+
var defaultRunner = spawnSync;
|
|
590
|
+
var FFPROBE_MISSING = "not checked: ffprobe was not found. Install FFmpeg or set HYPERFRAMES_FFPROBE_PATH.";
|
|
591
|
+
function validateUploadedMedia(filePath, runner = defaultRunner) {
|
|
639
592
|
const isVideo = VIDEO_EXT.test(filePath);
|
|
640
593
|
const isAudio = AUDIO_EXT.test(filePath);
|
|
641
594
|
if (!isVideo && !isAudio) {
|
|
642
595
|
return { ok: true };
|
|
643
596
|
}
|
|
597
|
+
const ffprobe = findFfBinary2("ffprobe") ?? (runner === defaultRunner ? void 0 : "ffprobe");
|
|
598
|
+
if (!ffprobe) return { ok: true, unchecked: FFPROBE_MISSING };
|
|
644
599
|
const result = runner(
|
|
645
|
-
|
|
600
|
+
ffprobe,
|
|
646
601
|
["-v", "error", "-show_entries", "stream=codec_type", "-of", "json", "--", filePath],
|
|
647
602
|
{ windowsHide: true }
|
|
648
603
|
);
|
|
604
|
+
if (result.error && process.env[FFPROBE_PATH_ENV]?.trim()) {
|
|
605
|
+
return {
|
|
606
|
+
ok: false,
|
|
607
|
+
reason: `${FFPROBE_PATH_ENV} names "${ffprobe}", which cannot run: fix it or unset it`
|
|
608
|
+
};
|
|
609
|
+
}
|
|
649
610
|
if (result.error?.code === "ENOENT") {
|
|
650
|
-
return { ok: true };
|
|
611
|
+
return { ok: true, unchecked: FFPROBE_MISSING };
|
|
651
612
|
}
|
|
652
613
|
if (result.status !== 0) {
|
|
653
614
|
return { ok: false, reason: "ffprobe failed to read the media file" };
|
|
654
615
|
}
|
|
616
|
+
return checkStreams(String(result.stdout || "{}"), isVideo, isAudio);
|
|
617
|
+
}
|
|
618
|
+
function checkStreams(stdout, isVideo, isAudio) {
|
|
655
619
|
try {
|
|
656
|
-
const parsed = JSON.parse(
|
|
620
|
+
const parsed = JSON.parse(stdout);
|
|
657
621
|
const streams = parsed.streams ?? [];
|
|
658
622
|
const hasVideo = streams.some((stream) => stream.codec_type === "video");
|
|
659
623
|
const hasAudio = streams.some((stream) => stream.codec_type === "audio");
|
|
@@ -668,9 +632,9 @@ function validateUploadedMedia(filePath, runner = spawnSync) {
|
|
|
668
632
|
return { ok: false, reason: "ffprobe returned unreadable media metadata" };
|
|
669
633
|
}
|
|
670
634
|
}
|
|
671
|
-
function validateUploadedMediaBuffer(fileName, buffer, runner =
|
|
672
|
-
const tempDir = mkdtempSync2(
|
|
673
|
-
const tempPath =
|
|
635
|
+
function validateUploadedMediaBuffer(fileName, buffer, runner = defaultRunner) {
|
|
636
|
+
const tempDir = mkdtempSync2(join4(tmpdir(), "hyperframes-upload-"));
|
|
637
|
+
const tempPath = join4(tempDir, basename(fileName));
|
|
674
638
|
try {
|
|
675
639
|
writeFileSync3(tempPath, buffer);
|
|
676
640
|
return validateUploadedMedia(tempPath, runner);
|
|
@@ -680,9 +644,9 @@ function validateUploadedMediaBuffer(fileName, buffer, runner = spawnSync) {
|
|
|
680
644
|
}
|
|
681
645
|
|
|
682
646
|
// src/helpers/backupJournal.ts
|
|
683
|
-
import { mkdirSync as mkdirSync3, readdirSync as
|
|
647
|
+
import { mkdirSync as mkdirSync3, readdirSync as readdirSync2, readFileSync as readFileSync3, unlinkSync, writeFileSync as writeFileSync4 } from "fs";
|
|
684
648
|
import { Buffer as Buffer2 } from "buffer";
|
|
685
|
-
import { join as
|
|
649
|
+
import { join as join5, relative as relative2 } from "path";
|
|
686
650
|
var DEFAULT_KEEP_PER_FILE = 10;
|
|
687
651
|
function backupKeyForPath(path) {
|
|
688
652
|
return Buffer2.from(path, "utf-8").toString("base64url");
|
|
@@ -692,7 +656,7 @@ function timestampPrefix() {
|
|
|
692
656
|
}
|
|
693
657
|
function backupPathForResponse(projectDir, backupPath) {
|
|
694
658
|
if (!backupPath) return null;
|
|
695
|
-
const rel =
|
|
659
|
+
const rel = relative2(projectDir, backupPath);
|
|
696
660
|
if (!rel || rel.startsWith("..")) return null;
|
|
697
661
|
return rel.split("\\").join("/");
|
|
698
662
|
}
|
|
@@ -700,8 +664,8 @@ function snapshotBeforeWrite(projectDir, absPath, options = {}) {
|
|
|
700
664
|
if (!isSafePath(projectDir, absPath)) return { backupPath: null };
|
|
701
665
|
try {
|
|
702
666
|
const content = readFileSync3(absPath);
|
|
703
|
-
const relativePath =
|
|
704
|
-
const backupDir =
|
|
667
|
+
const relativePath = relative2(projectDir, absPath);
|
|
668
|
+
const backupDir = join5(projectDir, ".hyperframes", "backup");
|
|
705
669
|
mkdirSync3(backupDir, { recursive: true });
|
|
706
670
|
const backupKey = backupKeyForPath(relativePath);
|
|
707
671
|
const backupPath = nextBackupPath(backupDir, backupKey);
|
|
@@ -717,7 +681,7 @@ function snapshotBeforeWrite(projectDir, absPath, options = {}) {
|
|
|
717
681
|
}
|
|
718
682
|
function nextBackupPath(backupDir, backupKey) {
|
|
719
683
|
const base = `${timestampPrefix()}-${backupKey}`;
|
|
720
|
-
let candidate =
|
|
684
|
+
let candidate = join5(backupDir, base);
|
|
721
685
|
let counter = 2;
|
|
722
686
|
while (true) {
|
|
723
687
|
try {
|
|
@@ -728,7 +692,7 @@ function nextBackupPath(backupDir, backupKey) {
|
|
|
728
692
|
}
|
|
729
693
|
throw error;
|
|
730
694
|
}
|
|
731
|
-
candidate =
|
|
695
|
+
candidate = join5(backupDir, `${base}-${counter}`);
|
|
732
696
|
counter += 1;
|
|
733
697
|
}
|
|
734
698
|
}
|
|
@@ -736,7 +700,7 @@ function pruneBackups(backupDir, backupKey, keepPerFile) {
|
|
|
736
700
|
const keep = Math.max(1, Math.floor(keepPerFile));
|
|
737
701
|
const suffix = `-${backupKey}`;
|
|
738
702
|
const numberedSuffix = new RegExp(`-${backupKey}-\\d+$`);
|
|
739
|
-
const matches =
|
|
703
|
+
const matches = readdirSync2(backupDir).filter((name) => name.endsWith(suffix) || numberedSuffix.test(name)).map((name) => join5(backupDir, name)).sort((a, b) => {
|
|
740
704
|
return b.localeCompare(a);
|
|
741
705
|
});
|
|
742
706
|
for (const file of matches.slice(keep)) {
|
|
@@ -937,9 +901,9 @@ import { parseHTML as parseHTML2 } from "linkedom";
|
|
|
937
901
|
import { ensureHfIds as ensureHfIds2 } from "@hyperframes/parsers/hf-ids";
|
|
938
902
|
|
|
939
903
|
// src/helpers/compositionInsertion.ts
|
|
940
|
-
import { existsSync as existsSync2, readFileSync as readFileSync5
|
|
904
|
+
import { existsSync as existsSync2, readFileSync as readFileSync5 } from "fs";
|
|
941
905
|
import { randomUUID as randomUUID3 } from "crypto";
|
|
942
|
-
import { dirname as
|
|
906
|
+
import { dirname as dirname3, relative as relative3, resolve as resolve2, sep as sep2 } from "path";
|
|
943
907
|
import { parseHTML } from "linkedom";
|
|
944
908
|
var CompositionInsertionError = class extends Error {
|
|
945
909
|
constructor(message, status) {
|
|
@@ -975,8 +939,8 @@ function canonicalProjectPath(projectDir, candidate) {
|
|
|
975
939
|
if (!existsSync2(candidate)) {
|
|
976
940
|
throw new CompositionInsertionError("Composition source was not found", 404);
|
|
977
941
|
}
|
|
978
|
-
const canonical =
|
|
979
|
-
if (!isSafePath(
|
|
942
|
+
const canonical = realpath(candidate);
|
|
943
|
+
if (!isSafePath(realpath(projectDir), canonical)) {
|
|
980
944
|
throw new CompositionInsertionError("Composition source escapes the project", 400);
|
|
981
945
|
}
|
|
982
946
|
return canonical;
|
|
@@ -988,13 +952,13 @@ function validateSourcePath(sourcePath) {
|
|
|
988
952
|
}
|
|
989
953
|
function canonicalProjectFile(projectDir, sourcePath) {
|
|
990
954
|
validateSourcePath(sourcePath);
|
|
991
|
-
return canonicalProjectPath(projectDir,
|
|
955
|
+
return canonicalProjectPath(projectDir, resolveWithinProject(projectDir, sourcePath));
|
|
992
956
|
}
|
|
993
957
|
function canonicalDependency(projectDir, ownerAbs, sourcePath) {
|
|
994
958
|
validateSourcePath(sourcePath);
|
|
995
959
|
return canonicalProjectPath(
|
|
996
960
|
projectDir,
|
|
997
|
-
|
|
961
|
+
resolveWithinProject(projectDir, relative3(projectDir, resolve2(dirname3(ownerAbs), sourcePath)))
|
|
998
962
|
);
|
|
999
963
|
}
|
|
1000
964
|
function validateDependencyGraph(projectDir, targetAbs, sourceAbs) {
|
|
@@ -1067,7 +1031,7 @@ function uniqueHostId(root, base) {
|
|
|
1067
1031
|
return `${base}_${suffix}`;
|
|
1068
1032
|
}
|
|
1069
1033
|
function relativeSourcePath(targetAbs, sourceAbs) {
|
|
1070
|
-
return
|
|
1034
|
+
return relative3(dirname3(targetAbs), sourceAbs).split(sep2).join("/");
|
|
1071
1035
|
}
|
|
1072
1036
|
function insertCompositionIntoSource(input) {
|
|
1073
1037
|
const targetAbs = canonicalProjectFile(input.projectDir, input.targetPath);
|
|
@@ -1199,7 +1163,7 @@ async function loadGsapParser() {
|
|
|
1199
1163
|
return import("@hyperframes/parsers/gsap-parser-recast");
|
|
1200
1164
|
}
|
|
1201
1165
|
function isDanglingSymlinkInProject(projectDir, lexicalPath) {
|
|
1202
|
-
if (!isSafePath(projectDir,
|
|
1166
|
+
if (!isSafePath(projectDir, dirname4(lexicalPath))) return false;
|
|
1203
1167
|
let stats;
|
|
1204
1168
|
try {
|
|
1205
1169
|
stats = lstatSync3(lexicalPath);
|
|
@@ -1207,7 +1171,7 @@ function isDanglingSymlinkInProject(projectDir, lexicalPath) {
|
|
|
1207
1171
|
return false;
|
|
1208
1172
|
}
|
|
1209
1173
|
if (!stats.isSymbolicLink()) return false;
|
|
1210
|
-
const target = resolve3(
|
|
1174
|
+
const target = resolve3(dirname4(lexicalPath), readlinkSync(lexicalPath));
|
|
1211
1175
|
if (!isSafePath(projectDir, target)) return false;
|
|
1212
1176
|
try {
|
|
1213
1177
|
statSync3(lexicalPath);
|
|
@@ -1231,7 +1195,7 @@ async function resolveProjectPath(c, adapter, route, opts) {
|
|
|
1231
1195
|
if (filePath.includes("\0")) {
|
|
1232
1196
|
return { error: c.json({ error: "forbidden", why: "nul" }, 403) };
|
|
1233
1197
|
}
|
|
1234
|
-
const absPath = (opts?.pin ? pinWithinProject :
|
|
1198
|
+
const absPath = (opts?.pin ? pinWithinProject : resolveWithinProject)(project.dir, filePath);
|
|
1235
1199
|
if (!absPath) {
|
|
1236
1200
|
if (isDanglingSymlinkInProject(project.dir, resolve3(project.dir, filePath))) {
|
|
1237
1201
|
return { error: c.json({ error: "not found", why: "dangling_symlink" }, 404) };
|
|
@@ -1430,7 +1394,7 @@ async function parseMutationBody(c) {
|
|
|
1430
1394
|
return { target: body.target, body };
|
|
1431
1395
|
}
|
|
1432
1396
|
function ensureDir(filePath) {
|
|
1433
|
-
const dir =
|
|
1397
|
+
const dir = dirname4(filePath);
|
|
1434
1398
|
if (!existsSync3(dir)) mkdirSync4(dir, { recursive: true });
|
|
1435
1399
|
}
|
|
1436
1400
|
function generateCopyPath(projectDir, originalPath) {
|
|
@@ -1448,8 +1412,8 @@ function generateCopyPath(projectDir, originalPath) {
|
|
|
1448
1412
|
}
|
|
1449
1413
|
function walkFiles(dir, filter) {
|
|
1450
1414
|
const results = [];
|
|
1451
|
-
for (const entry of
|
|
1452
|
-
const full =
|
|
1415
|
+
for (const entry of readdirSync3(dir, { withFileTypes: true })) {
|
|
1416
|
+
const full = join6(dir, entry.name);
|
|
1453
1417
|
if (entry.isDirectory()) {
|
|
1454
1418
|
if (entry.name === "node_modules" || entry.name === ".thumbnails" || entry.name === "renders" || entry.name === ".transcode-cache")
|
|
1455
1419
|
continue;
|
|
@@ -2552,6 +2516,7 @@ async function processUploadedFiles(formData, targetDir, projectDir) {
|
|
|
2552
2516
|
const uploaded = [];
|
|
2553
2517
|
const skipped = [];
|
|
2554
2518
|
const invalid = [];
|
|
2519
|
+
const unchecked = [];
|
|
2555
2520
|
const entries = formData.entries();
|
|
2556
2521
|
const subDir = targetDir === projectDir ? "" : targetDir.slice(projectDir.length + 1);
|
|
2557
2522
|
for (const [, value] of entries) {
|
|
@@ -2612,14 +2577,15 @@ async function processUploadedFiles(formData, targetDir, projectDir) {
|
|
|
2612
2577
|
if (n >= MAX_COPY_INDEX) skipped.push(name);
|
|
2613
2578
|
continue;
|
|
2614
2579
|
}
|
|
2615
|
-
const relativePath = subDir ?
|
|
2580
|
+
const relativePath = subDir ? join6(subDir, finalName) : finalName;
|
|
2616
2581
|
uploaded.push(relativePath);
|
|
2582
|
+
if (validation.unchecked) unchecked.push({ name: finalName, reason: validation.unchecked });
|
|
2617
2583
|
if (isAudioFile(finalName)) {
|
|
2618
2584
|
generateWaveformCache(projectDir, relativePath).catch(() => {
|
|
2619
2585
|
});
|
|
2620
2586
|
}
|
|
2621
2587
|
}
|
|
2622
|
-
return { uploaded, skipped, invalid };
|
|
2588
|
+
return { uploaded, skipped, invalid, unchecked };
|
|
2623
2589
|
}
|
|
2624
2590
|
function registerFileRoutes(api, adapter) {
|
|
2625
2591
|
api.get("/projects/:id/files/*", async (c) => {
|
|
@@ -3295,7 +3261,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
3295
3261
|
if (!body.newPath || body.newPath.includes("\0")) {
|
|
3296
3262
|
return c.json({ error: "newPath required" }, 400);
|
|
3297
3263
|
}
|
|
3298
|
-
const newAbs =
|
|
3264
|
+
const newAbs = resolveWithinProject(res.project.dir, body.newPath);
|
|
3299
3265
|
if (!newAbs) {
|
|
3300
3266
|
return c.json({ error: "forbidden" }, 403);
|
|
3301
3267
|
}
|
|
@@ -3314,12 +3280,12 @@ function registerFileRoutes(api, adapter) {
|
|
|
3314
3280
|
if (!body.path || body.path.includes("\0")) {
|
|
3315
3281
|
return c.json({ error: "path required" }, 400);
|
|
3316
3282
|
}
|
|
3317
|
-
const srcAbs =
|
|
3283
|
+
const srcAbs = resolveWithinProject(project.dir, body.path);
|
|
3318
3284
|
if (!srcAbs || !existsSync3(srcAbs)) {
|
|
3319
3285
|
return c.json({ error: "not found" }, 404);
|
|
3320
3286
|
}
|
|
3321
3287
|
const copyPath = generateCopyPath(project.dir, body.path);
|
|
3322
|
-
const destAbs =
|
|
3288
|
+
const destAbs = resolveWithinProject(project.dir, copyPath);
|
|
3323
3289
|
if (!destAbs) {
|
|
3324
3290
|
return c.json({ error: "forbidden" }, 403);
|
|
3325
3291
|
}
|
|
@@ -3345,13 +3311,19 @@ function registerFileRoutes(api, adapter) {
|
|
|
3345
3311
|
const project = await adapter.resolveProject(c.req.param("id"));
|
|
3346
3312
|
if (!project) return c.json({ error: "not found" }, 404);
|
|
3347
3313
|
const subDir = c.req.query("dir") ?? "";
|
|
3348
|
-
const targetDir = subDir ?
|
|
3314
|
+
const targetDir = subDir ? resolveWithinProject(project.dir, subDir) : project.dir;
|
|
3349
3315
|
if (!targetDir) return c.json({ error: "forbidden" }, 403);
|
|
3350
3316
|
if (subDir && !existsSync3(targetDir)) mkdirSync4(targetDir, { recursive: true });
|
|
3351
3317
|
const formData = await c.req.formData();
|
|
3352
3318
|
const result = await processUploadedFiles(formData, targetDir, project.dir);
|
|
3353
3319
|
return c.json(
|
|
3354
|
-
{
|
|
3320
|
+
{
|
|
3321
|
+
ok: true,
|
|
3322
|
+
files: result.uploaded,
|
|
3323
|
+
skipped: result.skipped,
|
|
3324
|
+
invalid: result.invalid,
|
|
3325
|
+
unchecked: result.unchecked
|
|
3326
|
+
},
|
|
3355
3327
|
201
|
|
3356
3328
|
);
|
|
3357
3329
|
}
|
|
@@ -3429,7 +3401,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
3429
3401
|
// src/routes/preview.ts
|
|
3430
3402
|
import { createReadStream, existsSync as existsSync5, readFileSync as readFileSync8, statSync as statSync4 } from "fs";
|
|
3431
3403
|
import { Readable } from "stream";
|
|
3432
|
-
import { join as
|
|
3404
|
+
import { join as join9, resolve as resolve5 } from "path";
|
|
3433
3405
|
import { createHash as createHash4 } from "crypto";
|
|
3434
3406
|
import {
|
|
3435
3407
|
addScenePartsManifest,
|
|
@@ -3437,11 +3409,13 @@ import {
|
|
|
3437
3409
|
insertBeforeCloseTag as insertBeforeCloseTag2,
|
|
3438
3410
|
stripEmbeddedRuntimeScripts as stripEmbeddedRuntimeScripts2
|
|
3439
3411
|
} from "@hyperframes/core/compiler";
|
|
3412
|
+
import { STUDIO_PREVIEW_MARK_META } from "@hyperframes/core/studio-preview-mark";
|
|
3413
|
+
import { injectTagsAtHeadStart } from "@hyperframes/core/compiler/html-document";
|
|
3440
3414
|
import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
|
|
3441
3415
|
|
|
3442
3416
|
// src/helpers/subComposition.ts
|
|
3443
3417
|
import { existsSync as existsSync4, readFileSync as readFileSync7 } from "fs";
|
|
3444
|
-
import { join as
|
|
3418
|
+
import { join as join7 } from "path";
|
|
3445
3419
|
import { parseHTML as parseHTML3 } from "linkedom";
|
|
3446
3420
|
import {
|
|
3447
3421
|
rewriteAssetPaths,
|
|
@@ -3451,7 +3425,7 @@ import {
|
|
|
3451
3425
|
import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler";
|
|
3452
3426
|
import { isFullHtmlDocument } from "@hyperframes/core/compiler/html-document";
|
|
3453
3427
|
function rewriteRelativePaths(root, compPath, projectDir) {
|
|
3454
|
-
const assetExists = (path) => existsSync4(
|
|
3428
|
+
const assetExists = (path) => existsSync4(join7(projectDir, path));
|
|
3455
3429
|
rewriteAssetPaths(
|
|
3456
3430
|
root.querySelectorAll("[src], [href]"),
|
|
3457
3431
|
compPath,
|
|
@@ -3548,7 +3522,7 @@ function tagRootCompositionFile(bodyHtml, compPath) {
|
|
|
3548
3522
|
return bodyHtml.slice(0, tagEnd) + ` data-composition-file="${compPath}"` + bodyHtml.slice(tagEnd);
|
|
3549
3523
|
}
|
|
3550
3524
|
function buildSubCompositionHtml(projectDir, compPath, runtimeUrl, baseHref, rawOverride) {
|
|
3551
|
-
const compFile =
|
|
3525
|
+
const compFile = join7(projectDir, compPath);
|
|
3552
3526
|
if (!existsSync4(compFile)) return null;
|
|
3553
3527
|
const rawComp = rawOverride ?? readFileSync7(compFile, "utf-8");
|
|
3554
3528
|
let compHeadContent = "";
|
|
@@ -3580,7 +3554,7 @@ function buildSubCompositionHtml(projectDir, compPath, runtimeUrl, baseHref, raw
|
|
|
3580
3554
|
}
|
|
3581
3555
|
rewrittenContent = stripEmbeddedRuntimeScripts(rewrittenContent);
|
|
3582
3556
|
rewrittenContent = tagRootCompositionFile(rewrittenContent, compPath);
|
|
3583
|
-
const indexPath =
|
|
3557
|
+
const indexPath = join7(projectDir, "index.html");
|
|
3584
3558
|
let headContent = "";
|
|
3585
3559
|
if (existsSync4(indexPath)) {
|
|
3586
3560
|
const indexHtml = readFileSync7(indexPath, "utf-8");
|
|
@@ -3624,14 +3598,14 @@ import { ensureHfIds as ensureHfIds3 } from "@hyperframes/parsers/hf-ids";
|
|
|
3624
3598
|
|
|
3625
3599
|
// src/helpers/previewReads.ts
|
|
3626
3600
|
import { readlinkSync as readlinkSync2 } from "fs";
|
|
3627
|
-
import { dirname as
|
|
3601
|
+
import { dirname as dirname5, join as join8, parse, relative as relative4, resolve as resolve4 } from "path";
|
|
3628
3602
|
var ALWAYS_AFFECTS = ["hyperframes.json", ...STUDIO_SIGNATURE_MANIFEST_PATHS];
|
|
3629
3603
|
var REFERENCE = /\b(?:src|href|poster|data-composition-src)\s*=\s*(?:"([^"\n]*)"|'([^'\n]*)')|url\(\s*(?:"([^"\n]*)"|'([^'\n]*)'|([^"'\s)]+))/gi;
|
|
3630
3604
|
var pathKey = process.platform === "darwin" || process.platform === "win32" ? (path) => resolve4(path).toLowerCase() : (path) => resolve4(path);
|
|
3631
3605
|
var readsByProject = /* @__PURE__ */ new Map();
|
|
3632
3606
|
var builtProjects = /* @__PURE__ */ new Set();
|
|
3633
3607
|
var projectKey = (projectDir) => pathKey(realFilePath(resolve4(projectDir)));
|
|
3634
|
-
var inRealProject = (projectDir, path) =>
|
|
3608
|
+
var inRealProject = (projectDir, path) => join8(realFilePath(resolve4(projectDir)), relative4(resolve4(projectDir), resolve4(projectDir, path)));
|
|
3635
3609
|
var readLink = (path) => {
|
|
3636
3610
|
try {
|
|
3637
3611
|
return readlinkSync2(path);
|
|
@@ -3647,10 +3621,10 @@ function linksOnTheWay(path) {
|
|
|
3647
3621
|
const part = rest.shift();
|
|
3648
3622
|
if (!part || part === ".") continue;
|
|
3649
3623
|
if (part === "..") {
|
|
3650
|
-
at =
|
|
3624
|
+
at = dirname5(at);
|
|
3651
3625
|
continue;
|
|
3652
3626
|
}
|
|
3653
|
-
const next =
|
|
3627
|
+
const next = join8(at, part);
|
|
3654
3628
|
const target = hops < 40 ? readLink(next) : null;
|
|
3655
3629
|
if (target === null) {
|
|
3656
3630
|
at = next;
|
|
@@ -3671,7 +3645,7 @@ function recordPreviewRead(projectDir, filePath) {
|
|
|
3671
3645
|
if (!reads) readsByProject.set(key, reads = /* @__PURE__ */ new Set());
|
|
3672
3646
|
const read = inRealProject(projectDir, filePath);
|
|
3673
3647
|
for (const found of [read, ...linksOnTheWay(read)]) {
|
|
3674
|
-
for (let path = pathKey(found); !reads.has(path); path =
|
|
3648
|
+
for (let path = pathKey(found); !reads.has(path); path = dirname5(path)) reads.add(path);
|
|
3675
3649
|
}
|
|
3676
3650
|
}
|
|
3677
3651
|
function recordPreviewReferences(projectDir, html) {
|
|
@@ -3721,6 +3695,34 @@ function injectPreviewVariables(html, values) {
|
|
|
3721
3695
|
return tag + html;
|
|
3722
3696
|
}
|
|
3723
3697
|
|
|
3698
|
+
// src/helpers/lazyPreviewImages.ts
|
|
3699
|
+
import { findStartTags } from "@hyperframes/core/compiler/html-document";
|
|
3700
|
+
import { parseHTML as parseHTML4 } from "linkedom";
|
|
3701
|
+
import { STUDIO_PREVIEW_LAZY_ATTR } from "@hyperframes/core/studio-preview-mark";
|
|
3702
|
+
function startsAfterZero(el) {
|
|
3703
|
+
for (let node = el; node; node = node.parentElement) {
|
|
3704
|
+
if (Number(node.getAttribute("data-start")) > 0) return true;
|
|
3705
|
+
}
|
|
3706
|
+
return false;
|
|
3707
|
+
}
|
|
3708
|
+
var hasLoading = (img) => Array.from(img.attributes).some((attr) => attr.name.toLowerCase() === "loading");
|
|
3709
|
+
function lazyPreviewImages(html) {
|
|
3710
|
+
if (!/<!doctype|<html[\s>]/i.test(html)) return html;
|
|
3711
|
+
const images = [...parseHTML4(html).document.querySelectorAll("img")];
|
|
3712
|
+
const tags = findStartTags(html, "img");
|
|
3713
|
+
if (tags.length !== images.length) return html;
|
|
3714
|
+
const parts = [];
|
|
3715
|
+
let from = 0;
|
|
3716
|
+
images.forEach((img, i) => {
|
|
3717
|
+
if (hasLoading(img) || !startsAfterZero(img)) return;
|
|
3718
|
+
const at = (tags[i] ?? 0) + 4;
|
|
3719
|
+
parts.push(html.slice(from, at), ` loading="lazy" ${STUDIO_PREVIEW_LAZY_ATTR}`);
|
|
3720
|
+
from = at;
|
|
3721
|
+
});
|
|
3722
|
+
parts.push(html.slice(from));
|
|
3723
|
+
return parts.join("");
|
|
3724
|
+
}
|
|
3725
|
+
|
|
3724
3726
|
// src/routes/preview.ts
|
|
3725
3727
|
var PROJECT_SIGNATURE_META = "hyperframes-project-signature";
|
|
3726
3728
|
var GSAP_CDN_VERSION = "3.15.0";
|
|
@@ -3740,7 +3742,7 @@ function injectProjectSignature(html, signature) {
|
|
|
3740
3742
|
${html}`;
|
|
3741
3743
|
}
|
|
3742
3744
|
function readStudioMotionManifestContent(projectDir) {
|
|
3743
|
-
const manifestPath =
|
|
3745
|
+
const manifestPath = join9(projectDir, STUDIO_MOTION_PATH);
|
|
3744
3746
|
if (!existsSync5(manifestPath)) return "";
|
|
3745
3747
|
try {
|
|
3746
3748
|
return readFileSync8(manifestPath, "utf-8");
|
|
@@ -3813,7 +3815,8 @@ function injectStudioMotionScript(html, projectDir, activeCompositionPath) {
|
|
|
3813
3815
|
false
|
|
3814
3816
|
);
|
|
3815
3817
|
}
|
|
3816
|
-
var
|
|
3818
|
+
var GSAP_FALLBACK_ATTR = "data-hf-gsap-fallback";
|
|
3819
|
+
var GSAP_CDN_FALLBACK_SCRIPT = `<script ${GSAP_FALLBACK_ATTR}>
|
|
3817
3820
|
(function(){
|
|
3818
3821
|
var cdnBase="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/";
|
|
3819
3822
|
var loaded={};
|
|
@@ -3834,7 +3837,7 @@ var GSAP_CDN_FALLBACK_SCRIPT = `<script data-hf-gsap-fallback>
|
|
|
3834
3837
|
})();
|
|
3835
3838
|
</script>`;
|
|
3836
3839
|
function injectGsapCdnFallback(html) {
|
|
3837
|
-
if (html.includes(
|
|
3840
|
+
if (html.includes(GSAP_FALLBACK_ATTR)) return html;
|
|
3838
3841
|
if (html.includes("<head>")) return html.replace("<head>", "<head>" + GSAP_CDN_FALLBACK_SCRIPT);
|
|
3839
3842
|
return GSAP_CDN_FALLBACK_SCRIPT + html;
|
|
3840
3843
|
}
|
|
@@ -3860,11 +3863,13 @@ function previewVariablesFromRequest(rawVariables) {
|
|
|
3860
3863
|
if (!parse2.ok) return { error: parse2.error };
|
|
3861
3864
|
return { raw: rawVariables, values: parse2.values };
|
|
3862
3865
|
}
|
|
3863
|
-
|
|
3866
|
+
var PREVIEW_CAPTURE_PARAM = "hf-capture";
|
|
3867
|
+
function injectStudioPreviewAugmentations(html, adapter, projectDir, activeCompositionPath, capture) {
|
|
3868
|
+
const marked = capture ? html : injectTagsAtHeadStart(lazyPreviewImages(html), `<meta name="${STUDIO_PREVIEW_MARK_META}">`);
|
|
3864
3869
|
return injectStudioMotionScript(
|
|
3865
3870
|
injectMotionPathPluginIfNeeded(
|
|
3866
3871
|
injectGsapCdnFallback(
|
|
3867
|
-
injectProjectSignature(
|
|
3872
|
+
injectProjectSignature(marked, resolveProjectSignature(adapter, projectDir))
|
|
3868
3873
|
)
|
|
3869
3874
|
),
|
|
3870
3875
|
projectDir,
|
|
@@ -3885,8 +3890,8 @@ async function transformPreviewHtml(html, adapter, project, activeCompositionPat
|
|
|
3885
3890
|
}
|
|
3886
3891
|
}
|
|
3887
3892
|
function resolveProjectMainHtml(projectDir, projectId) {
|
|
3888
|
-
const indexPath =
|
|
3889
|
-
const blockHtmlPath =
|
|
3893
|
+
const indexPath = join9(projectDir, "index.html");
|
|
3894
|
+
const blockHtmlPath = join9(projectDir, `${projectId}.html`);
|
|
3890
3895
|
recordPreviewRead(projectDir, indexPath);
|
|
3891
3896
|
recordPreviewRead(projectDir, blockHtmlPath);
|
|
3892
3897
|
if (existsSync5(indexPath)) {
|
|
@@ -3922,7 +3927,7 @@ function registerPreviewRoutes(api, adapter) {
|
|
|
3922
3927
|
if (builtPreviews.size > 4) builtPreviews.delete(builtPreviews.keys().next().value);
|
|
3923
3928
|
};
|
|
3924
3929
|
const previewBuilds = /* @__PURE__ */ new Map();
|
|
3925
|
-
async function buildPreview(project, previewVariables, builtKey) {
|
|
3930
|
+
async function buildPreview(project, previewVariables, builtKey, capture) {
|
|
3926
3931
|
const diskMain = resolveProjectMainHtml(project.dir, project.id);
|
|
3927
3932
|
const normalizedDisk = diskMain ? ensureHfIds3(diskMain.html) : null;
|
|
3928
3933
|
try {
|
|
@@ -3952,7 +3957,8 @@ ${runtimeTag}`;
|
|
|
3952
3957
|
ensureHfIds3(await transformPreviewHtml(bundled, adapter, project, mainCompositionPath)),
|
|
3953
3958
|
adapter,
|
|
3954
3959
|
project.dir,
|
|
3955
|
-
mainCompositionPath
|
|
3960
|
+
mainCompositionPath,
|
|
3961
|
+
capture
|
|
3956
3962
|
);
|
|
3957
3963
|
if (previewVariables) bundled = injectPreviewVariables(bundled, previewVariables);
|
|
3958
3964
|
bundled = await injectMediaCodecMap(
|
|
@@ -3964,7 +3970,7 @@ ${runtimeTag}`;
|
|
|
3964
3970
|
);
|
|
3965
3971
|
bundled = addScenePartsManifest(bundled, [`meta[name="${PROJECT_SIGNATURE_META}"]`]);
|
|
3966
3972
|
rememberPreview(builtKey, bundled);
|
|
3967
|
-
adapter.previewDocuments?.write(builtKey, bundled);
|
|
3973
|
+
if (!capture) adapter.previewDocuments?.write(builtKey, bundled);
|
|
3968
3974
|
return bundled;
|
|
3969
3975
|
} catch {
|
|
3970
3976
|
const fallback = resolveProjectMainHtml(project.dir, project.id);
|
|
@@ -3974,7 +3980,8 @@ ${runtimeTag}`;
|
|
|
3974
3980
|
await transformPreviewHtml(fallbackHtml, adapter, project, fallback.compositionPath),
|
|
3975
3981
|
adapter,
|
|
3976
3982
|
project.dir,
|
|
3977
|
-
fallback.compositionPath
|
|
3983
|
+
fallback.compositionPath,
|
|
3984
|
+
capture
|
|
3978
3985
|
);
|
|
3979
3986
|
if (previewVariables) {
|
|
3980
3987
|
fallbackAugmented = injectPreviewVariables(fallbackAugmented, previewVariables);
|
|
@@ -3998,7 +4005,8 @@ ${runtimeTag}`;
|
|
|
3998
4005
|
const vars = previewVariablesFromRequest(c.req.query("variables"));
|
|
3999
4006
|
if (vars.error !== void 0) return c.json({ error: vars.error }, 400);
|
|
4000
4007
|
const previewVariables = vars.values;
|
|
4001
|
-
const
|
|
4008
|
+
const capture = c.req.query(PREVIEW_CAPTURE_PARAM) !== void 0;
|
|
4009
|
+
const etag = `"preview:${signature}${variablesEtagSalt(vars.raw)}${capture ? ":capture" : ""}"`;
|
|
4002
4010
|
const ifNoneMatch = c.req.header("If-None-Match");
|
|
4003
4011
|
if (ifNoneMatch === etag) {
|
|
4004
4012
|
return new Response(null, {
|
|
@@ -4015,7 +4023,7 @@ ${etag}`;
|
|
|
4015
4023
|
}
|
|
4016
4024
|
let pending = previewBuilds.get(builtKey);
|
|
4017
4025
|
if (!pending) {
|
|
4018
|
-
pending = buildPreview(project, previewVariables, builtKey).finally(
|
|
4026
|
+
pending = buildPreview(project, previewVariables, builtKey, capture).finally(
|
|
4019
4027
|
() => previewBuilds.delete(builtKey)
|
|
4020
4028
|
);
|
|
4021
4029
|
previewBuilds.set(builtKey, pending);
|
|
@@ -4040,14 +4048,15 @@ ${etag}`;
|
|
|
4040
4048
|
if (vars.error !== void 0) return c.json({ error: vars.error }, 400);
|
|
4041
4049
|
const previewVariables = vars.values;
|
|
4042
4050
|
const compPath = requestSubPath(c.req.url, "projects/:id/preview/comp");
|
|
4043
|
-
const compFile =
|
|
4051
|
+
const compFile = resolveWithinProject(project.dir, compPath);
|
|
4044
4052
|
if (compFile) recordPreviewRead(project.dir, compFile);
|
|
4045
4053
|
recordPreviewRead(project.dir, "index.html");
|
|
4046
4054
|
if (!compFile || !existsSync5(compFile) || !statSync4(compFile).isFile()) {
|
|
4047
4055
|
return c.text("not found", 404);
|
|
4048
4056
|
}
|
|
4049
4057
|
const compPathHash = createHash4("sha1").update(compPath).digest("hex");
|
|
4050
|
-
const
|
|
4058
|
+
const capture = c.req.query(PREVIEW_CAPTURE_PARAM) !== void 0;
|
|
4059
|
+
const etag = `"comp:v2:${compPathHash}:${signature}${variablesEtagSalt(vars.raw)}${capture ? ":capture" : ""}"`;
|
|
4051
4060
|
const ifNoneMatch = c.req.header("If-None-Match");
|
|
4052
4061
|
if (ifNoneMatch === etag) {
|
|
4053
4062
|
return new Response(null, {
|
|
@@ -4068,7 +4077,7 @@ ${etag}`;
|
|
|
4068
4077
|
if (!html) return c.text("not found", 404);
|
|
4069
4078
|
recordPreviewReferences(project.dir, html);
|
|
4070
4079
|
html = ensureHfIds3(await transformPreviewHtml(html, adapter, project, compPath));
|
|
4071
|
-
html = injectStudioPreviewAugmentations(html, adapter, project.dir, compPath);
|
|
4080
|
+
html = injectStudioPreviewAugmentations(html, adapter, project.dir, compPath, capture);
|
|
4072
4081
|
if (previewVariables) html = injectPreviewVariables(html, previewVariables);
|
|
4073
4082
|
html = await injectMediaCodecMap(html, adapter, project.dir, compPath, mediaCodecProbeCache);
|
|
4074
4083
|
return c.html(html, 200, previewCacheHeaders(etag));
|
|
@@ -4175,7 +4184,7 @@ ${etag}`;
|
|
|
4175
4184
|
|
|
4176
4185
|
// src/routes/lint.ts
|
|
4177
4186
|
import { readFileSync as readFileSync9 } from "fs";
|
|
4178
|
-
import { join as
|
|
4187
|
+
import { join as join10 } from "path";
|
|
4179
4188
|
async function collectProjectFindings(adapter, projectDir) {
|
|
4180
4189
|
const findings = [];
|
|
4181
4190
|
const coveredFiles = /* @__PURE__ */ new Set();
|
|
@@ -4193,7 +4202,7 @@ async function lintUncoveredHtml(adapter, projectDir, htmlFiles, coveredFiles) {
|
|
|
4193
4202
|
const findings = [];
|
|
4194
4203
|
for (const file of htmlFiles) {
|
|
4195
4204
|
if (coveredFiles.has(file)) continue;
|
|
4196
|
-
const content = readFileSync9(
|
|
4205
|
+
const content = readFileSync9(join10(projectDir, file), "utf-8");
|
|
4197
4206
|
const result = await adapter.lint(content, { filePath: file, isSubComposition: true });
|
|
4198
4207
|
for (const finding of result?.findings ?? []) {
|
|
4199
4208
|
findings.push({ ...finding, file });
|
|
@@ -4226,8 +4235,8 @@ function registerLintRoutes(api, adapter) {
|
|
|
4226
4235
|
|
|
4227
4236
|
// src/routes/render.ts
|
|
4228
4237
|
import { streamSSE } from "hono/streaming";
|
|
4229
|
-
import { existsSync as existsSync6, readFileSync as readFileSync10, mkdirSync as mkdirSync5, unlinkSync as unlinkSync3, readdirSync as
|
|
4230
|
-
import { join as
|
|
4238
|
+
import { existsSync as existsSync6, readFileSync as readFileSync10, mkdirSync as mkdirSync5, unlinkSync as unlinkSync3, readdirSync as readdirSync4, statSync as statSync5 } from "fs";
|
|
4239
|
+
import { join as join11 } from "path";
|
|
4231
4240
|
import { VALID_CANVAS_RESOLUTIONS } from "@hyperframes/parsers";
|
|
4232
4241
|
import { formatRenderOutputTimestamp, parseFps } from "@hyperframes/core";
|
|
4233
4242
|
var VALID_RESOLUTIONS = new Set(VALID_CANVAS_RESOLUTIONS);
|
|
@@ -4278,7 +4287,7 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4278
4287
|
const outputResolution = VALID_RESOLUTIONS.has(body.resolution ?? "") ? body.resolution : void 0;
|
|
4279
4288
|
let composition;
|
|
4280
4289
|
if (typeof body.composition === "string" && body.composition.length > 0) {
|
|
4281
|
-
if (!
|
|
4290
|
+
if (!resolveWithinProject(project.dir, body.composition)) {
|
|
4282
4291
|
return c.json({ error: "composition path must be within the project directory" }, 400);
|
|
4283
4292
|
}
|
|
4284
4293
|
composition = body.composition;
|
|
@@ -4295,7 +4304,7 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4295
4304
|
const rendersDir = adapter.rendersDir(project);
|
|
4296
4305
|
if (!existsSync6(rendersDir)) mkdirSync5(rendersDir, { recursive: true });
|
|
4297
4306
|
const ext = FORMAT_EXT[format] ?? ".mp4";
|
|
4298
|
-
const outputPath =
|
|
4307
|
+
const outputPath = join11(rendersDir, `${jobId}${ext}`);
|
|
4299
4308
|
const jobState = adapter.startRender({
|
|
4300
4309
|
project,
|
|
4301
4310
|
outputPath,
|
|
@@ -4396,7 +4405,7 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4396
4405
|
if (state.id === jobId && state.outputPath) {
|
|
4397
4406
|
const dir = state.outputPath.replace(/\/[^/]+$/, "");
|
|
4398
4407
|
for (const ext of [".mp4", ".webm", ".mov", ".meta.json"]) {
|
|
4399
|
-
const fp =
|
|
4408
|
+
const fp = join11(dir, `${jobId}${ext}`);
|
|
4400
4409
|
if (existsSync6(fp)) unlinkSync3(fp);
|
|
4401
4410
|
}
|
|
4402
4411
|
break;
|
|
@@ -4411,7 +4420,7 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4411
4420
|
const filename = c.req.path.split("/renders/file/")[1];
|
|
4412
4421
|
if (!filename) return c.json({ error: "missing filename" }, 400);
|
|
4413
4422
|
const rendersDir = adapter.rendersDir(project);
|
|
4414
|
-
const fp =
|
|
4423
|
+
const fp = resolveWithinProject(rendersDir, filename);
|
|
4415
4424
|
if (!fp) return c.json({ error: "forbidden" }, 403);
|
|
4416
4425
|
if (!existsSync6(fp)) return c.json({ error: "not found" }, 404);
|
|
4417
4426
|
const contentType = renderContentType(fp);
|
|
@@ -4430,11 +4439,11 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4430
4439
|
if (!project) return c.json({ error: "not found" }, 404);
|
|
4431
4440
|
const rendersDir = adapter.rendersDir(project);
|
|
4432
4441
|
if (!existsSync6(rendersDir)) return c.json({ renders: [] });
|
|
4433
|
-
const files =
|
|
4434
|
-
const fp =
|
|
4442
|
+
const files = readdirSync4(rendersDir).filter((f) => f.endsWith(".mp4") || f.endsWith(".webm") || f.endsWith(".mov")).map((f) => {
|
|
4443
|
+
const fp = join11(rendersDir, f);
|
|
4435
4444
|
const stat3 = statSync5(fp);
|
|
4436
4445
|
const rid = f.replace(/\.(mp4|webm|mov)$/, "");
|
|
4437
|
-
const metaPath =
|
|
4446
|
+
const metaPath = join11(rendersDir, `${rid}.meta.json`);
|
|
4438
4447
|
let status = "complete";
|
|
4439
4448
|
let durationMs;
|
|
4440
4449
|
if (existsSync6(metaPath)) {
|
|
@@ -4460,7 +4469,7 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4460
4469
|
id: file.id,
|
|
4461
4470
|
status: file.status,
|
|
4462
4471
|
progress: 100,
|
|
4463
|
-
outputPath:
|
|
4472
|
+
outputPath: join11(rendersDir, file.filename),
|
|
4464
4473
|
createdAt: file.createdAt
|
|
4465
4474
|
});
|
|
4466
4475
|
}
|
|
@@ -4608,14 +4617,14 @@ async function generateThumbnail(path, signal) {
|
|
|
4608
4617
|
async function resolveSource(adapter, id, url) {
|
|
4609
4618
|
const project = await adapter.resolveProject(id);
|
|
4610
4619
|
if (!project) throw new HTTPException(404);
|
|
4611
|
-
let
|
|
4620
|
+
let relative6;
|
|
4612
4621
|
try {
|
|
4613
|
-
|
|
4622
|
+
relative6 = requestSubPath(url, "projects/:id/image-thumbnail");
|
|
4614
4623
|
} catch {
|
|
4615
4624
|
throw new HTTPException(400);
|
|
4616
4625
|
}
|
|
4617
|
-
if (!/\.jpe?g$/i.test(
|
|
4618
|
-
const path = pinWithinProject(project.dir,
|
|
4626
|
+
if (!/\.jpe?g$/i.test(relative6)) throw new HTTPException(415);
|
|
4627
|
+
const path = pinWithinProject(project.dir, relative6);
|
|
4619
4628
|
if (!path) throw new HTTPException(404);
|
|
4620
4629
|
const info = await stat(path).catch(() => {
|
|
4621
4630
|
throw new HTTPException(404);
|
|
@@ -4676,14 +4685,14 @@ import {
|
|
|
4676
4685
|
openSync as openSync3,
|
|
4677
4686
|
mkdirSync as mkdirSync6,
|
|
4678
4687
|
readFileSync as readFileSync12,
|
|
4679
|
-
readdirSync as
|
|
4688
|
+
readdirSync as readdirSync5,
|
|
4680
4689
|
renameSync as renameSync3,
|
|
4681
4690
|
rmSync as rmSync4,
|
|
4682
4691
|
statSync as statSync6,
|
|
4683
4692
|
unlinkSync as unlinkSync4,
|
|
4684
4693
|
writeFileSync as writeFileSync6
|
|
4685
4694
|
} from "fs";
|
|
4686
|
-
import { join as
|
|
4695
|
+
import { join as join12 } from "path";
|
|
4687
4696
|
import { createHash as createHash6, randomUUID as randomUUID4 } from "crypto";
|
|
4688
4697
|
|
|
4689
4698
|
// src/helpers/compositionInputs.ts
|
|
@@ -4694,7 +4703,7 @@ function projectReader(projectDir) {
|
|
|
4694
4703
|
const sources = /* @__PURE__ */ new Map();
|
|
4695
4704
|
return (compPath) => {
|
|
4696
4705
|
if (!sources.has(compPath)) {
|
|
4697
|
-
const file =
|
|
4706
|
+
const file = resolveWithinProject(projectDir, compPath);
|
|
4698
4707
|
let source = null;
|
|
4699
4708
|
try {
|
|
4700
4709
|
source = file ? readFileSync11(file, "utf-8") : null;
|
|
@@ -4753,14 +4762,14 @@ var prunedCacheDirs = /* @__PURE__ */ new Set();
|
|
|
4753
4762
|
function pruneThumbnailCache(cacheDir, protectedPaths, now = Date.now()) {
|
|
4754
4763
|
let entries;
|
|
4755
4764
|
try {
|
|
4756
|
-
entries =
|
|
4765
|
+
entries = readdirSync5(cacheDir, { withFileTypes: true });
|
|
4757
4766
|
} catch (err) {
|
|
4758
4767
|
if (err.code === "ENOENT") return;
|
|
4759
4768
|
throw err;
|
|
4760
4769
|
}
|
|
4761
4770
|
const files = entries.flatMap((entry) => {
|
|
4762
4771
|
if (!entry.isFile()) return [];
|
|
4763
|
-
const path =
|
|
4772
|
+
const path = join12(cacheDir, entry.name);
|
|
4764
4773
|
try {
|
|
4765
4774
|
const stats = statSync6(path);
|
|
4766
4775
|
return [{ path, bytes: stats.size, mtimeMs: stats.mtimeMs }];
|
|
@@ -4830,7 +4839,7 @@ function registerThumbnailRoutes(api, adapter) {
|
|
|
4830
4839
|
const { project, signature: projectSignature } = resolved;
|
|
4831
4840
|
let compPath = requestSubPath(c.req.url, "projects/:id/thumbnail");
|
|
4832
4841
|
if (compPath && !compPath.includes(".")) compPath += ".html";
|
|
4833
|
-
const htmlFile =
|
|
4842
|
+
const htmlFile = resolveWithinProject(project.dir, compPath);
|
|
4834
4843
|
const source = htmlFile ? readFileOnce(htmlFile) : "not-a-file";
|
|
4835
4844
|
if (source === "not-a-file") return c.json({ error: "not found" }, 404);
|
|
4836
4845
|
const inputSignature = compositionInputSignature(project.dir, compPath, projectSignature);
|
|
@@ -4863,12 +4872,13 @@ function registerThumbnailRoutes(api, adapter) {
|
|
|
4863
4872
|
if (hMatch?.[1]) compH = parseInt(hMatch[1]);
|
|
4864
4873
|
}
|
|
4865
4874
|
}
|
|
4866
|
-
const manualEdits = manifestKey(
|
|
4867
|
-
const motion = manifestKey(
|
|
4875
|
+
const manualEdits = manifestKey(join12(project.dir, STUDIO_MANUAL_EDITS_PATH));
|
|
4876
|
+
const motion = manifestKey(join12(project.dir, STUDIO_MOTION_PATH));
|
|
4868
4877
|
sourceMtime = Math.max(sourceMtime, manualEdits.mtimeMs, motion.mtimeMs);
|
|
4869
4878
|
const projectUrl = `http://${c.req.header("host")}/api/projects/${encodeURIComponent(project.id)}`;
|
|
4870
|
-
const
|
|
4871
|
-
const
|
|
4879
|
+
const previewPath = compPath === "index.html" ? `${projectUrl}/preview` : `${projectUrl}/preview/comp/${compPath.split("/").map(encodeURIComponent).join("/")}`;
|
|
4880
|
+
const previewUrl = `${previewPath}?${PREVIEW_CAPTURE_PARAM}=1`;
|
|
4881
|
+
const cacheDir = join12(project.dir, ".thumbnails");
|
|
4872
4882
|
const selectorKey = selector ? `_${selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}_${selectorIndex ?? 0}` : "";
|
|
4873
4883
|
const urlVersionKey = urlVersion ? `_${urlVersion.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 32)}` : "";
|
|
4874
4884
|
const inputSignatureKey = `_${createHash6("sha1").update(inputSignature).digest("hex").slice(0, 16)}`;
|
|
@@ -4876,7 +4886,7 @@ function registerThumbnailRoutes(api, adapter) {
|
|
|
4876
4886
|
const outputWidth = Math.max(1, Math.round(compW * outputScale));
|
|
4877
4887
|
const outputHeight = Math.max(1, Math.round(compH * outputScale));
|
|
4878
4888
|
const cacheKey = `${THUMBNAIL_CACHE_VERSION}${urlVersionKey}${inputSignatureKey}${manualEdits.key}${motion.key}${sourceKey}_${format}_${outputMode}_${compPath.replace(/\//g, "_")}_${compW}x${compH}_${outputWidth}x${outputHeight}_${sourceMtime}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`;
|
|
4879
|
-
const cachePath =
|
|
4889
|
+
const cachePath = join12(cacheDir, cacheKey);
|
|
4880
4890
|
if (!prunedCacheDirs.has(cacheDir)) {
|
|
4881
4891
|
prunedCacheDirs.add(cacheDir);
|
|
4882
4892
|
pruneThumbnailCache(
|
|
@@ -4943,7 +4953,7 @@ function registerThumbnailRoutes(api, adapter) {
|
|
|
4943
4953
|
|
|
4944
4954
|
// src/routes/waveform.ts
|
|
4945
4955
|
import { existsSync as existsSync7, readFileSync as readFileSync13, statSync as statSync7 } from "fs";
|
|
4946
|
-
import { join as
|
|
4956
|
+
import { join as join13, resolve as resolve6 } from "path";
|
|
4947
4957
|
import { isWithinProjectRoot as isWithinProjectRoot2 } from "@hyperframes/parsers/asset-resolution";
|
|
4948
4958
|
function registerWaveformRoutes(api, adapter) {
|
|
4949
4959
|
api.get("/projects/:id/waveform/*", async (c) => {
|
|
@@ -4956,8 +4966,8 @@ function registerWaveformRoutes(api, adapter) {
|
|
|
4956
4966
|
}
|
|
4957
4967
|
const stats = statSync7(audioPath, { throwIfNoEntry: false });
|
|
4958
4968
|
if (!stats) return c.json({ error: "file not found" }, 404);
|
|
4959
|
-
const cacheDir =
|
|
4960
|
-
const cachePath =
|
|
4969
|
+
const cacheDir = join13(project.dir, ".waveform-cache");
|
|
4970
|
+
const cachePath = join13(cacheDir, buildWaveformCacheKey(assetPath, stats));
|
|
4961
4971
|
try {
|
|
4962
4972
|
if (isWaveformCacheDirectory(cacheDir) && existsSync7(cachePath)) {
|
|
4963
4973
|
const peaks2 = JSON.parse(readFileSync13(cachePath, "utf-8"));
|
|
@@ -5244,7 +5254,7 @@ function registerSelectionRoutes(api, adapter) {
|
|
|
5244
5254
|
// src/routes/media.ts
|
|
5245
5255
|
import { streamSSE as streamSSE2 } from "hono/streaming";
|
|
5246
5256
|
import { existsSync as existsSync8, mkdirSync as mkdirSync7 } from "fs";
|
|
5247
|
-
import { basename as
|
|
5257
|
+
import { basename as basename2, dirname as dirname6, extname as extname2, join as join14, posix } from "path";
|
|
5248
5258
|
var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
5249
5259
|
".mp4",
|
|
5250
5260
|
".mov",
|
|
@@ -5274,24 +5284,34 @@ function containsNullByte(path) {
|
|
|
5274
5284
|
return path.includes("\0");
|
|
5275
5285
|
}
|
|
5276
5286
|
function slugFileBase(path) {
|
|
5277
|
-
const name =
|
|
5287
|
+
const name = basename2(path, extname2(path)).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
5278
5288
|
return name || "media";
|
|
5279
5289
|
}
|
|
5280
|
-
function uniqueAssetPath(projectDir, assetPath) {
|
|
5290
|
+
function uniqueAssetPath(projectDir, assetPath, taken) {
|
|
5281
5291
|
const ext = extname2(assetPath);
|
|
5282
5292
|
const withoutExt = assetPath.slice(0, -ext.length);
|
|
5283
5293
|
let candidate = assetPath;
|
|
5284
|
-
for (let index = 2; existsSync8(
|
|
5294
|
+
for (let index = 2; taken.has(candidate) || existsSync8(join14(projectDir, candidate)); index++) {
|
|
5285
5295
|
candidate = `${withoutExt}-${index}${ext}`;
|
|
5286
5296
|
}
|
|
5287
5297
|
return candidate;
|
|
5288
5298
|
}
|
|
5289
|
-
function defaultOutputPath(projectDir, inputPath) {
|
|
5299
|
+
function defaultOutputPath(projectDir, inputPath, taken) {
|
|
5290
5300
|
const ext = isImagePath(inputPath) ? ".png" : ".webm";
|
|
5291
|
-
return uniqueAssetPath(
|
|
5301
|
+
return uniqueAssetPath(
|
|
5302
|
+
projectDir,
|
|
5303
|
+
`assets/cutouts/${slugFileBase(inputPath)}-cutout${ext}`,
|
|
5304
|
+
taken
|
|
5305
|
+
);
|
|
5292
5306
|
}
|
|
5293
|
-
function defaultPlatePath(projectDir, inputPath) {
|
|
5294
|
-
return uniqueAssetPath(projectDir, `assets/cutouts/${slugFileBase(inputPath)}-plate.webm
|
|
5307
|
+
function defaultPlatePath(projectDir, inputPath, taken) {
|
|
5308
|
+
return uniqueAssetPath(projectDir, `assets/cutouts/${slugFileBase(inputPath)}-plate.webm`, taken);
|
|
5309
|
+
}
|
|
5310
|
+
function outputsInFlight(mediaJobs) {
|
|
5311
|
+
const running = [...mediaJobs.values()].filter((job) => job.status === "processing");
|
|
5312
|
+
return new Set(
|
|
5313
|
+
running.flatMap((job) => [job.outputAssetPath, job.backgroundOutputAssetPath ?? ""])
|
|
5314
|
+
);
|
|
5295
5315
|
}
|
|
5296
5316
|
function makeJobId(projectId, mediaJobs) {
|
|
5297
5317
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
|
|
@@ -5330,7 +5350,7 @@ function registerMediaRoutes(api, adapter, options = {}) {
|
|
|
5330
5350
|
if (/^(?:https?:|data:|blob:)/i.test(assetPath)) {
|
|
5331
5351
|
return c.json({ error: "media metadata requires a project-local asset" }, 400);
|
|
5332
5352
|
}
|
|
5333
|
-
const filePath =
|
|
5353
|
+
const filePath = resolveWithinProject(project.dir, assetPath);
|
|
5334
5354
|
if (!filePath) return c.json({ error: "forbidden" }, 403);
|
|
5335
5355
|
if (!existsSync8(filePath)) return c.json({ error: "media not found" }, 404);
|
|
5336
5356
|
return c.json({ path: assetPath, metadata: await readMediaMetadata(filePath) });
|
|
@@ -5352,7 +5372,7 @@ function registerMediaRoutes(api, adapter, options = {}) {
|
|
|
5352
5372
|
if (/^(?:https?:|data:|blob:)/i.test(inputAssetPath)) {
|
|
5353
5373
|
return c.json({ error: "background removal requires a project-local media asset" }, 400);
|
|
5354
5374
|
}
|
|
5355
|
-
const inputPath =
|
|
5375
|
+
const inputPath = resolveWithinProject(project.dir, inputAssetPath);
|
|
5356
5376
|
if (!inputPath) return c.json({ error: "forbidden" }, 403);
|
|
5357
5377
|
if (!existsSync8(inputPath)) return c.json({ error: "input media not found" }, 404);
|
|
5358
5378
|
const inputIsVideo = isVideoPath(inputAssetPath);
|
|
@@ -5360,15 +5380,16 @@ function registerMediaRoutes(api, adapter, options = {}) {
|
|
|
5360
5380
|
if (!inputIsVideo && !inputIsImage) {
|
|
5361
5381
|
return c.json({ error: "background removal supports video or image assets only" }, 400);
|
|
5362
5382
|
}
|
|
5363
|
-
const requestedOutput = body.outputPath ? normalizeProjectAssetPath(body.outputPath) : "";
|
|
5383
|
+
const requestedOutput = body.outputPath ? posix.normalize(normalizeProjectAssetPath(body.outputPath)) : "";
|
|
5364
5384
|
if (requestedOutput && containsNullByte(requestedOutput)) {
|
|
5365
5385
|
return c.json({ error: "forbidden" }, 403);
|
|
5366
5386
|
}
|
|
5367
|
-
if (requestedOutput && !
|
|
5387
|
+
if (requestedOutput && !resolveWithinProject(project.dir, requestedOutput)) {
|
|
5368
5388
|
return c.json({ error: "forbidden" }, 403);
|
|
5369
5389
|
}
|
|
5370
|
-
const
|
|
5371
|
-
const
|
|
5390
|
+
const taken = outputsInFlight(mediaJobs);
|
|
5391
|
+
const outputAssetPath = requestedOutput ? uniqueAssetPath(project.dir, requestedOutput, taken) : defaultOutputPath(project.dir, inputAssetPath, taken);
|
|
5392
|
+
const outputPath = pinWithinProject(project.dir, outputAssetPath);
|
|
5372
5393
|
if (!outputPath) return c.json({ error: "forbidden" }, 403);
|
|
5373
5394
|
if (inputIsVideo && !VIDEO_OUTPUT_EXTENSIONS.has(extname2(outputAssetPath).toLowerCase())) {
|
|
5374
5395
|
return c.json({ error: "video background removal output must be .webm or .mov" }, 400);
|
|
@@ -5382,14 +5403,15 @@ function registerMediaRoutes(api, adapter, options = {}) {
|
|
|
5382
5403
|
if (!inputIsVideo) {
|
|
5383
5404
|
return c.json({ error: "background plates are only supported for video inputs" }, 400);
|
|
5384
5405
|
}
|
|
5385
|
-
|
|
5386
|
-
|
|
5406
|
+
taken.add(outputAssetPath);
|
|
5407
|
+
backgroundOutputAssetPath = defaultPlatePath(project.dir, inputAssetPath, taken);
|
|
5408
|
+
backgroundOutputPath = pinWithinProject(project.dir, backgroundOutputAssetPath) ?? void 0;
|
|
5387
5409
|
if (!backgroundOutputPath) {
|
|
5388
5410
|
return c.json({ error: "forbidden" }, 403);
|
|
5389
5411
|
}
|
|
5390
5412
|
}
|
|
5391
|
-
mkdirSync7(
|
|
5392
|
-
if (backgroundOutputPath) mkdirSync7(
|
|
5413
|
+
mkdirSync7(dirname6(outputPath), { recursive: true });
|
|
5414
|
+
if (backgroundOutputPath) mkdirSync7(dirname6(backgroundOutputPath), { recursive: true });
|
|
5393
5415
|
const jobId = makeJobId(project.id, mediaJobs);
|
|
5394
5416
|
const state = adapter.startBackgroundRemoval({
|
|
5395
5417
|
project,
|
|
@@ -5448,9 +5470,9 @@ function registerMediaRoutes(api, adapter, options = {}) {
|
|
|
5448
5470
|
// src/routes/globalAssets.ts
|
|
5449
5471
|
import { existsSync as existsSync9, readFileSync as readFileSync14 } from "fs";
|
|
5450
5472
|
import { homedir } from "os";
|
|
5451
|
-
import { join as
|
|
5473
|
+
import { join as join15 } from "path";
|
|
5452
5474
|
function readGlobalAssets(home = homedir()) {
|
|
5453
|
-
const manifestPath =
|
|
5475
|
+
const manifestPath = join15(home, ".media", "manifest.jsonl");
|
|
5454
5476
|
if (!existsSync9(manifestPath)) return [];
|
|
5455
5477
|
const out = [];
|
|
5456
5478
|
for (const line of readFileSync14(manifestPath, "utf8").split("\n")) {
|
|
@@ -5471,26 +5493,26 @@ function registerGlobalAssetRoutes(api) {
|
|
|
5471
5493
|
}
|
|
5472
5494
|
|
|
5473
5495
|
// src/history/projectHistory.ts
|
|
5474
|
-
import { randomUUID as
|
|
5475
|
-
import { readdir as
|
|
5496
|
+
import { randomUUID as randomUUID9 } from "crypto";
|
|
5497
|
+
import { readdir as readdir3, rm as rm3, rmdir, writeFile } from "fs/promises";
|
|
5476
5498
|
import {
|
|
5477
5499
|
existsSync as existsSync11,
|
|
5478
5500
|
lstatSync as lstatSync4,
|
|
5479
|
-
statSync as
|
|
5501
|
+
statSync as statSync10,
|
|
5480
5502
|
mkdirSync as mkdirSync11,
|
|
5481
5503
|
readdirSync as readdirSync7,
|
|
5482
|
-
readFileSync as
|
|
5483
|
-
realpathSync
|
|
5504
|
+
readFileSync as readFileSync19,
|
|
5505
|
+
realpathSync,
|
|
5484
5506
|
rmSync as rmSync6
|
|
5485
5507
|
} from "fs";
|
|
5486
5508
|
import { homedir as homedir2 } from "os";
|
|
5487
|
-
import { dirname as dirname10, isAbsolute as
|
|
5509
|
+
import { dirname as dirname10, isAbsolute as isAbsolute2, join as join20, relative as relative5, resolve as resolve8, sep as sep3 } from "path";
|
|
5488
5510
|
|
|
5489
5511
|
// src/history/blobStore.ts
|
|
5490
5512
|
import { createHash as createHash7, randomUUID as randomUUID5 } from "crypto";
|
|
5491
5513
|
import { constants as constants3, createReadStream as createReadStream2, renameSync as renameSync4 } from "fs";
|
|
5492
5514
|
import { copyFile, mkdir, readdir, readFile as readFile2, rename, rm, stat as stat2 } from "fs/promises";
|
|
5493
|
-
import { dirname as
|
|
5515
|
+
import { dirname as dirname7, join as join16 } from "path";
|
|
5494
5516
|
var BLOB_HASH = /^[0-9a-f]{64}$/;
|
|
5495
5517
|
async function hashFile(path) {
|
|
5496
5518
|
const hash = createHash7("sha256");
|
|
@@ -5498,7 +5520,7 @@ async function hashFile(path) {
|
|
|
5498
5520
|
return hash.digest("hex");
|
|
5499
5521
|
}
|
|
5500
5522
|
async function cloneOrCopy(from, to, beforeReplace) {
|
|
5501
|
-
await mkdir(
|
|
5523
|
+
await mkdir(dirname7(to), { recursive: true });
|
|
5502
5524
|
const temp = `${to}.${randomUUID5()}.tmp`;
|
|
5503
5525
|
try {
|
|
5504
5526
|
await copyFile(from, temp, constants3.COPYFILE_FICLONE);
|
|
@@ -5513,16 +5535,16 @@ async function openBlobStore(dir) {
|
|
|
5513
5535
|
const sizes = /* @__PURE__ */ new Map();
|
|
5514
5536
|
await mkdir(dir, { recursive: true });
|
|
5515
5537
|
for (const shard of await readdir(dir))
|
|
5516
|
-
for (const hash of await readdir(
|
|
5517
|
-
sizes.set(hash, (await stat2(
|
|
5538
|
+
for (const hash of await readdir(join16(dir, shard)).catch(() => []))
|
|
5539
|
+
sizes.set(hash, (await stat2(join16(dir, shard, hash))).size);
|
|
5518
5540
|
const pathOf = (hash) => {
|
|
5519
5541
|
if (!BLOB_HASH.test(hash)) throw new Error("That is not a history blob.");
|
|
5520
|
-
return
|
|
5542
|
+
return join16(dir, hash.slice(0, 2), hash);
|
|
5521
5543
|
};
|
|
5522
5544
|
let total = [...sizes.values()].reduce((sum, size) => sum + size, 0);
|
|
5523
5545
|
return {
|
|
5524
5546
|
async put(absPath) {
|
|
5525
|
-
const temp =
|
|
5547
|
+
const temp = join16(dir, `incoming-${randomUUID5()}`);
|
|
5526
5548
|
await mkdir(dir, { recursive: true });
|
|
5527
5549
|
await copyFile(absPath, temp, constants3.COPYFILE_FICLONE);
|
|
5528
5550
|
const hash = await hashFile(temp);
|
|
@@ -5530,7 +5552,7 @@ async function openBlobStore(dir) {
|
|
|
5530
5552
|
await rm(temp, { force: true });
|
|
5531
5553
|
return hash;
|
|
5532
5554
|
}
|
|
5533
|
-
await mkdir(
|
|
5555
|
+
await mkdir(dirname7(pathOf(hash)), { recursive: true });
|
|
5534
5556
|
const size = (await stat2(temp)).size;
|
|
5535
5557
|
await rename(temp, pathOf(hash));
|
|
5536
5558
|
sizes.set(hash, size);
|
|
@@ -5553,15 +5575,23 @@ async function openBlobStore(dir) {
|
|
|
5553
5575
|
};
|
|
5554
5576
|
}
|
|
5555
5577
|
|
|
5578
|
+
// src/history/pruneHistories.ts
|
|
5579
|
+
import { randomUUID as randomUUID8 } from "crypto";
|
|
5580
|
+
import { readdirSync as readdirSync6, readFileSync as readFileSync17, renameSync as renameSync5, statSync as statSync9, writeFileSync as writeFileSync9 } from "fs";
|
|
5581
|
+
import { lstat, readdir as readdir2, rm as rm2 } from "fs/promises";
|
|
5582
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
5583
|
+
import { dirname as dirname8, join as join19 } from "path";
|
|
5584
|
+
|
|
5556
5585
|
// src/history/historyId.ts
|
|
5557
5586
|
import { randomUUID as randomUUID6 } from "crypto";
|
|
5558
5587
|
import { existsSync as existsSync10, mkdirSync as mkdirSync8, readFileSync as readFileSync15, statSync as statSync8, writeFileSync as writeFileSync7 } from "fs";
|
|
5559
|
-
import { join as
|
|
5560
|
-
var ID_PATH =
|
|
5588
|
+
import { basename as basename3, join as join17, resolve as resolve7 } from "path";
|
|
5589
|
+
var ID_PATH = join17(".hyperframes", "history-id");
|
|
5561
5590
|
var ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
5591
|
+
var isHistoryId = (name) => ID_SHAPE.test(name);
|
|
5562
5592
|
function readId(projectDir) {
|
|
5563
5593
|
try {
|
|
5564
|
-
const id = readFileSync15(
|
|
5594
|
+
const id = readFileSync15(join17(projectDir, ID_PATH), "utf-8").trim();
|
|
5565
5595
|
return ID_SHAPE.test(id) ? id : null;
|
|
5566
5596
|
} catch {
|
|
5567
5597
|
return null;
|
|
@@ -5569,36 +5599,62 @@ function readId(projectDir) {
|
|
|
5569
5599
|
}
|
|
5570
5600
|
var sameFolder = (a, b) => a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
|
|
5571
5601
|
function isRecordedFolder(historyDir, folder) {
|
|
5602
|
+
const was = readRecord(historyDir);
|
|
5603
|
+
if (typeof was?.ino === "number")
|
|
5604
|
+
return sameFolder({ ino: was.ino, birthtimeMs: was.born ?? NaN }, folder);
|
|
5605
|
+
const unreadableSo0878KeptTheId = typeof was?.dir !== "string";
|
|
5606
|
+
if (unreadableSo0878KeptTheId) return true;
|
|
5607
|
+
return !isCopyOf0878Folder(was.dir, basename3(historyDir), folder);
|
|
5608
|
+
}
|
|
5609
|
+
function readRecord(historyDir) {
|
|
5610
|
+
try {
|
|
5611
|
+
return JSON.parse(readFileSync15(join17(historyDir, "project.json"), "utf-8"));
|
|
5612
|
+
} catch {
|
|
5613
|
+
return null;
|
|
5614
|
+
}
|
|
5615
|
+
}
|
|
5616
|
+
function isCopyOf0878Folder(recordedDir, id, folder) {
|
|
5617
|
+
let there;
|
|
5572
5618
|
try {
|
|
5573
|
-
|
|
5574
|
-
return sameFolder({ ino: was.ino, birthtimeMs: was.born }, folder);
|
|
5619
|
+
there = statSync8(recordedDir, { throwIfNoEntry: false });
|
|
5575
5620
|
} catch {
|
|
5576
5621
|
return false;
|
|
5577
5622
|
}
|
|
5623
|
+
return !!there && !sameFolder(there, folder) && readId(recordedDir) === id;
|
|
5578
5624
|
}
|
|
5579
5625
|
function projectHistoryId(projectDir, historyRoot) {
|
|
5580
5626
|
const dir = resolve7(projectDir);
|
|
5581
5627
|
const folder = statSync8(dir);
|
|
5582
5628
|
let id = readId(dir);
|
|
5583
|
-
if (!id
|
|
5629
|
+
if (!id && existsSync10(join17(dir, ID_PATH)))
|
|
5630
|
+
throw new HistoryIdError(
|
|
5631
|
+
`${join17(dir, ID_PATH)} holds no history id this version can read; move it aside to start anew.`
|
|
5632
|
+
);
|
|
5633
|
+
if (!id || existsSync10(join17(historyRoot, id)) && !isRecordedFolder(join17(historyRoot, id), folder)) {
|
|
5584
5634
|
id = randomUUID6();
|
|
5585
|
-
mkdirSync8(
|
|
5586
|
-
writeFileSync7(
|
|
5635
|
+
mkdirSync8(join17(dir, ".hyperframes"), { recursive: true });
|
|
5636
|
+
writeFileSync7(join17(dir, ID_PATH), `${id}
|
|
5587
5637
|
`);
|
|
5588
5638
|
}
|
|
5589
|
-
recordProject(
|
|
5639
|
+
recordProject(join17(historyRoot, id), dir, folder);
|
|
5590
5640
|
return id;
|
|
5591
5641
|
}
|
|
5642
|
+
var HistoryIdError = class extends Error {
|
|
5643
|
+
constructor(message) {
|
|
5644
|
+
super(message);
|
|
5645
|
+
this.name = "HistoryIdError";
|
|
5646
|
+
}
|
|
5647
|
+
};
|
|
5592
5648
|
function recordProject(historyDir, dir, folder) {
|
|
5593
|
-
const record = { dir, ino: folder.ino, born: folder.birthtimeMs };
|
|
5649
|
+
const record = { dir, ino: folder.ino, born: folder.birthtimeMs, dev: folder.dev };
|
|
5594
5650
|
mkdirSync8(historyDir, { recursive: true });
|
|
5595
|
-
replaceFileAtomically(
|
|
5651
|
+
replaceFileAtomically(join17(historyDir, "project.json"), JSON.stringify(record), 420);
|
|
5596
5652
|
}
|
|
5597
5653
|
|
|
5598
5654
|
// src/history/ownerLock.ts
|
|
5599
5655
|
import { randomUUID as randomUUID7 } from "crypto";
|
|
5600
5656
|
import { linkSync, mkdirSync as mkdirSync9, readFileSync as readFileSync16, rmSync as rmSync5, writeFileSync as writeFileSync8 } from "fs";
|
|
5601
|
-
import { join as
|
|
5657
|
+
import { join as join18 } from "path";
|
|
5602
5658
|
var HistoryBusyError = class extends Error {
|
|
5603
5659
|
constructor(pid) {
|
|
5604
5660
|
super(`This project's history is open in another process (pid ${pid}).`);
|
|
@@ -5657,7 +5713,7 @@ function evictDeadOwner(file) {
|
|
|
5657
5713
|
}
|
|
5658
5714
|
}
|
|
5659
5715
|
async function takeHistoryOwnership(home, waitMs) {
|
|
5660
|
-
const file =
|
|
5716
|
+
const file = join18(home, "owner.pid");
|
|
5661
5717
|
const deadline = Date.now() + waitMs;
|
|
5662
5718
|
mkdirSync9(home, { recursive: true });
|
|
5663
5719
|
for (; ; ) {
|
|
@@ -5676,8 +5732,144 @@ async function takeHistoryOwnership(home, waitMs) {
|
|
|
5676
5732
|
}
|
|
5677
5733
|
}
|
|
5678
5734
|
|
|
5735
|
+
// src/history/pruneHistories.ts
|
|
5736
|
+
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
5737
|
+
var KEEP_GONE_PROJECT_HISTORY_MS = 14 * DAY_MS;
|
|
5738
|
+
var PRUNED_PREFIX = ".pruned-";
|
|
5739
|
+
var LAST_PRUNE_FILE = ".last-prune";
|
|
5740
|
+
async function pruneGoneProjectHistories(historyRoot, options = {}) {
|
|
5741
|
+
return (await pruneWithin(historyRoot, options)).pruned;
|
|
5742
|
+
}
|
|
5743
|
+
async function pruneWithin(historyRoot, {
|
|
5744
|
+
now = Date.now(),
|
|
5745
|
+
dryRun = false,
|
|
5746
|
+
tempDir = tmpdir2(),
|
|
5747
|
+
onError = (error) => console.warn("[history] could not prune a history:", error),
|
|
5748
|
+
budgetMs = Infinity,
|
|
5749
|
+
measure = true
|
|
5750
|
+
}) {
|
|
5751
|
+
const started = performance.now();
|
|
5752
|
+
let names;
|
|
5753
|
+
try {
|
|
5754
|
+
names = readdirSync6(historyRoot);
|
|
5755
|
+
} catch (error) {
|
|
5756
|
+
if (error.code === "ENOENT") return { pruned: [], finished: true };
|
|
5757
|
+
throw error;
|
|
5758
|
+
}
|
|
5759
|
+
const pruned = [];
|
|
5760
|
+
for (const name of names) {
|
|
5761
|
+
if (performance.now() - started >= budgetMs) return { pruned, finished: false };
|
|
5762
|
+
try {
|
|
5763
|
+
const gone = await pruneEntry(historyRoot, name, { now, dryRun, tempDir, measure });
|
|
5764
|
+
if (gone) pruned.push(gone);
|
|
5765
|
+
} catch (error) {
|
|
5766
|
+
onError(error);
|
|
5767
|
+
}
|
|
5768
|
+
}
|
|
5769
|
+
return { pruned, finished: true };
|
|
5770
|
+
}
|
|
5771
|
+
async function pruneEntry(historyRoot, name, run) {
|
|
5772
|
+
if (name.startsWith(PRUNED_PREFIX)) {
|
|
5773
|
+
if (!run.dryRun) await rm2(join19(historyRoot, name), { recursive: true, force: true });
|
|
5774
|
+
return null;
|
|
5775
|
+
}
|
|
5776
|
+
return isHistoryId(name) ? pruneOne(historyRoot, name, run) : null;
|
|
5777
|
+
}
|
|
5778
|
+
async function pruneOne(historyRoot, id, run) {
|
|
5779
|
+
const home = join19(historyRoot, id);
|
|
5780
|
+
if (abandonedProject(home, id, run.now, run.tempDir) === null) return null;
|
|
5781
|
+
const release = await takeUnlessBusy(home);
|
|
5782
|
+
if (!release) return null;
|
|
5783
|
+
let taken;
|
|
5784
|
+
try {
|
|
5785
|
+
taken = recheckAndMoveAway(historyRoot, id, run);
|
|
5786
|
+
} catch (error) {
|
|
5787
|
+
release();
|
|
5788
|
+
throw error;
|
|
5789
|
+
}
|
|
5790
|
+
if (!taken?.trash) release();
|
|
5791
|
+
if (!taken) return null;
|
|
5792
|
+
const bytes = await clearAway(taken.trash, home, run.measure);
|
|
5793
|
+
return { id, projectDir: taken.projectDir, bytes };
|
|
5794
|
+
}
|
|
5795
|
+
async function clearAway(trash, home, measure) {
|
|
5796
|
+
const bytes = measure ? await bytesIn(trash ?? home) : 0;
|
|
5797
|
+
if (trash) await rm2(trash, { recursive: true, force: true });
|
|
5798
|
+
return bytes;
|
|
5799
|
+
}
|
|
5800
|
+
async function takeUnlessBusy(home) {
|
|
5801
|
+
try {
|
|
5802
|
+
return await takeHistoryOwnership(home, 0);
|
|
5803
|
+
} catch (error) {
|
|
5804
|
+
if (error instanceof HistoryBusyError) return null;
|
|
5805
|
+
throw error;
|
|
5806
|
+
}
|
|
5807
|
+
}
|
|
5808
|
+
function recheckAndMoveAway(historyRoot, id, { now, dryRun, tempDir }) {
|
|
5809
|
+
const home = join19(historyRoot, id);
|
|
5810
|
+
const projectDir = abandonedProject(home, id, now, tempDir);
|
|
5811
|
+
if (projectDir === null) return null;
|
|
5812
|
+
if (dryRun) return { projectDir, trash: null };
|
|
5813
|
+
const trash = join19(historyRoot, `${PRUNED_PREFIX}${id}-${randomUUID8()}`);
|
|
5814
|
+
renameSync5(home, trash);
|
|
5815
|
+
return { projectDir, trash };
|
|
5816
|
+
}
|
|
5817
|
+
function abandonedProject(home, id, now, tempDir) {
|
|
5818
|
+
const record = readRecord(home);
|
|
5819
|
+
const dir = record?.dir;
|
|
5820
|
+
if (typeof dir !== "string") return null;
|
|
5821
|
+
if (now - lastUsed(home) < KEEP_GONE_PROJECT_HISTORY_MS && !isSafePath(tempDir, dir)) return null;
|
|
5822
|
+
return projectGone(dir, id, record?.dev, tempDir) ? dir : null;
|
|
5823
|
+
}
|
|
5824
|
+
function projectGone(dir, id, dev, tempDir) {
|
|
5825
|
+
if (!statSync9(join19(dir, ID_PATH), { throwIfNoEntry: false }))
|
|
5826
|
+
return diskStillHere(dir, dev, tempDir);
|
|
5827
|
+
const there = readId(dir);
|
|
5828
|
+
return there !== null && there !== id;
|
|
5829
|
+
}
|
|
5830
|
+
function diskStillHere(dir, dev, tempDir) {
|
|
5831
|
+
let at = dir;
|
|
5832
|
+
let stat3 = statSync9(at, { throwIfNoEntry: false });
|
|
5833
|
+
while (!stat3) {
|
|
5834
|
+
if (dirname8(at) === at) return false;
|
|
5835
|
+
at = dirname8(at);
|
|
5836
|
+
stat3 = statSync9(at, { throwIfNoEntry: false });
|
|
5837
|
+
}
|
|
5838
|
+
if (typeof dev === "number") return stat3.dev === dev;
|
|
5839
|
+
return at === dir || isSafePath(tempDir, at);
|
|
5840
|
+
}
|
|
5841
|
+
function lastUsed(home) {
|
|
5842
|
+
const mtime = (file) => statSync9(join19(home, file), { throwIfNoEntry: false })?.mtimeMs ?? 0;
|
|
5843
|
+
return Math.max(mtime("project.json"), mtime("log.jsonl"));
|
|
5844
|
+
}
|
|
5845
|
+
async function bytesIn(dir) {
|
|
5846
|
+
let total = 0;
|
|
5847
|
+
for (const entry of await readdir2(dir, { withFileTypes: true })) {
|
|
5848
|
+
const path = join19(dir, entry.name);
|
|
5849
|
+
total += entry.isDirectory() ? await bytesIn(path) : (await lstat(path)).size;
|
|
5850
|
+
}
|
|
5851
|
+
return total;
|
|
5852
|
+
}
|
|
5853
|
+
function prunedWithinADay(stamp, now) {
|
|
5854
|
+
try {
|
|
5855
|
+
const last = Number(readFileSync17(stamp, "utf-8"));
|
|
5856
|
+
return last <= now && now - last < DAY_MS;
|
|
5857
|
+
} catch {
|
|
5858
|
+
return false;
|
|
5859
|
+
}
|
|
5860
|
+
}
|
|
5861
|
+
var pruning = /* @__PURE__ */ new Set();
|
|
5862
|
+
function pruneGoneProjectHistoriesDaily(historyRoot, now, { onError, budgetMs } = {}) {
|
|
5863
|
+
const stamp = join19(historyRoot, LAST_PRUNE_FILE);
|
|
5864
|
+
if (pruning.has(historyRoot) || prunedWithinADay(stamp, now)) return;
|
|
5865
|
+
pruning.add(historyRoot);
|
|
5866
|
+
setImmediate(() => {
|
|
5867
|
+
pruneWithin(historyRoot, { now, onError, budgetMs, measure: false }).then(({ finished }) => finished && writeFileSync9(stamp, String(now))).catch((error) => onError?.(error)).finally(() => pruning.delete(historyRoot));
|
|
5868
|
+
});
|
|
5869
|
+
}
|
|
5870
|
+
|
|
5679
5871
|
// src/history/historyLog.ts
|
|
5680
|
-
import { closeSync as closeSync5, constants as constants4, mkdirSync as mkdirSync10, openSync as openSync5, readFileSync as
|
|
5872
|
+
import { closeSync as closeSync5, constants as constants4, mkdirSync as mkdirSync10, openSync as openSync5, readFileSync as readFileSync18, writeSync as writeSync2 } from "fs";
|
|
5681
5873
|
import { dirname as dirname9 } from "path";
|
|
5682
5874
|
var START = "start";
|
|
5683
5875
|
function parseRecord(line) {
|
|
@@ -5690,7 +5882,7 @@ function parseRecord(line) {
|
|
|
5690
5882
|
function readLog(file, onUnreadable) {
|
|
5691
5883
|
let text2;
|
|
5692
5884
|
try {
|
|
5693
|
-
text2 =
|
|
5885
|
+
text2 = readFileSync18(file, "utf-8");
|
|
5694
5886
|
} catch {
|
|
5695
5887
|
return null;
|
|
5696
5888
|
}
|
|
@@ -5823,7 +6015,7 @@ var isMissingFolder = (error) => ["ENOENT", "ENOTDIR"].includes(error.code ?? ""
|
|
|
5823
6015
|
async function removeEmptyFolders(dir) {
|
|
5824
6016
|
let entries;
|
|
5825
6017
|
try {
|
|
5826
|
-
entries = await
|
|
6018
|
+
entries = await readdir3(dir, { withFileTypes: true });
|
|
5827
6019
|
} catch (error) {
|
|
5828
6020
|
if (isMissingFolder(error)) return;
|
|
5829
6021
|
throw error;
|
|
@@ -5841,8 +6033,8 @@ function fileLeftIn(dir, at, deleted) {
|
|
|
5841
6033
|
return void 0;
|
|
5842
6034
|
}
|
|
5843
6035
|
function ignoresCase(dir) {
|
|
5844
|
-
const self =
|
|
5845
|
-
const other =
|
|
6036
|
+
const self = statSync10(join20(dir, ID_PATH), { throwIfNoEntry: false });
|
|
6037
|
+
const other = statSync10(join20(dir, ID_PATH.toUpperCase()), { throwIfNoEntry: false });
|
|
5846
6038
|
return self !== void 0 && other?.ino === self.ino && other.dev === self.dev;
|
|
5847
6039
|
}
|
|
5848
6040
|
var blocks = (removed, added) => added.startsWith(`${removed}/`) || removed.startsWith(`${added}/`);
|
|
@@ -5861,7 +6053,7 @@ var Engine = class {
|
|
|
5861
6053
|
this.dir = resolve8(options.projectDir);
|
|
5862
6054
|
this.home = join20(options.historyRoot, projectId);
|
|
5863
6055
|
this.foldsCase = ignoresCase(this.dir);
|
|
5864
|
-
const folder =
|
|
6056
|
+
const folder = statSync10(this.dir, { throwIfNoEntry: false });
|
|
5865
6057
|
if (!folder || readId(this.dir) !== projectId || !isRecordedFolder(this.home, folder))
|
|
5866
6058
|
throw this.replaced();
|
|
5867
6059
|
this.folder = { dev: folder.dev, ino: folder.ino, birthtimeMs: folder.birthtimeMs };
|
|
@@ -5907,7 +6099,7 @@ var Engine = class {
|
|
|
5907
6099
|
}
|
|
5908
6100
|
/** Another project if the folder or its id changed, or the id is gone: a recreated folder may reuse the inode. */
|
|
5909
6101
|
whereFolder() {
|
|
5910
|
-
const now =
|
|
6102
|
+
const now = statSync10(this.dir, { throwIfNoEntry: false });
|
|
5911
6103
|
if (!now) return "gone";
|
|
5912
6104
|
const same = now.dev === this.folder.dev && sameFolder(now, this.folder);
|
|
5913
6105
|
return same && readId(this.dir) === this.projectId ? "here" : "replaced";
|
|
@@ -5970,7 +6162,7 @@ var Engine = class {
|
|
|
5970
6162
|
}
|
|
5971
6163
|
readStatCache() {
|
|
5972
6164
|
try {
|
|
5973
|
-
const files = JSON.parse(
|
|
6165
|
+
const files = JSON.parse(readFileSync19(join20(this.home, "stat.json"), "utf-8"));
|
|
5974
6166
|
return new Map(Object.entries(files));
|
|
5975
6167
|
} catch {
|
|
5976
6168
|
return /* @__PURE__ */ new Map();
|
|
@@ -6082,7 +6274,7 @@ var Engine = class {
|
|
|
6082
6274
|
return group && taken.every((change) => after(change.path, change.before) === change.before) && [...group.changes.values()].every(untouched) ? group : null;
|
|
6083
6275
|
}
|
|
6084
6276
|
logPath(path) {
|
|
6085
|
-
return
|
|
6277
|
+
return relative5(this.dir, resolve8(this.dir, path)).split(sep3).join("/");
|
|
6086
6278
|
}
|
|
6087
6279
|
/** Takes `paths`' uncommitted changes from others, cut at what `who` overwrote. */
|
|
6088
6280
|
async takeClaimed(who, paths, overwrote) {
|
|
@@ -6126,12 +6318,12 @@ var Engine = class {
|
|
|
6126
6318
|
if (hash === change.before || hash === told) return hash;
|
|
6127
6319
|
}
|
|
6128
6320
|
if (bytes === void 0) return void 0;
|
|
6129
|
-
const staged = join20(this.home, `overwrote-${
|
|
6321
|
+
const staged = join20(this.home, `overwrote-${randomUUID9()}`);
|
|
6130
6322
|
await writeFile(staged, bytes);
|
|
6131
6323
|
try {
|
|
6132
6324
|
return await this.blobs.put(staged);
|
|
6133
6325
|
} finally {
|
|
6134
|
-
await
|
|
6326
|
+
await rm3(staged, { force: true });
|
|
6135
6327
|
}
|
|
6136
6328
|
}
|
|
6137
6329
|
cutOut(group, change, cut) {
|
|
@@ -6148,6 +6340,7 @@ var Engine = class {
|
|
|
6148
6340
|
part.changes.set(path, window.changes.get(path));
|
|
6149
6341
|
window.changes.delete(path);
|
|
6150
6342
|
}
|
|
6343
|
+
(window.parts ??= /* @__PURE__ */ new Set()).add(part.id);
|
|
6151
6344
|
window.entry = await this.commit(part);
|
|
6152
6345
|
}
|
|
6153
6346
|
holdClaim(group, key, idleMs = this.options.quietMs ?? 2e3) {
|
|
@@ -6180,7 +6373,7 @@ var Engine = class {
|
|
|
6180
6373
|
return this.outside;
|
|
6181
6374
|
}
|
|
6182
6375
|
newGroup(who, label) {
|
|
6183
|
-
return { id:
|
|
6376
|
+
return { id: randomUUID9(), who, label, startedAt: this.now(), changes: /* @__PURE__ */ new Map() };
|
|
6184
6377
|
}
|
|
6185
6378
|
async commitOutside() {
|
|
6186
6379
|
if (this.outside?.changes.size) await this.commitClaim();
|
|
@@ -6309,8 +6502,8 @@ var Engine = class {
|
|
|
6309
6502
|
if (!standing) return void 0;
|
|
6310
6503
|
if (standing.stat.isSymbolicLink()) return standing.at;
|
|
6311
6504
|
if (standing.at === path && !standing.stat.isDirectory()) return void 0;
|
|
6312
|
-
const real =
|
|
6313
|
-
const at =
|
|
6505
|
+
const real = realpathSync.native(join20(this.dir, standing.at));
|
|
6506
|
+
const at = relative5(realpathSync.native(this.dir), real).split(sep3).join("/");
|
|
6314
6507
|
if (standing.at !== path) return deleted.has(at) ? void 0 : at;
|
|
6315
6508
|
return fileLeftIn(real, at, deleted);
|
|
6316
6509
|
}
|
|
@@ -6387,7 +6580,13 @@ var Engine = class {
|
|
|
6387
6580
|
(group) => group?.changes.size && mine(group.who)
|
|
6388
6581
|
);
|
|
6389
6582
|
if (pending) return direction === "back" ? this.pendingEntry(pending) : void 0;
|
|
6390
|
-
|
|
6583
|
+
const everyone = this.options.undoScope === "everyone";
|
|
6584
|
+
const ofOpenTurn = (entry) => this.windows.some((open2) => open2.parts?.has(entry.id));
|
|
6585
|
+
return stepTarget(
|
|
6586
|
+
this.log.entries,
|
|
6587
|
+
direction,
|
|
6588
|
+
(entry) => mine(entry.who) || everyone && !ofOpenTurn(entry)
|
|
6589
|
+
);
|
|
6391
6590
|
}
|
|
6392
6591
|
/** A pending group as the entry it becomes once committed (a window's part gets a fresh id). */
|
|
6393
6592
|
pendingEntry(group) {
|
|
@@ -6462,14 +6661,14 @@ var Engine = class {
|
|
|
6462
6661
|
const dest = resolve8(emptyDir);
|
|
6463
6662
|
if (isWithin(this.dir, dest))
|
|
6464
6663
|
throw new Error(`Checkout writes outside the project only: ${dest}`);
|
|
6465
|
-
if ((await
|
|
6664
|
+
if ((await readdir3(dest).catch(missingIsEmpty)).length > 0)
|
|
6466
6665
|
throw new Error(`Checkout writes into an empty folder only: ${dest}`);
|
|
6467
6666
|
const files = manifestAround(this.log, entryId, side);
|
|
6468
6667
|
try {
|
|
6469
6668
|
for (const [path, hash] of files) await this.blobs.writeTo(hash, join20(dest, path));
|
|
6470
6669
|
} catch (error) {
|
|
6471
|
-
for (const name of await
|
|
6472
|
-
await
|
|
6670
|
+
for (const name of await readdir3(dest).catch(() => []))
|
|
6671
|
+
await rm3(join20(dest, name), { recursive: true, force: true });
|
|
6473
6672
|
throw error;
|
|
6474
6673
|
}
|
|
6475
6674
|
}),
|
|
@@ -6506,8 +6705,8 @@ var Engine = class {
|
|
|
6506
6705
|
function isWithin(dir, path) {
|
|
6507
6706
|
let probe = resolve8(path);
|
|
6508
6707
|
while (!existsSync11(probe) && dirname10(probe) !== probe) probe = dirname10(probe);
|
|
6509
|
-
const rel =
|
|
6510
|
-
return rel === "" || rel !== ".." && !rel.startsWith(`..${
|
|
6708
|
+
const rel = relative5(realpathSync.native(dir), realpathSync.native(probe));
|
|
6709
|
+
return rel === "" || rel !== ".." && !rel.startsWith(`..${sep3}`) && !isAbsolute2(rel);
|
|
6511
6710
|
}
|
|
6512
6711
|
function missingIsEmpty(error) {
|
|
6513
6712
|
if (error.code === "ENOENT") return [];
|
|
@@ -6531,6 +6730,10 @@ async function openProjectHistory(options) {
|
|
|
6531
6730
|
const blobs = await openBlobStore(join20(options.historyRoot, projectId, "blobs"));
|
|
6532
6731
|
const engine = new Engine(options, projectId, blobs);
|
|
6533
6732
|
await engine.queue(() => engine.start());
|
|
6733
|
+
pruneGoneProjectHistoriesDaily(options.historyRoot, engine.now(), {
|
|
6734
|
+
onError: options.onError,
|
|
6735
|
+
budgetMs: options.pruneGoneProjectsBudgetMs
|
|
6736
|
+
});
|
|
6534
6737
|
const api = engine.api();
|
|
6535
6738
|
return {
|
|
6536
6739
|
...api,
|
|
@@ -6777,7 +6980,7 @@ function numericAttribute(element, name) {
|
|
|
6777
6980
|
|
|
6778
6981
|
// src/helpers/previewDocumentStore.ts
|
|
6779
6982
|
import { createHash as createHash8 } from "crypto";
|
|
6780
|
-
import { mkdirSync as mkdirSync12, readFileSync as
|
|
6983
|
+
import { mkdirSync as mkdirSync12, readFileSync as readFileSync20, readdirSync as readdirSync8, rmSync as rmSync7 } from "fs";
|
|
6781
6984
|
import { join as join21 } from "path";
|
|
6782
6985
|
function createPreviewDocumentStore(dir, salt) {
|
|
6783
6986
|
const fileFor = (key) => join21(dir, `${createHash8("sha256").update(`${salt}
|
|
@@ -6785,7 +6988,7 @@ ${key}`).digest("hex").slice(0, 32)}.html`);
|
|
|
6785
6988
|
return {
|
|
6786
6989
|
read(key) {
|
|
6787
6990
|
try {
|
|
6788
|
-
return
|
|
6991
|
+
return readFileSync20(fileFor(key), "utf-8");
|
|
6789
6992
|
} catch {
|
|
6790
6993
|
return null;
|
|
6791
6994
|
}
|
|
@@ -6877,6 +7080,7 @@ export {
|
|
|
6877
7080
|
START as HISTORY_START,
|
|
6878
7081
|
HistoryBusyError,
|
|
6879
7082
|
HistoryClosedError,
|
|
7083
|
+
HistoryIdError,
|
|
6880
7084
|
MAX_WINDOW_IDLE_MS,
|
|
6881
7085
|
MIME_TYPES,
|
|
6882
7086
|
PREVIEW_BUNDLE_OPTIONS,
|
|
@@ -6908,6 +7112,7 @@ export {
|
|
|
6908
7112
|
openProjectHistory,
|
|
6909
7113
|
parseSourceDocument,
|
|
6910
7114
|
patchElementInHtml,
|
|
7115
|
+
pruneGoneProjectHistories,
|
|
6911
7116
|
removeElementFromHtml,
|
|
6912
7117
|
requestSubPath,
|
|
6913
7118
|
settledFileTag,
|