@hyperframes/engine 0.6.118 → 0.6.120
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/package.json +24 -7
- package/scripts/generate-lut-reference.py +0 -168
- package/scripts/test-fitTextFontSize-browser.ts +0 -135
- package/src/cdp-headless-experimental.d.ts +0 -54
- package/src/config.test.ts +0 -213
- package/src/config.ts +0 -417
- package/src/index.ts +0 -273
- package/src/services/audioMixer.test.ts +0 -326
- package/src/services/audioMixer.ts +0 -604
- package/src/services/audioMixer.types.ts +0 -35
- package/src/services/audioVolumeEnvelope.test.ts +0 -176
- package/src/services/audioVolumeEnvelope.ts +0 -138
- package/src/services/browserManager.test.ts +0 -330
- package/src/services/browserManager.ts +0 -670
- package/src/services/chunkEncoder.test.ts +0 -1415
- package/src/services/chunkEncoder.ts +0 -831
- package/src/services/chunkEncoder.types.ts +0 -60
- package/src/services/extractionCache.test.ts +0 -199
- package/src/services/extractionCache.ts +0 -216
- package/src/services/fileServer.ts +0 -110
- package/src/services/frameCapture-discardWarmup.test.ts +0 -183
- package/src/services/frameCapture-namePolyfill.test.ts +0 -78
- package/src/services/frameCapture-pollImagesReady.test.ts +0 -153
- package/src/services/frameCapture-staticDedupIndex.test.ts +0 -76
- package/src/services/frameCapture-warmupTicks.test.ts +0 -174
- package/src/services/frameCapture.test.ts +0 -192
- package/src/services/frameCapture.ts +0 -1934
- package/src/services/hdrCapture.test.ts +0 -159
- package/src/services/hdrCapture.ts +0 -315
- package/src/services/parallelCoordinator.test.ts +0 -139
- package/src/services/parallelCoordinator.ts +0 -437
- package/src/services/screenshotService.test.ts +0 -510
- package/src/services/screenshotService.ts +0 -615
- package/src/services/streamingEncoder.test.ts +0 -832
- package/src/services/streamingEncoder.ts +0 -594
- package/src/services/systemMemory.test.ts +0 -324
- package/src/services/systemMemory.ts +0 -180
- package/src/services/videoFrameExtractor.test.ts +0 -1062
- package/src/services/videoFrameExtractor.ts +0 -1139
- package/src/services/videoFrameInjector.test.ts +0 -300
- package/src/services/videoFrameInjector.ts +0 -687
- package/src/services/vp9Options.ts +0 -13
- package/src/types.ts +0 -191
- package/src/utils/alphaBlit.test.ts +0 -1349
- package/src/utils/alphaBlit.ts +0 -1015
- package/src/utils/assertSwiftShader.test.ts +0 -130
- package/src/utils/assertSwiftShader.ts +0 -126
- package/src/utils/ffmpegBinaries.test.ts +0 -43
- package/src/utils/ffmpegBinaries.ts +0 -63
- package/src/utils/ffprobe.test.ts +0 -342
- package/src/utils/ffprobe.ts +0 -457
- package/src/utils/gpuEncoder.test.ts +0 -140
- package/src/utils/gpuEncoder.ts +0 -268
- package/src/utils/hdr.test.ts +0 -191
- package/src/utils/hdr.ts +0 -137
- package/src/utils/hdrCompositing.test.ts +0 -130
- package/src/utils/htmlTemplate.test.ts +0 -42
- package/src/utils/htmlTemplate.ts +0 -42
- package/src/utils/layerCompositor.test.ts +0 -150
- package/src/utils/layerCompositor.ts +0 -58
- package/src/utils/parityContract.ts +0 -1
- package/src/utils/processTracker.test.ts +0 -74
- package/src/utils/processTracker.ts +0 -41
- package/src/utils/readWebGlVendorInfoFromCanvas.ts +0 -52
- package/src/utils/runFfmpeg.test.ts +0 -102
- package/src/utils/runFfmpeg.ts +0 -136
- package/src/utils/shaderTransitions.test.ts +0 -738
- package/src/utils/shaderTransitions.ts +0 -1130
- package/src/utils/uint16-alignment-audit.test.ts +0 -125
- package/src/utils/urlDownloader.test.ts +0 -65
- package/src/utils/urlDownloader.ts +0 -143
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -7
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import type { HdrTransfer } from "../utils/hdr.js";
|
|
2
|
-
import type { Fps } from "@hyperframes/core";
|
|
3
|
-
|
|
4
|
-
export interface EncoderOptions {
|
|
5
|
-
/** Frame rate as an exact rational; see `Fps` in @hyperframes/core. */
|
|
6
|
-
fps: Fps;
|
|
7
|
-
width: number;
|
|
8
|
-
height: number;
|
|
9
|
-
codec?: "h264" | "h265" | "vp9" | "prores";
|
|
10
|
-
preset?: string;
|
|
11
|
-
quality?: number;
|
|
12
|
-
bitrate?: string;
|
|
13
|
-
pixelFormat?: string;
|
|
14
|
-
/** libvpx-vp9 -cpu-used value. Defaults to the engine VP9 setting. */
|
|
15
|
-
vp9CpuUsed?: number;
|
|
16
|
-
useGpu?: boolean;
|
|
17
|
-
hdr?: { transfer: HdrTransfer };
|
|
18
|
-
/**
|
|
19
|
-
* When `true`, force closed-GOP encoding with a keyframe at every
|
|
20
|
-
* `gopSize` boundary so the resulting chunk file can be losslessly
|
|
21
|
-
* concatenated (`ffmpeg -f concat -c copy`) with sibling chunks.
|
|
22
|
-
*
|
|
23
|
-
* Default `false`: GOP placement is left to libx264/libx265 defaults
|
|
24
|
-
* (open-GOP, scenecut-driven keyframes), preserving the in-process
|
|
25
|
-
* renderer's byte-identical output.
|
|
26
|
-
*
|
|
27
|
-
* Honored by the SW libx264 / libx265 / libvpx-vp9 paths. GPU encoders
|
|
28
|
-
* and ProRes ignore the flag — GPU concat-copy is a separate story and
|
|
29
|
-
* ProRes is intra-only (every frame is already a keyframe, so no
|
|
30
|
-
* closed-GOP forcing is needed).
|
|
31
|
-
*
|
|
32
|
-
* For libvpx-vp9, closed-GOP also forces `-auto-alt-ref 0` so the
|
|
33
|
-
* boundary frame between chunks remains independently decodable —
|
|
34
|
-
* libvpx-vp9's default alt-ref frames can land anywhere in the GOP
|
|
35
|
-
* for compression and break concat-copy seams.
|
|
36
|
-
*/
|
|
37
|
-
lockGopForChunkConcat?: boolean;
|
|
38
|
-
/**
|
|
39
|
-
* Required when `lockGopForChunkConcat` is `true`. Number of frames per
|
|
40
|
-
* GOP — set to `chunkSize` so every chunk starts on an IDR keyframe and
|
|
41
|
-
* concat-copy boundaries land on independently-decodable frames.
|
|
42
|
-
*/
|
|
43
|
-
gopSize?: number;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface EncodeResult {
|
|
47
|
-
success: boolean;
|
|
48
|
-
outputPath: string;
|
|
49
|
-
durationMs: number;
|
|
50
|
-
framesEncoded: number;
|
|
51
|
-
fileSize: number;
|
|
52
|
-
error?: string;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export interface MuxResult {
|
|
56
|
-
success: boolean;
|
|
57
|
-
outputPath: string;
|
|
58
|
-
durationMs: number;
|
|
59
|
-
error?: string;
|
|
60
|
-
}
|
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
-
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
COMPLETE_SENTINEL,
|
|
8
|
-
FRAME_FILENAME_PREFIX,
|
|
9
|
-
SCHEMA_PREFIX,
|
|
10
|
-
cacheEntryDirName,
|
|
11
|
-
computeCacheKey,
|
|
12
|
-
ensureCacheEntryDir,
|
|
13
|
-
lookupCacheEntry,
|
|
14
|
-
markCacheEntryComplete,
|
|
15
|
-
readKeyStat,
|
|
16
|
-
type CacheKeyInput,
|
|
17
|
-
} from "./extractionCache.js";
|
|
18
|
-
|
|
19
|
-
const keyFor = (videoPath: string, overrides: Partial<CacheKeyInput> = {}): CacheKeyInput => {
|
|
20
|
-
const stat = readKeyStat(videoPath);
|
|
21
|
-
if (!stat) throw new Error(`keyFor fixture missing on disk: ${videoPath}`);
|
|
22
|
-
return {
|
|
23
|
-
videoPath,
|
|
24
|
-
mtimeMs: stat.mtimeMs,
|
|
25
|
-
size: stat.size,
|
|
26
|
-
mediaStart: 0,
|
|
27
|
-
duration: 3,
|
|
28
|
-
fps: 30,
|
|
29
|
-
format: "jpg",
|
|
30
|
-
...overrides,
|
|
31
|
-
};
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
describe("extractionCache constants", () => {
|
|
35
|
-
it("exposes the v2 schema prefix", () => {
|
|
36
|
-
expect(SCHEMA_PREFIX).toBe("hfcache-v2-");
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("exposes the frame filename prefix shared with the extractor", () => {
|
|
40
|
-
expect(FRAME_FILENAME_PREFIX).toBe("frame_");
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("uses a dotfile sentinel so ls-without-A hides it", () => {
|
|
44
|
-
expect(COMPLETE_SENTINEL.startsWith(".")).toBe(true);
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
describe("computeCacheKey", () => {
|
|
49
|
-
let tmpRoot: string;
|
|
50
|
-
let sourceFile: string;
|
|
51
|
-
|
|
52
|
-
beforeEach(() => {
|
|
53
|
-
tmpRoot = mkdtempSync(join(tmpdir(), "hf-extract-cache-test-"));
|
|
54
|
-
sourceFile = join(tmpRoot, "clip.mp4");
|
|
55
|
-
writeFileSync(sourceFile, "fake-video-bytes", "utf-8");
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
afterEach(() => {
|
|
59
|
-
if (existsSync(tmpRoot)) rmSync(tmpRoot, { recursive: true, force: true });
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
const base = (videoPath: string): CacheKeyInput => keyFor(videoPath);
|
|
63
|
-
|
|
64
|
-
it("returns the same key for identical inputs", () => {
|
|
65
|
-
const a = computeCacheKey(base(sourceFile));
|
|
66
|
-
const b = computeCacheKey(base(sourceFile));
|
|
67
|
-
expect(a).toBe(b);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("produces a 64-char hex SHA-256 digest", () => {
|
|
71
|
-
const key = computeCacheKey(base(sourceFile));
|
|
72
|
-
expect(key).toMatch(/^[0-9a-f]{64}$/);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("changes when path changes (moved files re-extract)", () => {
|
|
76
|
-
const other = join(tmpRoot, "other.mp4");
|
|
77
|
-
writeFileSync(other, "fake-video-bytes", "utf-8");
|
|
78
|
-
const a = computeCacheKey(base(sourceFile));
|
|
79
|
-
const b = computeCacheKey(base(other));
|
|
80
|
-
expect(a).not.toBe(b);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("changes when mediaStart changes", () => {
|
|
84
|
-
const a = computeCacheKey(base(sourceFile));
|
|
85
|
-
const b = computeCacheKey({ ...base(sourceFile), mediaStart: 1 });
|
|
86
|
-
expect(a).not.toBe(b);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("changes when duration changes", () => {
|
|
90
|
-
const a = computeCacheKey(base(sourceFile));
|
|
91
|
-
const b = computeCacheKey({ ...base(sourceFile), duration: 5 });
|
|
92
|
-
expect(a).not.toBe(b);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it("changes when fps changes (different frame count invalidates key)", () => {
|
|
96
|
-
const a = computeCacheKey(base(sourceFile));
|
|
97
|
-
const b = computeCacheKey({ ...base(sourceFile), fps: 60 });
|
|
98
|
-
expect(a).not.toBe(b);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it("changes when format changes", () => {
|
|
102
|
-
const a = computeCacheKey(base(sourceFile));
|
|
103
|
-
const b = computeCacheKey({ ...base(sourceFile), format: "png" });
|
|
104
|
-
expect(a).not.toBe(b);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it("normalizes non-finite duration so Infinity doesn't produce unstable keys", () => {
|
|
108
|
-
const a = computeCacheKey({ ...base(sourceFile), duration: Infinity });
|
|
109
|
-
const b = computeCacheKey({ ...base(sourceFile), duration: Infinity });
|
|
110
|
-
expect(a).toBe(b);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
it("changes when file content changes (mtime+size bump)", () => {
|
|
114
|
-
const before = computeCacheKey(base(sourceFile));
|
|
115
|
-
// Force an mtime change by waiting 5ms then overwriting with different bytes.
|
|
116
|
-
// 5ms is well above the Linux mtime resolution (typically nanoseconds) and
|
|
117
|
-
// below any Windows cache coherency window. Using a longer sleep pads against
|
|
118
|
-
// coarse filesystem mtime granularity without slowing the suite.
|
|
119
|
-
const start = Date.now();
|
|
120
|
-
while (Date.now() - start < 5) {
|
|
121
|
-
/* spin */
|
|
122
|
-
}
|
|
123
|
-
writeFileSync(sourceFile, "different-bytes-longer-than-before", "utf-8");
|
|
124
|
-
const after = computeCacheKey(base(sourceFile));
|
|
125
|
-
expect(after).not.toBe(before);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it("readKeyStat returns null for a missing source (callers skip the cache)", () => {
|
|
129
|
-
// Previously readKeyStat returned a `{mtimeMs: 0, size: 0}` sentinel for
|
|
130
|
-
// missing files; two unrelated missing paths then shared the same cache
|
|
131
|
-
// key tuple and polluted the cache. The contract now returns null so
|
|
132
|
-
// callers can explicitly skip the cache path and let the extractor
|
|
133
|
-
// surface the real file-not-found error.
|
|
134
|
-
const missing = join(tmpRoot, "does-not-exist.mp4");
|
|
135
|
-
expect(readKeyStat(missing)).toBeNull();
|
|
136
|
-
});
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
describe("cacheEntryDirName", () => {
|
|
140
|
-
it("prefixes with the schema and truncates to 16 hex chars", () => {
|
|
141
|
-
const full = "a".repeat(64);
|
|
142
|
-
expect(cacheEntryDirName(full)).toBe(`${SCHEMA_PREFIX}${"a".repeat(16)}`);
|
|
143
|
-
});
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
describe("lookupCacheEntry / markCacheEntryComplete", () => {
|
|
147
|
-
let tmpRoot: string;
|
|
148
|
-
let sourceFile: string;
|
|
149
|
-
|
|
150
|
-
beforeEach(() => {
|
|
151
|
-
tmpRoot = mkdtempSync(join(tmpdir(), "hf-extract-cache-test-"));
|
|
152
|
-
sourceFile = join(tmpRoot, "clip.mp4");
|
|
153
|
-
writeFileSync(sourceFile, "fake-video-bytes", "utf-8");
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
afterEach(() => {
|
|
157
|
-
if (existsSync(tmpRoot)) rmSync(tmpRoot, { recursive: true, force: true });
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
const base = (videoPath: string): CacheKeyInput => keyFor(videoPath);
|
|
161
|
-
|
|
162
|
-
it("misses on an empty cache root", () => {
|
|
163
|
-
const lookup = lookupCacheEntry(tmpRoot, base(sourceFile));
|
|
164
|
-
expect(lookup.hit).toBe(false);
|
|
165
|
-
expect(lookup.entry.dir.startsWith(tmpRoot)).toBe(true);
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
it("hits after ensureCacheEntryDir + markCacheEntryComplete", () => {
|
|
169
|
-
const first = lookupCacheEntry(tmpRoot, base(sourceFile));
|
|
170
|
-
ensureCacheEntryDir(first.entry);
|
|
171
|
-
markCacheEntryComplete(first.entry);
|
|
172
|
-
|
|
173
|
-
const second = lookupCacheEntry(tmpRoot, base(sourceFile));
|
|
174
|
-
expect(second.hit).toBe(true);
|
|
175
|
-
expect(second.entry.dir).toBe(first.entry.dir);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it("treats an in-progress dir without the sentinel as a miss", () => {
|
|
179
|
-
const lookup = lookupCacheEntry(tmpRoot, base(sourceFile));
|
|
180
|
-
ensureCacheEntryDir(lookup.entry);
|
|
181
|
-
// Simulate abandoned extraction — frames written but sentinel never marked.
|
|
182
|
-
writeFileSync(join(lookup.entry.dir, "frame_00001.jpg"), "x", "utf-8");
|
|
183
|
-
const again = lookupCacheEntry(tmpRoot, base(sourceFile));
|
|
184
|
-
expect(again.hit).toBe(false);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it("places entries under the cache root, not the source parent", () => {
|
|
188
|
-
const subroot = join(tmpRoot, "cache-root");
|
|
189
|
-
mkdirSync(subroot, { recursive: true });
|
|
190
|
-
const lookup = lookupCacheEntry(subroot, base(sourceFile));
|
|
191
|
-
expect(lookup.entry.dir.startsWith(subroot)).toBe(true);
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it("uses the same directory for identical inputs across lookups", () => {
|
|
195
|
-
const a = lookupCacheEntry(tmpRoot, base(sourceFile));
|
|
196
|
-
const b = lookupCacheEntry(tmpRoot, base(sourceFile));
|
|
197
|
-
expect(a.entry.dir).toBe(b.entry.dir);
|
|
198
|
-
});
|
|
199
|
-
});
|
|
@@ -1,216 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Content-Addressed Extraction Cache
|
|
3
|
-
*
|
|
4
|
-
* Video frame extraction is the single most expensive phase of a render
|
|
5
|
-
* after capture. Repeat renders of the same composition (preview → final,
|
|
6
|
-
* studio iteration) re-extract identical frames from the same source file,
|
|
7
|
-
* burning ffmpeg time that adds no value. This module keys extracted frame
|
|
8
|
-
* bundles on the (path, mtime, size, mediaStart, duration, fps, format)
|
|
9
|
-
* tuple so re-renders resolve to a pre-extracted directory instead of
|
|
10
|
-
* re-invoking ffmpeg.
|
|
11
|
-
*
|
|
12
|
-
* ### Scheme
|
|
13
|
-
*
|
|
14
|
-
* - The key is the SHA-256 of a stable JSON encoding of the tuple above.
|
|
15
|
-
* - Cache entries live under `<rootDir>/<SCHEMA_PREFIX><key[0..16]>/` so
|
|
16
|
-
* `ls` output and tracing logs stay short. Truncation to 16 hex chars
|
|
17
|
-
* leaves 64 bits of entropy — collision risk at cache scale is negligible.
|
|
18
|
-
* - A completed entry is marked by writing the `.hf-complete` sentinel file
|
|
19
|
-
* after all frames are on disk. A dir without the sentinel is treated as
|
|
20
|
-
* absent (stale/abandoned) and re-extracted into a fresh key (the old dir
|
|
21
|
-
* is left for external gc — the cache owns keys, not deletion policy).
|
|
22
|
-
*
|
|
23
|
-
* ### Versioning
|
|
24
|
-
*
|
|
25
|
-
* `SCHEMA_PREFIX` bumps when the cache-contents invariant changes (e.g.
|
|
26
|
-
* extraction format, frame layout). Old entries under the previous prefix
|
|
27
|
-
* become inert and can be gc'd by the caller.
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
import { createHash } from "node:crypto";
|
|
31
|
-
import { mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
32
|
-
import { existsSync } from "node:fs";
|
|
33
|
-
import { join } from "node:path";
|
|
34
|
-
import type { VideoMetadata } from "../utils/ffprobe.js";
|
|
35
|
-
|
|
36
|
-
/** Filename prefix for extracted frames. Shared with the extractor. */
|
|
37
|
-
export const FRAME_FILENAME_PREFIX = "frame_";
|
|
38
|
-
|
|
39
|
-
/** Sentinel filename written after a cache entry is fully populated. */
|
|
40
|
-
export const COMPLETE_SENTINEL = ".hf-complete";
|
|
41
|
-
|
|
42
|
-
/** Current schema version. Bump when cache-entry layout changes. */
|
|
43
|
-
export const SCHEMA_PREFIX = "hfcache-v2-";
|
|
44
|
-
|
|
45
|
-
/** Truncated hex chars of SHA-256 used for the entry directory name. */
|
|
46
|
-
const KEY_HEX_CHARS = 16;
|
|
47
|
-
|
|
48
|
-
export type CacheFrameFormat = "jpg" | "png";
|
|
49
|
-
|
|
50
|
-
export interface CacheKeyInput {
|
|
51
|
-
/** Absolute path to the source video file. Part of the key so moved files
|
|
52
|
-
* re-extract rather than match by (size, mtime) alone. */
|
|
53
|
-
videoPath: string;
|
|
54
|
-
/** Source file modification time in ms (floored). Invalidates the key on edit. */
|
|
55
|
-
mtimeMs: number;
|
|
56
|
-
/** Source file size in bytes. Invalidates the key on content change. */
|
|
57
|
-
size: number;
|
|
58
|
-
/** Seconds into source the composition starts reading (video.mediaStart). */
|
|
59
|
-
mediaStart: number;
|
|
60
|
-
/** Seconds of source the composition uses. Infinity is normalized to -1
|
|
61
|
-
* so callers that pass an unresolved "natural duration" still produce a
|
|
62
|
-
* stable key across invocations. */
|
|
63
|
-
duration: number;
|
|
64
|
-
/** Target output frames-per-second. */
|
|
65
|
-
fps: number;
|
|
66
|
-
/** Output image format. */
|
|
67
|
-
format: CacheFrameFormat;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export interface CacheEntry {
|
|
71
|
-
/** Absolute path to the cache entry directory. */
|
|
72
|
-
dir: string;
|
|
73
|
-
/** Full 64-char SHA-256 hex digest (parent of the truncated key). */
|
|
74
|
-
keyHash: string;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export interface CacheLookup {
|
|
78
|
-
/** Cache entry information — always returned even on a miss so the caller
|
|
79
|
-
* can extract directly into `dir` then call `markCacheEntryComplete`. */
|
|
80
|
-
entry: CacheEntry;
|
|
81
|
-
/** True when the entry exists AND carries the completion sentinel. */
|
|
82
|
-
hit: boolean;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Read `(mtimeMs, size)` for a path. Returns `null` if the file is missing —
|
|
87
|
-
* callers should skip the cache path for that entry so the extractor surfaces
|
|
88
|
-
* the real file-not-found error. Returning a zero-stat sentinel would let two
|
|
89
|
-
* missing files share the same `(0, 0)` tuple and pollute the cache with an
|
|
90
|
-
* orphaned entry.
|
|
91
|
-
*/
|
|
92
|
-
export function readKeyStat(videoPath: string): { mtimeMs: number; size: number } | null {
|
|
93
|
-
try {
|
|
94
|
-
const stat = statSync(videoPath);
|
|
95
|
-
return { mtimeMs: Math.floor(stat.mtimeMs), size: stat.size };
|
|
96
|
-
} catch {
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function canonicalKeyBlob(input: CacheKeyInput): string {
|
|
102
|
-
const durationForKey = Number.isFinite(input.duration) ? input.duration : -1;
|
|
103
|
-
return JSON.stringify({
|
|
104
|
-
p: input.videoPath,
|
|
105
|
-
m: input.mtimeMs,
|
|
106
|
-
s: input.size,
|
|
107
|
-
ms: input.mediaStart,
|
|
108
|
-
d: durationForKey,
|
|
109
|
-
f: input.fps,
|
|
110
|
-
fmt: input.format,
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Compute the SHA-256 hex digest for a cache key input.
|
|
116
|
-
*/
|
|
117
|
-
export function computeCacheKey(input: CacheKeyInput): string {
|
|
118
|
-
return createHash("sha256").update(canonicalKeyBlob(input)).digest("hex");
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Derive the truncated cache-entry directory name from a full key hash.
|
|
123
|
-
* Exposed so tests and the entry dir resolver share one truncation rule.
|
|
124
|
-
*/
|
|
125
|
-
export function cacheEntryDirName(keyHash: string): string {
|
|
126
|
-
return SCHEMA_PREFIX + keyHash.slice(0, KEY_HEX_CHARS);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Look up a cache entry by key input. Returns the resolved entry path plus a
|
|
131
|
-
* `hit` flag. On miss, callers should extract frames into `entry.dir`
|
|
132
|
-
* (after calling `ensureCacheEntryDir`) and then call `markCacheEntryComplete`
|
|
133
|
-
* once the extraction succeeds.
|
|
134
|
-
*/
|
|
135
|
-
export function lookupCacheEntry(rootDir: string, input: CacheKeyInput): CacheLookup {
|
|
136
|
-
const keyHash = computeCacheKey(input);
|
|
137
|
-
const dir = join(rootDir, cacheEntryDirName(keyHash));
|
|
138
|
-
const complete = existsSync(join(dir, COMPLETE_SENTINEL));
|
|
139
|
-
return { entry: { dir, keyHash }, hit: complete };
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Ensure a cache entry's directory exists so the extractor can write into it.
|
|
144
|
-
* Idempotent: `mkdirSync({recursive:true})` is a no-op when the dir exists.
|
|
145
|
-
*/
|
|
146
|
-
export function ensureCacheEntryDir(entry: CacheEntry): void {
|
|
147
|
-
mkdirSync(entry.dir, { recursive: true });
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Write the completion sentinel so subsequent lookups treat this entry as a
|
|
152
|
-
* hit. Must be called only after every frame has been written.
|
|
153
|
-
*
|
|
154
|
-
* Concurrency: lookup→populate→mark is non-atomic. Two concurrent renders of
|
|
155
|
-
* the same key may both miss, both extract into the same dir, and the later
|
|
156
|
-
* writer's frames win. The result is correct (identical inputs yield identical
|
|
157
|
-
* frames) but wasteful. Acceptable for a single-process render pipeline;
|
|
158
|
-
* anyone running concurrent renders against a shared cache root should front
|
|
159
|
-
* it with an external lock.
|
|
160
|
-
*/
|
|
161
|
-
export function markCacheEntryComplete(entry: CacheEntry): void {
|
|
162
|
-
writeFileSync(join(entry.dir, COMPLETE_SENTINEL), "", "utf-8");
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Rebuild the in-memory frame index for a cached entry. Called on cache hits
|
|
167
|
-
* so the extractor's caller receives the same `ExtractedFrames` shape it
|
|
168
|
-
* would get from a fresh extraction — without re-running ffmpeg or ffprobe.
|
|
169
|
-
*
|
|
170
|
-
* The `metadata` argument is the `VideoMetadata` probed in the extractor's
|
|
171
|
-
* Phase 2 (pre-preflight). Passing it here avoids an extra ffprobe on the
|
|
172
|
-
* hit path.
|
|
173
|
-
*/
|
|
174
|
-
export interface RehydrateOptions {
|
|
175
|
-
videoId: string;
|
|
176
|
-
srcPath: string;
|
|
177
|
-
fps: number;
|
|
178
|
-
format: CacheFrameFormat;
|
|
179
|
-
metadata: VideoMetadata;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export interface RehydratedFrames {
|
|
183
|
-
videoId: string;
|
|
184
|
-
srcPath: string;
|
|
185
|
-
outputDir: string;
|
|
186
|
-
framePattern: string;
|
|
187
|
-
fps: number;
|
|
188
|
-
totalFrames: number;
|
|
189
|
-
metadata: VideoMetadata;
|
|
190
|
-
framePaths: Map<number, string>;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
export function rehydrateCacheEntry(
|
|
194
|
-
entry: CacheEntry,
|
|
195
|
-
options: RehydrateOptions,
|
|
196
|
-
): RehydratedFrames {
|
|
197
|
-
const framePattern = `${FRAME_FILENAME_PREFIX}%05d.${options.format}`;
|
|
198
|
-
const framePaths = new Map<number, string>();
|
|
199
|
-
const suffix = `.${options.format}`;
|
|
200
|
-
const files = readdirSync(entry.dir)
|
|
201
|
-
.filter((f) => f.startsWith(FRAME_FILENAME_PREFIX) && f.endsWith(suffix))
|
|
202
|
-
.sort();
|
|
203
|
-
files.forEach((file, idx) => {
|
|
204
|
-
framePaths.set(idx, join(entry.dir, file));
|
|
205
|
-
});
|
|
206
|
-
return {
|
|
207
|
-
videoId: options.videoId,
|
|
208
|
-
srcPath: options.srcPath,
|
|
209
|
-
outputDir: entry.dir,
|
|
210
|
-
framePattern,
|
|
211
|
-
fps: options.fps,
|
|
212
|
-
totalFrames: framePaths.size,
|
|
213
|
-
metadata: options.metadata,
|
|
214
|
-
framePaths,
|
|
215
|
-
};
|
|
216
|
-
}
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* File Server
|
|
3
|
-
*
|
|
4
|
-
* Lightweight HTTP server that serves a project directory to headless Chrome.
|
|
5
|
-
* Optionally injects scripts into index.html on-the-fly (e.g. runtime, bridge).
|
|
6
|
-
* Framework-agnostic — the caller decides what scripts to inject.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { Hono } from "hono";
|
|
10
|
-
import { serve } from "@hono/node-server";
|
|
11
|
-
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
12
|
-
import { join, extname } from "node:path";
|
|
13
|
-
import { injectScriptsIntoHtml } from "@hyperframes/core/compiler";
|
|
14
|
-
|
|
15
|
-
const MIME_TYPES: Record<string, string> = {
|
|
16
|
-
".html": "text/html; charset=utf-8",
|
|
17
|
-
".css": "text/css; charset=utf-8",
|
|
18
|
-
".js": "application/javascript; charset=utf-8",
|
|
19
|
-
".json": "application/json; charset=utf-8",
|
|
20
|
-
".png": "image/png",
|
|
21
|
-
".jpg": "image/jpeg",
|
|
22
|
-
".jpeg": "image/jpeg",
|
|
23
|
-
".gif": "image/gif",
|
|
24
|
-
".svg": "image/svg+xml",
|
|
25
|
-
".webp": "image/webp",
|
|
26
|
-
".mp4": "video/mp4",
|
|
27
|
-
".webm": "video/webm",
|
|
28
|
-
".mp3": "audio/mpeg",
|
|
29
|
-
".wav": "audio/wav",
|
|
30
|
-
".ogg": "audio/ogg",
|
|
31
|
-
".aac": "audio/aac",
|
|
32
|
-
".woff": "font/woff",
|
|
33
|
-
".woff2": "font/woff2",
|
|
34
|
-
".ttf": "font/ttf",
|
|
35
|
-
".otf": "font/otf",
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
export interface FileServerOptions {
|
|
39
|
-
projectDir: string;
|
|
40
|
-
compiledDir?: string;
|
|
41
|
-
port?: number;
|
|
42
|
-
/** Scripts injected into <head> of index.html. Default: none. */
|
|
43
|
-
headScripts?: string[];
|
|
44
|
-
/** Scripts injected before </body> of index.html. Default: none. */
|
|
45
|
-
bodyScripts?: string[];
|
|
46
|
-
/** Strip embedded runtime scripts from HTML before injection. Default: true. */
|
|
47
|
-
stripEmbeddedRuntime?: boolean;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface FileServerHandle {
|
|
51
|
-
url: string;
|
|
52
|
-
port: number;
|
|
53
|
-
close: () => void;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function createFileServer(options: FileServerOptions): Promise<FileServerHandle> {
|
|
57
|
-
const { projectDir, compiledDir, port = 0, stripEmbeddedRuntime = true } = options;
|
|
58
|
-
|
|
59
|
-
const headScripts = options.headScripts ?? [];
|
|
60
|
-
const bodyScripts = options.bodyScripts ?? [];
|
|
61
|
-
|
|
62
|
-
const app = new Hono();
|
|
63
|
-
|
|
64
|
-
app.get("/*", (c) => {
|
|
65
|
-
let requestPath = c.req.path;
|
|
66
|
-
if (requestPath === "/") requestPath = "/index.html";
|
|
67
|
-
|
|
68
|
-
// Remove leading slash
|
|
69
|
-
const relativePath = requestPath.replace(/^\//, "");
|
|
70
|
-
const compiledPath = compiledDir ? join(compiledDir, relativePath) : null;
|
|
71
|
-
const hasCompiledFile = Boolean(
|
|
72
|
-
compiledPath && existsSync(compiledPath) && statSync(compiledPath).isFile(),
|
|
73
|
-
);
|
|
74
|
-
const filePath = hasCompiledFile ? (compiledPath as string) : join(projectDir, relativePath);
|
|
75
|
-
|
|
76
|
-
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
|
|
77
|
-
return c.text("Not found", 404);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const ext = extname(filePath).toLowerCase();
|
|
81
|
-
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
|
82
|
-
|
|
83
|
-
if (ext === ".html") {
|
|
84
|
-
const rawHtml = readFileSync(filePath, "utf-8");
|
|
85
|
-
const html =
|
|
86
|
-
relativePath === "index.html"
|
|
87
|
-
? injectScriptsIntoHtml(rawHtml, headScripts, bodyScripts, stripEmbeddedRuntime)
|
|
88
|
-
: rawHtml;
|
|
89
|
-
return c.text(html, 200, { "Content-Type": contentType });
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const content = readFileSync(filePath);
|
|
93
|
-
return new Response(content, {
|
|
94
|
-
status: 200,
|
|
95
|
-
headers: { "Content-Type": contentType },
|
|
96
|
-
});
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
return new Promise((resolve) => {
|
|
100
|
-
const server = serve({ fetch: app.fetch, port }, (info) => {
|
|
101
|
-
const actualPort = info.port;
|
|
102
|
-
const url = `http://localhost:${actualPort}`;
|
|
103
|
-
resolve({
|
|
104
|
-
url,
|
|
105
|
-
port: actualPort,
|
|
106
|
-
close: () => server.close(),
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
});
|
|
110
|
-
}
|