authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-09-06 03:52:38-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:47-07:00
logc8b7e2ef01711a96e294ade99c4c478a1a8f0be8
treee0abbf048b25deb28226d3effc86e7bdad602763
parent3f7ee731b61bebfb8d577c2cc80b97211691b31d
signature Commit is signed but in an unrecognized format.

feat(file-viewer): rework scan3, add mpeg-dash encoding

i swore i already wrote this code. but it was not there. i'm much happier with this pass of the 'AV1+Opus via MPEG-Dash' encoding -- the code is a lot cleaner. this also reworks the table schema for asset refs to store derived assets as first class entries. table joins and everything make `derived.get` really nice (needed to fufill HEAD and "If-None-Match" requests, which i was stuck on for the cache layer). not everything is done here, but just enough to actually start running it on the production store.

13 files changed, 1076 insertions(+), 407 deletions(-)

flake.lock+4-4
...@@ -2,16 +2,16 @@...@@ -2,16 +2,16 @@
2 "nodes": {2 "nodes": {
3 "nixpkgs": {3 "nixpkgs": {
4 "locked": {4 "locked": {
5 "lastModified": 1751271578,5 "lastModified": 1758763312,
6 "narHash": "sha256-P/SQmKDu06x8yv7i0s8bvnnuJYkxVGBWLWHaU+tt4YY=",6 "narHash": "sha256-puBMviZhYlqOdUUgEmMVJpXqC/ToEqSvkyZ30qQ09xM=",
7 "owner": "NixOS",7 "owner": "NixOS",
8 "repo": "nixpkgs",8 "repo": "nixpkgs",
9 "rev": "3016b4b15d13f3089db8a41ef937b13a9e33a8df",9 "rev": "e57b3b16ad8758fd681511a078f35c416a8cc939",
10 "type": "github"10 "type": "github"
11 },11 },
12 "original": {12 "original": {
13 "owner": "NixOS",13 "owner": "NixOS",
14 "ref": "nixos-unstable",14 "ref": "nixpkgs-unstable",
15 "repo": "nixpkgs",15 "repo": "nixpkgs",
16 "type": "github"16 "type": "github"
17 }17 }
flake.nix+3-7
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1{1{
2 inputs = {2 inputs = {
3 nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";3 nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
4 utils.url = "github:numtide/flake-utils";4 utils.url = "github:numtide/flake-utils";
5 };5 };
6 outputs =6 outputs =
...@@ -15,12 +15,8 @@...@@ -15,12 +15,8 @@
15 pkgs.python3 # for font subsetting15 pkgs.python3 # for font subsetting
1616
17 # paperclover.net17 # paperclover.net
18 # (pkgs.ffmpeg.override {18 pkgs.exiftool
19 # withOpus = true;19 pkgs.ffmpeg-full
20 # withSvtav1 = true;
21 # withJxl = true;
22 # withWebp = true;
23 # })
24 pkgs.rsync20 pkgs.rsync
25 ];21 ];
26 };22 };
framework/lib/async.ts+129
...@@ -213,6 +213,7 @@ function defaultGetItemText(item: unknown) {...@@ -213,6 +213,7 @@ function defaultGetItemText(item: unknown) {
213 return itemText;213 return itemText;
214}214}
215215
216/** @deprecated use OnceMap2 */
216export class OnceMap<T> {217export class OnceMap<T> {
217 private ongoing = new Map<string, Promise<T>>();218 private ongoing = new Map<string, Promise<T>>();
218219
...@@ -300,6 +301,134 @@ export function once<T>(fn: () => Promise<T>): () => Promise<T> {...@@ -300,6 +301,134 @@ export function once<T>(fn: () => Promise<T>): () => Promise<T> {
300 };301 };
301}302}
302303
304export class Watch<T> {
305 #value: T;
306 #observers = new Set<WatchCallback<T>>();
307 constructor(value: T) {
308 this.#value = value;
309 }
310
311 on(cb: WatchCallback<T>) {
312 this.#observers.add(cb);
313 return () => void this.#observers.delete(cb);
314 }
315 once(cb: WatchCallback<T>) {
316 const once = (n: T, p: T) => (cb(n, p), this.#observers.delete(once));
317 this.#observers.add(once);
318 }
319
320 next(): Promise<T> {
321 return new Promise((resolve) => this.once(resolve));
322 }
323
324 until(condition: (v: T) => boolean): Promise<T> {
325 if (condition(this.#value)) return Promise.resolve(this.#value);
326 return new Promise((resolve) => {
327 function check(next: T) {
328 if (condition(next)) resolve(next), release();
329 }
330 const release = this.on(check);
331 });
332 }
333
334 get value() {
335 return this.#value;
336 }
337 set value(next: T) {
338 const prev = this.#value;
339 if (prev !== next) {
340 this.#value = next;
341 this.#observers.forEach((cb) => cb(next, prev));
342 }
343 }
344}
345type WatchCallback<T> = (next: T, prev: T) => void;
346
347/**
348 * When two requests with the same args come in at the same time, the response
349 * from them is shared. After returning, the data is no longer stored.
350 */
351export class DedupeConcurrent<Args extends unknown[], Ret> {
352 pending = new Map<unknown, Promise<Ret>>();
353 constructor(
354 public fn: (...args: Args) => Promise<Ret>,
355 public keyFn: (args: Args) => unknown = JSON.stringify,
356 ) {}
357
358 isPending(...args: Args) {
359 return this.pending.has(this.keyFn(args));
360 }
361
362 /** Unbound to allow easily exporting this callback */
363 get = (...args: Args): Promise<Ret> => {
364 const k = this.keyFn(args);
365 let promise = this.pending.get(k);
366 if (promise) return promise;
367 promise = this.fn(...args);
368 this.pending.set(k, promise);
369 void promise.finally(() => void this.pending.delete(k));
370 return promise;
371 };
372}
373
374/**
375 * When two requests with the same args come, the result of the first is memoized.
376 */
377export class OnceMap2<Args extends unknown[], Ret> {
378 cache = new Map<unknown, Promise<Ret>>();
379 constructor(
380 public fn: (...args: Args) => Promise<Ret>,
381 public keyFn: (args: Args) => unknown = JSON.stringify,
382 ) {}
383
384 has(...args: Args) {
385 return this.cache.has(this.keyFn(args));
386 }
387
388 /** Unbound to allow easily exporting this callback */
389 getOrRun = (...args: Args): Promise<Ret> => {
390 const k = this.keyFn(args);
391 let promise = this.cache.get(k);
392 if (promise) return promise;
393 promise = this.fn(...args);
394 this.cache.set(k, promise);
395 return promise;
396 };
397}
398
399export class PromiseAggregator {
400 failures: unknown[] = [];
401 promises = new Set<Promise<unknown>>();
402
403 push(p: Promise<unknown>) {
404 p = p.then(
405 () => {
406 this.promises.delete(p);
407 },
408 (err) => {
409 this.failures.push(err);
410 },
411 );
412 this.promises.add(p);
413 }
414
415 async all() {
416 while (this.promises.size > 0) {
417 await Promise.all(this.promises);
418 this.promises.clear();
419 }
420 if (this.failures.length > 0) {
421 const agg = new AggregateError(this.failures);
422 this.failures = [];
423 throw agg;
424 }
425 }
426
427 [Symbol.asyncDispose]() {
428 return this.all();
429 }
430}
431
303import { Progress } from "@paperclover/console/Progress";432import { Progress } from "@paperclover/console/Progress";
304import { Spinner } from "@paperclover/console/Spinner";433import { Spinner } from "@paperclover/console/Spinner";
305import * as path from "node:path";434import * as path from "node:path";
framework/lib/lru.ts-2
...@@ -155,8 +155,6 @@ export class Lru<K, V> extends Map<K, V> {...@@ -155,8 +155,6 @@ export class Lru<K, V> extends Map<K, V> {
155 this.#used -= needed - remain;155 this.#used -= needed - remain;
156 }156 }
157157
158 hasEvictableCapacity() {}
159
160 /** Unlink an entry without removing it. */158 /** Unlink an entry without removing it. */
161 #evict(key: K) {159 #evict(key: K) {
162 const entry = this.#order.get(key);160 const entry = this.#order.get(key);
framework/lib/path.ts+137-12
...@@ -2,19 +2,25 @@ const unique = new Map<string, WeakRef<Path>>();...@@ -2,19 +2,25 @@ const unique = new Map<string, WeakRef<Path>>();
2const final = new FinalizationRegistry((raw: string) => unique.delete(raw));2const final = new FinalizationRegistry((raw: string) => unique.delete(raw));
33
4export class Path {4export class Path {
5 raw: string;5 private raw: string;
66
7 static from(...parts: (string | Path)[]) {7 static resolve(base: Path | string, ...more: string[]): Path {
8 return new Path(...parts.map((x) => x instanceof Path ? x.raw : x));8 if (more.length === 0) {
9 if (typeof base === "string") {
10 return new Path(path.resolve(base));
11 }
12 return base;
13 }
14 return new Path(path.resolve(
15 typeof base === "string" ? base : base.raw,
16 ...more,
17 ));
9 }18 }
1019
11 /** For convenience, paths are deduplicated. */20 /** For convenience, paths are deduplicated. */
12 constructor(...parts: string[]) {21 constructor(raw: string) {
13 const raw = this.raw =22 ASSERT(path.isAbsolute(raw));
14 parts.length === 1 && parts[0] && path.isAbsolute(parts[0])23 const existing = unique.get(this.raw = raw)?.deref();
15 ? parts[0]
16 : path.resolve(...parts);
17 const existing = unique.get(raw)?.deref();
18 if (existing) return existing;24 if (existing) return existing;
19 unique.set(raw, new WeakRef(this));25 unique.set(raw, new WeakRef(this));
20 final.register(this, raw);26 final.register(this, raw);
...@@ -31,8 +37,8 @@ export class Path {...@@ -31,8 +37,8 @@ export class Path {
31 return new Path(parent);37 return new Path(parent);
32 }38 }
3339
34 get relative() {40 relative(from: Path | "." = ".") {
35 return path.relative(".", this.raw);41 return path.relative(from.toString(), this.raw);
36 }42 }
3743
38 get ext() {44 get ext() {
...@@ -47,14 +53,131 @@ export class Path {...@@ -47,14 +53,131 @@ export class Path {
47 return path.basename(this.raw, this.ext);53 return path.basename(this.raw, this.ext);
48 }54 }
4955
56 replaceExt(ext: string) {
57 if (ext.length > 0 && ext[0] !== ".") ext = `.${ext}`;
58 const { ext: previous } = this;
59 return new Path(this.raw.slice(0, previous.length) + ext);
60 }
61
62 // -- filesystem --
63
50 join(subPath: string, ...more: string[]) {64 join(subPath: string, ...more: string[]) {
51 return new Path(this.raw, subPath, ...more);65 return new Path(path.join(this.raw, subPath, ...more));
52 }66 }
5367
68 /** @deprecated ifExistsSync */
54 existsSync() {69 existsSync() {
55 return fs.existsSync(this.raw);70 return fs.existsSync(this.raw);
56 }71 }
5772
73 /**
74 * Be aware that checking for a file's existence before performing an action
75 * can always be a race condition. Prefer reading the file directly and
76 * handling the ENOENT error.
77 */
78 ifExistsSync() {
79 return fs.existsSync(this.raw) ? this : null;
80 }
81
82 async delete(o?: RmOptions) {
83 await fs.rm(this.raw, o);
84 }
85
86 deleteSync(o?: RmOptions) {
87 fs.rmSync(this.raw, o);
88 }
89
90 async stat() {
91 return await fs.stat(this.raw);
92 }
93
94 statSync() {
95 return fs.statSync(this.raw);
96 }
97
98 async makeDir() {
99 return await fs.mkdir(this.raw);
100 }
101
102 makeDirSync() {
103 return fs.mkdirSync(this.raw);
104 }
105
106 async makeOrEmptyDir() {
107 // TODO: remove contents instead of node
108 await fs.rm(this.raw, { force: true, recursive: true });
109 return await fs.mkdir(this.raw);
110 }
111
112 makeOrEmptyDirSync() {
113 // TODO: remove contents instead of node
114 fs.rmSync(this.raw, { force: true, recursive: true });
115 return fs.mkdirSync(this.raw);
116 }
117
118 readIfExistsSync(encoding?: "buffer"): Buffer | null;
119 readIfExistsSync<T = unknown>(encoding: "json"): T | null;
120 readIfExistsSync(encoding: "utf-8"): string | null;
121 readIfExistsSync(encoding?: "utf-8" | "buffer" | "json"): unknown;
122 readIfExistsSync(encoding?: "utf-8" | "buffer" | "json"): unknown {
123 try {
124 return this.readSync(encoding);
125 } catch (err) {
126 if (error.code(err) === "ENOENT") return null;
127 throw err;
128 }
129 }
130
131 readSync(encoding?: "buffer"): Buffer;
132 readSync<T = unknown>(encoding: "json"): T;
133 readSync(encoding: "utf-8"): string;
134 readSync(encoding?: "utf-8" | "buffer" | "json"): unknown;
135 readSync(encoding?: "utf-8" | "buffer" | "json"): unknown {
136 if (encoding === "json") {
137 return JSON.parse(fs.readFileSync(this.raw, "utf-8"));
138 }
139 return fs.readFileSync(this.raw, encoding === "buffer" ? {} : { encoding });
140 }
141
142 async readIfExists(encoding?: "buffer"): Promise<Buffer | null>;
143 async readIfExists<T = unknown>(encoding: "json"): Promise<T | null>;
144 async readIfExists(encoding: "utf-8"): Promise<string | null>;
145 async readIfExists(
146 encoding?: "utf-8" | "buffer" | "json",
147 ): Promise<unknown>;
148 async readIfExists(
149 encoding?: "utf-8" | "buffer" | "json",
150 ): Promise<unknown> {
151 try {
152 return await this.read(encoding);
153 } catch (err) {
154 if (error.code(err) === "ENOENT") return null;
155 throw err;
156 }
157 }
158
159 async read(encoding?: "buffer"): Promise<Buffer>;
160 async read<T = unknown>(encoding: "json"): Promise<T>;
161 async read(encoding: "utf-8"): Promise<string>;
162 async read(encoding?: "utf-8" | "buffer" | "json"): Promise<unknown>;
163 async read(encoding?: "utf-8" | "buffer" | "json"): Promise<unknown> {
164 if (encoding === "json") {
165 return JSON.parse(await fs.readFile(this.raw, "utf-8"));
166 }
167 return await fs.readFile(
168 this.raw,
169 encoding === "buffer" ? {} : { encoding },
170 );
171 }
172
173 readDirSync(): Array<Path> {
174 return fs.readdirSync(this.raw).map((sub) => this.join(sub));
175 }
176
177 async readDir(): Promise<Array<Path>> {
178 return (await fs.readdir(this.raw)).map((sub) => this.join(sub));
179 }
180
58 static sep = path.sep;181 static sep = path.sep;
59182
60 [Symbol.for("nodejs.util.inspect.custom")](183 [Symbol.for("nodejs.util.inspect.custom")](
...@@ -69,3 +192,5 @@ export class Path {...@@ -69,3 +192,5 @@ export class Path {
69import * as path from "node:path";192import * as path from "node:path";
70import * as fs from "./fs.ts";193import * as fs from "./fs.ts";
71import * as util from "node:util";194import * as util from "node:util";
195import * as error from "./error.ts";
196import type { RmOptions } from "node:fs";
framework/lib/subprocess.ts+43
...@@ -11,5 +11,48 @@ export const exec: typeof execFileRaw = ((...@@ -11,5 +11,48 @@ export const exec: typeof execFileRaw = ((
11 throw e;11 throw e;
12 })) as any;12 })) as any;
1313
14const activePids = new Set<number>();
15export interface SpawnOptions extends child_process.SpawnOptions {
16 cmd: string[];
17}
18export function spawn({ cmd, ...rest }: SpawnOptions) {
19 rest.detached ??= true;
20 const process = child_process.spawn(cmd[0]!, cmd.slice(1), rest);
21 const pid = rest.detached && process.pid ? -process.pid : process.pid;
22 if (pid) {
23 activePids.add(pid);
24 process.on("exit", () => void activePids.delete(pid));
25 }
26 return process;
27}
28
29export async function spawnAndWait(opts: SpawnOptions) {
30 opts.stdio ??= ["ignore", "inherit", "inherit"];
31 const process = spawn(opts);
32 let [code, signal] = await events.EventEmitter.once(process, "exit") as [
33 number | null,
34 string | null,
35 ];
36 if (code !== 0) {
37 if (code !== null && code > 2 ** 31) code |= 0;
38 const exit = signal ? `signal ${signal}` : `code ${code}`;
39 throw new Error(`${opts.cmd[0]} failed with ${exit}`);
40 }
41 const [stdout, stderr] = await Promise.all([
42 process.stdout ? Array.fromAsync(process.stdout).then(Buffer.concat) : null,
43 process.stderr ? Array.fromAsync(process.stderr).then(Buffer.concat) : null,
44 ]);
45 return { stdout, stderr };
46}
47
48process.on("exit", () => {
49 for (const pid of activePids) {
50 console.info(`Kill ${Math.abs(pid)}`);
51 process.kill(pid, 9);
52 }
53});
54
55import * as process from "node:process";
56import * as events from "node:events";
14import * as util from "node:util";57import * as util from "node:util";
15import * as child_process from "node:child_process";58import * as child_process from "node:child_process";
src/file-viewer/bin/scan3.ts+371-224
...@@ -10,8 +10,7 @@...@@ -10,8 +10,7 @@
10// This is the third iteration of the scanner, hence its name "scan3";10// This is the third iteration of the scanner, hence its name "scan3";
11// Remember that any software you want to be maintainable and high11// Remember that any software you want to be maintainable and high
12// quality cannot be written with AI.12// quality cannot be written with AI.
13const workDir = path.resolve(".clover/derived");13const sotToken = process.env.CLOVER_SOT_KEY;
14const sotToken = UNWRAP(process.env.CLOVER_SOT_KEY);
1514
16export async function main() {15export async function main() {
17 const start = performance.now();16 const start = performance.now();
...@@ -32,11 +31,12 @@ export async function main() {...@@ -32,11 +31,12 @@ export async function main() {
32 async fn(absPath: string) {31 async fn(absPath: string) {
33 const stat = await fs.stat(absPath);32 const stat = await fs.stat(absPath);
3433
35 const publicPath = toPublicPath(absPath);34 const publicPath = toPublicPath(Path.resolve(absPath));
36 const mediaFile = MediaFile.getByPath(publicPath);35 const mediaFile = MediaFile.getByPath(publicPath);
3736
38 if (stat.isDirectory()) {37 if (stat.isDirectory()) {
39 const items = await fs.readdir(absPath);38 const items = (await fs.readdir(absPath))
39 .filter((basename) => !skipBasename(basename));
40 qList.addMany(items.map((subPath) => path.join(absPath, subPath)));40 qList.addMany(items.map((subPath) => path.join(absPath, subPath)));
4141
42 if (mediaFile) {42 if (mediaFile) {
...@@ -51,7 +51,7 @@ export async function main() {...@@ -51,7 +51,7 @@ export async function main() {
5151
52 qMeta.addMany(52 qMeta.addMany(
53 deleted.map((mediaFile) => ({53 deleted.map((mediaFile) => ({
54 absPath: path.join(root, mediaFile.path),54 path: Path.resolve(root, mediaFile.path),
55 publicPath: mediaFile.path,55 publicPath: mediaFile.path,
56 stat: null,56 stat: null,
57 mediaFile,57 mediaFile,
...@@ -68,27 +68,27 @@ export async function main() {...@@ -68,27 +68,27 @@ export async function main() {
68 stat.size !== mediaFile.size ||68 stat.size !== mediaFile.size ||
69 stat.mtime.getTime() !== mediaFile.date.getTime()69 stat.mtime.getTime() !== mediaFile.date.getTime()
70 ) {70 ) {
71 qMeta.add({ absPath, publicPath, stat, mediaFile });71 qMeta.add({ path: new Path(absPath), publicPath, stat, mediaFile });
72 return;72 return;
73 }73 }
7474
75 // If the scanners changed, it may mean more processes should be run.75 // If the scanners changed, it may mean more processes should be run.
76 queueProcessors({ absPath, stat, mediaFile });76 queueProcessors({ path: new Path(absPath), stat, mediaFile });
77 },77 },
78 maxJobs: 24,78 maxJobs: 24,
79 });79 });
80 using qMeta = new async.Queue({80 using qMeta = new async.Queue({
81 name: "Update Metadata",81 name: "Update Metadata",
82 async fn({ absPath, publicPath, stat, mediaFile }: UpdateMetadataJob) {82 async fn({ path, publicPath, stat, mediaFile }: UpdateMetadataJob) {
83 if (!stat) {83 if (!stat) {
84 // File was deleted.84 // File was deleted.
85 await runUndoProcessors(UNWRAP(mediaFile));85 await runUndoProcessors(UNWRAP(mediaFile));
86 return;86 return;
87 }87 }
88 // TODO: run scrubLocationMetadata first88 await scrubLocationMetadata(path, stat);
8989
90 const hash = await new Promise<string>((resolve, reject) => {90 const hash = await new Promise<string>((resolve, reject) => {
91 const reader = fs.createReadStream(absPath);91 const reader = fs.createReadStream(path.toString());
92 reader.on("error", reject);92 reader.on("error", reject);
9393
94 const hasher = crypto.createHash("sha1").setEncoding("hex");94 const hasher = crypto.createHash("sha1").setEncoding("hex");
...@@ -121,7 +121,11 @@ export async function main() {...@@ -121,7 +121,11 @@ export async function main() {
121 dimensions: mediaFile?.dimensions ?? "",121 dimensions: mediaFile?.dimensions ?? "",
122 contents: mediaFile?.contents ?? "",122 contents: mediaFile?.contents ?? "",
123 });123 });
124 await queueProcessors({ absPath, stat, mediaFile });124 await queueProcessors({
125 path,
126 stat,
127 mediaFile,
128 });
125 },129 },
126 getItemText: (job) =>130 getItemText: (job) =>
127 job.publicPath.slice(1) + (job.stat ? "" : " (deleted)"),131 job.publicPath.slice(1) + (job.stat ? "" : " (deleted)"),
...@@ -130,10 +134,15 @@ export async function main() {...@@ -130,10 +134,15 @@ export async function main() {
130 using qProcess = new async.Queue({134 using qProcess = new async.Queue({
131 name: "Process Contents",135 name: "Process Contents",
132 async fn(136 async fn(
133 { absPath, stat, mediaFile, processor, index, after }: ProcessJob,137 { path, stat, mediaFile, processor, index, after }: ProcessJob,
134 spin,138 spin,
135 ) {139 ) {
136 await processor.run({ absPath, stat, mediaFile, spin });140 await processor.run({
141 path,
142 stat,
143 mediaFile,
144 spin,
145 });
137 mediaFile.setProcessed(mediaFile.processed | (1 << (16 + index)));146 mediaFile.setProcessed(mediaFile.processed | (1 << (16 + index)));
138 for (const dependantJob of after) {147 for (const dependantJob of after) {
139 ASSERT(148 ASSERT(
...@@ -155,12 +164,12 @@ export async function main() {...@@ -155,12 +164,12 @@ export async function main() {
155 .filter(Boolean)164 .filter(Boolean)
156 .map(([a, b, c]) => ({165 .map(([a, b, c]) => ({
157 id: a,166 id: a,
158 hash: (b.charCodeAt(0) << 8) + c.charCodeAt(0),167 hash: (UNWRAP(b).charCodeAt(0) << 8) + UNWRAP(c).charCodeAt(0),
159 }));168 }));
160 }169 }
161170
162 async function queueProcessors({171 async function queueProcessors({
163 absPath,172 path,
164 stat,173 stat,
165 mediaFile,174 mediaFile,
166 }: Omit<ProcessFileArgs, "spin">) {175 }: Omit<ProcessFileArgs, "spin">) {
...@@ -169,6 +178,7 @@ export async function main() {...@@ -169,6 +178,7 @@ export async function main() {
169 p.include ? p.include.has(ext) : !p.exclude?.has(ext)178 p.include ? p.include.has(ext) : !p.exclude?.has(ext)
170 );179 );
171 if (possible.length === 0) return;180 if (possible.length === 0) return;
181 ASSERT(possible.length < 16, "too many bits");
172182
173 const hash = possible.reduce((a, b) => a ^ b.hash, 0) | 1;183 const hash = possible.reduce((a, b) => a ^ b.hash, 0) | 1;
174 ASSERT(hash <= 0xffff, `${hash.toString(16)} has no bits above 16 set`);184 ASSERT(hash <= 0xffff, `${hash.toString(16)} has no bits above 16 set`);
...@@ -209,13 +219,13 @@ export async function main() {...@@ -209,13 +219,13 @@ export async function main() {
209 for (let i = 0, { length } = possible; i < length; i += 1) {219 for (let i = 0, { length } = possible; i < length; i += 1) {
210 if ((processed & (1 << (16 + i))) === 0) {220 if ((processed & (1 << (16 + i))) === 0) {
211 const job: ProcessJob = {221 const job: ProcessJob = {
212 absPath,222 path,
213 stat,223 stat,
214 mediaFile,224 mediaFile,
215 processor: possible[i],225 processor: UNWRAP(possible[i]),
216 index: i,226 index: i,
217 after: [],227 after: [],
218 needs: possible[i].depends.length,228 needs: UNWRAP(possible[i]).depends.length,
219 };229 };
220 jobs.push(job);230 jobs.push(job);
221 if (job.needs === 0) qProcess.add(job);231 if (job.needs === 0) qProcess.add(job);
...@@ -257,9 +267,8 @@ export async function main() {...@@ -257,9 +267,8 @@ export async function main() {
257 await qProcess.done();267 await qProcess.done();
258268
259 // Update directory metadata269 // Update directory metadata
260 const dirs = MediaFile.getDirectoriesToReindex().sort(270 const dirs = MediaFile.getDirectoriesToReindex()
261 (a, b) => b.path.length - a.path.length,271 .sort((a, b) => b.path.length - a.path.length);
262 );
263 for (const dir of dirs) {272 for (const dir of dirs) {
264 const children = dir.getChildren();273 const children = dir.getChildren();
265274
...@@ -315,52 +324,54 @@ export async function main() {...@@ -315,52 +324,54 @@ export async function main() {
315 }324 }
316325
317 // Sync to remote326 // Sync to remote
318 if ((await fs.readdir(workDir)).length > 0) {327 // if (
319 await rsync.spawn({328 // ((await derived.workDir.ifExistsSync()?.readDir())?.length ?? 0) > 0
320 args: [329 // ) {
321 "--links",330 // await rsync.spawn({
322 "--recursive",331 // args: [
323 "--times",332 // "--links",
324 "--partial",333 // "--recursive",
325 "--progress",334 // "--times",
326 "--remove-source-files",335 // "--partial",
327 "--delay-updates",336 // "--progress",
328 workDir + "/",337 // "--remove-source-files",
329 "clo@zenith:/mnt/storage1/clover/Documents/Config/clover_file/derived/",338 // "--delay-updates",
330 ],339 // derived.workDir.toString() + "/",
331 title: "Uploading Derived Assets",340 // "clo@zenith:/mnt/storage1/clover/Documents/Config/clover_file/derived/",
332 cwd: process.cwd(),341 // ],
333 });342 // title: "Uploading Derived Assets",
334343 // cwd: process.cwd(),
335 await fs.removeEmptyDirectories(workDir);344 // });
336 } else {345 //
337 console.info("No new derived assets");346 // await fs.removeEmptyDirectories(derived.workDir.toString());
338 }347 // } else {
348 // console.info("No new derived assets");
349 // }
339350
340 MediaFile.db.prepare("VACUUM").run();351 MediaFile.db.prepare("VACUUM").run();
341 MediaFile.db.reload();352 MediaFile.db.reload();
342 // TODO: reload prod web instance353
343 await rsync.spawn({354 // await rsync.spawn({
344 args: [355 // args: [
345 MediaFile.db.file,356 // MediaFile.db.file,
346 "clo@zenith:/mnt/storage1/clover/Documents/Config/paperclover/cache.sqlite",357 // "clo@zenith:/mnt/storage1/clover/Documents/Config/paperclover/cache.sqlite",
347 ],358 // ],
348 title: "Uploading Database",359 // title: "Uploading Database",
349 cwd: process.cwd(),360 // cwd: process.cwd(),
350 });361 // });
351 {362 // if (sotToken) {
352 const res = await fetch("https://db.paperclover.net/reload", {363 // const res = await fetch("https://db.paperclover.net/reload", {
353 method: "post",364 // method: "post",
354 headers: {365 // headers: {
355 Authorization: sotToken,366 // Authorization: sotToken,
356 },367 // },
357 });368 // });
358 if (!res.ok) {369 // if (!res.ok) {
359 console.warn(370 // console.warn(
360 `Failed to reload remote database ${res.status} ${res.statusText}`,371 // `Failed to reload remote database ${res.status} ${res.statusText}`,
361 );372 // );
362 }373 // }
363 }374 // } else console.warn("Missing SOT token");
364375
365 console.info(376 console.info(
366 "Updated file viewer index in \x1b[1m" +377 "Updated file viewer index in \x1b[1m" +
...@@ -369,14 +380,12 @@ export async function main() {...@@ -369,14 +380,12 @@ export async function main() {
369 );380 );
370381
371 const { duration, count } = MediaFile.db382 const { duration, count } = MediaFile.db
372 .prepare<[], { count: number; duration: number }>(383 .prepare<[], { count: number; duration: number }>(`
373 `384 select
374 select385 count(*) as count,
375 count(*) as count,386 sum(duration) as duration
376 sum(duration) as duration387 from media_files
377 from media_files388 `)
378 `,
379 )
380 .getNonNull();389 .getNonNull();
381390
382 console.info();391 console.info();
...@@ -408,11 +417,12 @@ const ffmpegBin = testProgram("ffmpeg", "--help");...@@ -408,11 +417,12 @@ const ffmpegBin = testProgram("ffmpeg", "--help");
408417
409const ffmpegOptions = ["-hide_banner", "-loglevel", "warning"];418const ffmpegOptions = ["-hide_banner", "-loglevel", "warning"];
410419
420// NOTE: Never re-order the processors. Add new ones at the end.
411const procDuration: Process = {421const procDuration: Process = {
412 name: "calculate duration",422 name: "calculate duration",
413 enable: ffprobeBin !== null,423 enable: ffprobeBin !== null,
414 include: rules.extsDuration,424 include: rules.extsDuration,
415 async run({ absPath, mediaFile }) {425 async run({ path, mediaFile }) {
416 const { stdout } = await subprocess.exec(ffprobeBin!, [426 const { stdout } = await subprocess.exec(ffprobeBin!, [
417 "-v",427 "-v",
418 "error",428 "error",
...@@ -420,7 +430,7 @@ const procDuration: Process = {...@@ -420,7 +430,7 @@ const procDuration: Process = {
420 "format=duration",430 "format=duration",
421 "-of",431 "-of",
422 "default=noprint_wrappers=1:nokey=1",432 "default=noprint_wrappers=1:nokey=1",
423 absPath,433 path.toString(),
424 ]);434 ]);
425435
426 const duration = parseFloat(stdout.trim());436 const duration = parseFloat(stdout.trim());
...@@ -431,19 +441,18 @@ const procDuration: Process = {...@@ -431,19 +441,18 @@ const procDuration: Process = {
431 },441 },
432};442};
433443
434// NOTE: Never re-order the processors. Add new ones at the end.
435const procDimensions: Process = {444const procDimensions: Process = {
436 name: "calculate dimensions",445 name: "calculate dimensions",
437 enable: ffprobeBin != null,446 enable: ffprobeBin != null,
438 include: rules.extsDimensions,447 include: rules.extsDimensions,
439 async run({ absPath, mediaFile }) {448 async run({ path, mediaFile }) {
440 const ext = path.extname(absPath);449 const { ext } = path;
441450
442 let dimensions;451 let dimensions;
443452
444 if (ext === ".svg") {453 if (ext === ".svg") {
445 // Parse out of text data454 // Parse out of text data
446 const content = await fs.readFile(absPath, "utf8");455 const content = await path.read("utf-8");
447 const widthMatch = content.match(/width="(\d+)"/);456 const widthMatch = content.match(/width="(\d+)"/);
448 const heightMatch = content.match(/height="(\d+)"/);457 const heightMatch = content.match(/height="(\d+)"/);
449458
...@@ -452,7 +461,7 @@ const procDimensions: Process = {...@@ -452,7 +461,7 @@ const procDimensions: Process = {
452 }461 }
453 } else {462 } else {
454 // Use ffprobe to observe streams463 // Use ffprobe to observe streams
455 const { stdout } = await execFile("ffprobe", [464 const { stdout } = await subprocess.exec("ffprobe", [
456 "-v",465 "-v",
457 "error",466 "error",
458 "-select_streams",467 "-select_streams",
...@@ -461,7 +470,7 @@ const procDimensions: Process = {...@@ -461,7 +470,7 @@ const procDimensions: Process = {
461 "stream=width,height",470 "stream=width,height",
462 "-of",471 "-of",
463 "csv=s=x:p=0",472 "csv=s=x:p=0",
464 absPath,473 path.toString(),
465 ]);474 ]);
466 if (stdout.includes("x")) {475 if (stdout.includes("x")) {
467 dimensions = stdout.trim();476 dimensions = stdout.trim();
...@@ -475,9 +484,9 @@ const procDimensions: Process = {...@@ -475,9 +484,9 @@ const procDimensions: Process = {
475const procLoadTextContents: Process = {484const procLoadTextContents: Process = {
476 name: "load text content",485 name: "load text content",
477 include: rules.extsReadContents,486 include: rules.extsReadContents,
478 async run({ absPath, mediaFile, stat }) {487 async run({ path, mediaFile, stat }) {
479 if (stat.size > 1_000_000) return;488 if (stat.size > 1_000_000) return;
480 const text = await fs.readFile(absPath, "utf-8");489 const text = await path.read("utf-8");
481 mediaFile.setContents(text);490 mediaFile.setContents(text);
482 },491 },
483};492};
...@@ -485,9 +494,9 @@ const procLoadTextContents: Process = {...@@ -485,9 +494,9 @@ const procLoadTextContents: Process = {
485const procHighlightCode: Process = {494const procHighlightCode: Process = {
486 name: "highlight source code",495 name: "highlight source code",
487 include: new Set(rules.extsCode.keys()),496 include: new Set(rules.extsCode.keys()),
488 async run({ absPath, mediaFile, stat }) {497 async run({ path, mediaFile, stat }) {
489 const language = UNWRAP(498 const language = UNWRAP(
490 rules.extsCode.get(path.extname(absPath).toLowerCase()),499 rules.extsCode.get(path.ext.toLowerCase()),
491 );500 );
492 // An issue is that .ts is an overloaded extension, shared between501 // An issue is that .ts is an overloaded extension, shared between
493 // 'transport stream' and 'typescript'.502 // 'transport stream' and 'typescript'.
...@@ -497,7 +506,7 @@ const procHighlightCode: Process = {...@@ -497,7 +506,7 @@ const procHighlightCode: Process = {
497 // - invalid UTF-8506 // - invalid UTF-8
498 if (stat.size > 1_000_000) return;507 if (stat.size > 1_000_000) return;
499 let code;508 let code;
500 const buf = await fs.readFile(absPath);509 const buf = await path.read();
501 try {510 try {
502 code = new TextDecoder("utf-8", { fatal: true }).decode(buf);511 code = new TextDecoder("utf-8", { fatal: true }).decode(buf);
503 } catch (error) {512 } catch (error) {
...@@ -512,9 +521,9 @@ const procHighlightCode: Process = {...@@ -512,9 +521,9 @@ const procHighlightCode: Process = {
512const procImageSubsets: Process = {521const procImageSubsets: Process = {
513 name: "encode image subsets",522 name: "encode image subsets",
514 include: rules.extsImage,523 include: rules.extsImage,
515 depends: ["calculate dimensions"],524 depends: [procDimensions.name],
516 version: 2,525 version: 2,
517 async run({ absPath, mediaFile, spin }) {526 async run({ path, mediaFile, spin }) {
518 const { width, height } = UNWRAP(mediaFile.parseDimensions());527 const { width, height } = UNWRAP(mediaFile.parseDimensions());
519 const targetSizes = transcodeRules.imageSizes.filter((w) => w < width);528 const targetSizes = transcodeRules.imageSizes.filter((w) => w < width);
520 const baseStatus = spin.text;529 const baseStatus = spin.text;
...@@ -526,19 +535,16 @@ const procImageSubsets: Process = {...@@ -526,19 +535,16 @@ const procImageSubsets: Process = {
526 spin.text = baseStatus + ` (${w}x${h}, ${ext.slice(1).toUpperCase()})`;535 spin.text = baseStatus + ` (${w}x${h}, ${ext.slice(1).toUpperCase()})`;
527536
528 stack.use(537 stack.use(
529 await produceAsset(`${mediaFile.hash}/${size}${ext}`, async (out) => {538 await derived.produce(mediaFile, `${size}${ext}`, async (dir) => {
530 await fs.mkdir(path.dirname(out));539 await subprocess.exec(ffmpegBin!, [
531 await fs.rm(out, { force: true });
532 await execFile(ffmpegBin!, [
533 ...ffmpegOptions,540 ...ffmpegOptions,
534 "-i",541 "-i",
535 absPath,542 path.toString(),
536 "-vf",543 "-vf",
537 `scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h}`,544 `scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h}`,
538 ...args,545 ...args,
539 out,546 dir.join(`${size}${ext}`).toString(),
540 ]);547 ]);
541 return [out];
542 }),548 }),
543 );549 );
544 }550 }
...@@ -546,16 +552,12 @@ const procImageSubsets: Process = {...@@ -546,16 +552,12 @@ const procImageSubsets: Process = {
546552
547 stack.move();553 stack.move();
548 },554 },
549 async undo(mediaFile) {
550 const { width } = UNWRAP(mediaFile.parseDimensions());
551 const targetSizes = transcodeRules.imageSizes.filter((w) => w < width);
552 for (const size of targetSizes) {
553 for (const { ext } of transcodeRules.imagePresets) {
554 unproduceAsset(`${mediaFile.hash}/${size}${ext}`);
555 }
556 }
557 },
558};555};
556
557const videoInputArgsCache = new async.OnceMap2(
558 transcodeRules.getVideoInputArgs,
559);
560
559const qualityMap: Record<string, string> = {561const qualityMap: Record<string, string> = {
560 u: "ultra-high",562 u: "ultra-high",
561 h: "high",563 h: "high",
...@@ -564,91 +566,150 @@ const qualityMap: Record<string, string> = {...@@ -564,91 +566,150 @@ const qualityMap: Record<string, string> = {
564 d: "data-saving",566 d: "data-saving",
565};567};
566const procVideos = transcodeRules.videoFormats.map<Process>((preset) => ({568const procVideos = transcodeRules.videoFormats.map<Process>((preset) => ({
567 name: `encode ${preset.codec} ${UNWRAP(qualityMap[preset.id[1]])}`,569 name: `encode av1 ${UNWRAP(qualityMap[UNWRAP(preset.id[1])])}`,
568 include: rules.extsVideo,570 include: rules.extsVideo,
569 enable: ffmpegBin != null,571 enable: ffmpegBin != null,
570 async run({ absPath, mediaFile, spin }) {572 depends: [procDuration.name, procDimensions.name],
571 if ((mediaFile.duration ?? 0) < 10) return;573 async run({ path, mediaFile, spin }) {
572 await produceAsset(`${mediaFile.hash}/${preset.id}`, async (base) => {574 if ((mediaFile.duration ?? 0) < 5) return;
573 base = path.dirname(base);575 await derived.produce(mediaFile, `av1-${preset.id}`, async (dir) => {
574 await fs.mkdir(base);576 const input = await videoInputArgsCache.getOrRun(path);
575577 const args = transcodeRules.getAv1VideoArgs(preset, input.video, dir);
576 let inputArgs = ["-i", absPath];578
577 try {579 const fakeProgress = new Progress({ text: spin.text, spinner: null });
578 const config = await fs.readJson<any>(580 fakeProgress.stop();
579 path.join(581 spin.format = (now: number) => fakeProgress.format(now);
580 path.dirname(absPath),582 // @ts-expect-error
581 path.basename(absPath, path.extname(absPath)) + ".json",583 fakeProgress.redraw = () => spin.redraw();
582 ),584
583 );585 await ffmpeg.spawn({
584 if (config.encoder && typeof config.encoder.videoSrc === "string") {586 ffmpeg: ffmpegBin!,
585 const { videoSrc, audioSrc, rate } = config.encoder;587 title: fakeProgress.text,
586 inputArgs = [588 progress: fakeProgress,
587 ...(rate ? ["-r", String(rate)] : []),589 args,
588 "-i",590 cwd: dir,
589 videoSrc,591 });
590 ...(audioSrc ? ["-i", audioSrc] : []),
591 ];
592 }
593 } catch (err: any) {
594 if (err?.code !== "ENOENT") throw err;
595 }
596
597 const args = transcodeRules.getVideoArgs(preset, base, inputArgs);
598 try {
599 const fakeProgress = new Progress({ text: spin.text, spinner: null });
600 fakeProgress.stop();
601 spin.format = (now: number) => fakeProgress.format(now);
602 // @ts-expect-error
603 fakeProgress.redraw = () => spin.redraw();
604
605 await ffmpeg.spawn({
606 ffmpeg: ffmpegBin!,
607 title: fakeProgress.text,
608 progress: fakeProgress,
609 args,
610 cwd: base,
611 });
612 return await collectFiles();
613 } catch (err) {
614 for (const file of await collectFiles()) {
615 try {
616 fs.rm(file);
617 } catch {}
618 }
619 throw err;
620 }
621
622 async function collectFiles(): Promise<string[]> {
623 return (await fs.readdir(base))
624 .filter((basename) => basename.startsWith(preset.id))
625 .map((basename) => path.join(base, basename));
626 }
627 });592 });
628 },593 },
629}));594}));
595const procVideoAudios = transcodeRules.audioFormats.map<Process>((preset) => ({
596 name: `encode opus ${UNWRAP(qualityMap[UNWRAP(preset.id)])}`,
597 include: rules.extsVideo,
598 enable: ffmpegBin != null,
599 depends: [procDuration.name, procDimensions.name],
600 async run({ path, mediaFile, spin }) {
601 if ((mediaFile.duration ?? 0) < 5) return;
602 await derived.produce(mediaFile, `opus-${preset.id}`, async (dir) => {
603 const input = await videoInputArgsCache.getOrRun(path);
604 if (!input.audio) return;
605 const args = transcodeRules.getOpusAudioArgs(preset, input.audio, dir);
606
607 const fakeProgress = new Progress({ text: spin.text, spinner: null });
608 fakeProgress.stop();
609 spin.format = (now: number) => fakeProgress.format(now);
610 // @ts-expect-error
611 fakeProgress.redraw = () => spin.redraw();
612
613 await ffmpeg.spawn({
614 ffmpeg: ffmpegBin!,
615 title: fakeProgress.text,
616 progress: fakeProgress,
617 args,
618 cwd: dir,
619 });
620 });
621 },
622}));
623const procDash: Process = {
624 name: `encode mpeg-dash`,
625 include: rules.extsVideo,
626 enable: ffmpegBin != null,
627 depends: [...procVideos, ...procVideoAudios].map((x) => x.name),
628 async run({ path, mediaFile, spin }) {
629 if ((mediaFile.duration ?? 0) < 5) return;
630 await derived.produce(mediaFile, `dash-av1`, async (dir) => {
631 const input = await videoInputArgsCache.getOrRun(path);
632 const videos = transcodeRules.videoFormats.map(
633 (preset) =>
634 UNWRAP(dir.parent).join(
635 `av1-${preset.id}`,
636 transcodeRules.av1FileName,
637 ),
638 );
639 const audios = input.audio
640 ? transcodeRules.videoFormats.map(
641 (preset) =>
642 UNWRAP(dir.parent).join(
643 `av1-${preset.id}`,
644 transcodeRules.av1FileName,
645 ),
646 )
647 : [];
648 const args = transcodeRules.getMpegDashArgs(videos, audios, dir);
649
650 await dir.join("d").makeDir();
651
652 const fakeProgress = new Progress({ text: spin.text, spinner: null });
653 fakeProgress.stop();
654 spin.format = (now: number) => fakeProgress.format(now);
655 // @ts-expect-error
656 fakeProgress.redraw = () => spin.redraw();
657
658 await ffmpeg.spawn({
659 ffmpeg: ffmpegBin!,
660 title: fakeProgress.text,
661 progress: fakeProgress,
662 args,
663 cwd: dir,
664 });
665 });
666 },
667};
668const procH264Hls: Process = {
669 name: `encode h.264 hls`,
670 include: rules.extsVideo,
671 enable: ffmpegBin != null,
672 depends: [procDuration.name, procDimensions.name],
673 async run({ path, mediaFile, spin }) {
674 if ((mediaFile.duration ?? 0) < 5) return;
675 await derived.produce(mediaFile, `hls`, async (dir) => {
676 const input = await videoInputArgsCache.getOrRun(path);
677 const args = transcodeRules.getH264HlsArgs(input, dir);
678
679 const fakeProgress = new Progress({ text: spin.text, spinner: null });
680 fakeProgress.stop();
681 spin.format = (now: number) => fakeProgress.format(now);
682 // @ts-expect-error
683 fakeProgress.redraw = () => spin.redraw();
684
685 await ffmpeg.spawn({
686 ffmpeg: ffmpegBin!,
687 title: fakeProgress.text,
688 progress: fakeProgress,
689 args,
690 cwd: dir,
691 });
692 });
693 },
694};
630695
631const procCompression = [696const procCompression = [
632 { name: "gzip", fn: () => zlib.createGzip({ level: 9 }) },697 { name: "gzip", fn: () => zlib.createGzip({ level: 9 }) },
633 { name: "zstd", fn: () => zlib.createZstdCompress() },698 { name: "zstd", fn: () => zlib.createZstdCompress() },
634].map(699].map<Process>(
635 ({ name, fn }) =>700 ({ name, fn }) => ({
636 ({701 name: `compress ${name}`,
637 name: `compress ${name}`,702 exclude: rules.extsPreCompressed,
638 exclude: rules.extsPreCompressed,703 async run({ path, mediaFile }) {
639 async run({ absPath, mediaFile }) {704 await derived.produce(mediaFile, name, async (dir) => {
640 if ((mediaFile.size ?? 0) < 10) return;705 await stream.promises.pipeline(
641 await produceAsset(`${mediaFile.hash}/${name}`, async (base) => {706 fs.createReadStream(path.toString()),
642 fs.mkdirSync(path.dirname(base));707 fn(),
643 await stream.promises.pipeline(708 fs.createWriteStream(dir.join(name).toString()),
644 fs.createReadStream(absPath),709 );
645 fn(),710 });
646 fs.createWriteStream(base),711 },
647 );712 }),
648 return [base];
649 });
650 },
651 }) satisfies Process as Process,
652);713);
653714
654const processors = [715const processors = [
...@@ -658,7 +719,10 @@ const processors = [...@@ -658,7 +719,10 @@ const processors = [
658 procHighlightCode,719 procHighlightCode,
659 procImageSubsets,720 procImageSubsets,
660 ...procVideos,721 ...procVideos,
722 ...procVideoAudios,
723 procDash,
661 ...procCompression,724 ...procCompression,
725 procH264Hls,
662].map((process, id, all) => {726].map((process, id, all) => {
663 const strIndex = (id: number) => String.fromCharCode("a".charCodeAt(0) + id);727 const strIndex = (id: number) => String.fromCharCode("a".charCodeAt(0) + id);
664 return {728 return {
...@@ -688,53 +752,22 @@ function resizeDimensions(w: number, h: number, desiredWidth: number) {...@@ -688,53 +752,22 @@ function resizeDimensions(w: number, h: number, desiredWidth: number) {
688 return { w: desiredWidth, h: Math.floor((h / w) * desiredWidth) };752 return { w: desiredWidth, h: Math.floor((h / w) * desiredWidth) };
689}753}
690754
691async function produceAsset(
692 key: string,
693 builder: (prefix: string) => Promise<string[]>,
694) {
695 const asset = AssetRef.putOrIncrement(key);
696 try {
697 if (asset.refs === 1) {
698 const paths = await builder(path.join(workDir, key));
699 asset.addFiles(
700 paths.map((file) => path.relative(workDir, file).replaceAll("\\", "/")),
701 );
702 }
703 return {
704 [Symbol.dispose]: () => asset.unref(),
705 };
706 } catch (err: any) {
707 if (err && typeof err === "object") err.assetKey = key;
708 asset.unref();
709 throw err;
710 }
711}
712
713async function unproduceAsset(key: string) {
714 const ref = AssetRef.get(key);
715 if (ref) {
716 ref.unref();
717 console.warn(`TODO: unref ${key}`);
718 // TODO: remove associated files from target
719 }
720}
721
722interface UpdateMetadataJob {755interface UpdateMetadataJob {
723 absPath: string;756 path: Path;
724 publicPath: string;757 publicPath: string;
725 stat: fs.Stats | null;758 stat: fs.Stats | null;
726 mediaFile: MediaFile | null;759 mediaFile: MediaFile | null;
727}760}
728761
729interface ProcessFileArgs {762interface ProcessFileArgs {
730 absPath: string;763 path: Path;
731 stat: fs.Stats;764 stat: fs.Stats;
732 mediaFile: MediaFile;765 mediaFile: MediaFile;
733 spin: Spinner;766 spin: Spinner;
734}767}
735768
736interface ProcessJob {769interface ProcessJob {
737 absPath: string;770 path: Path;
738 stat: fs.Stats;771 stat: fs.Stats;
739 mediaFile: MediaFile;772 mediaFile: MediaFile;
740 processor: (typeof processors)[0];773 processor: (typeof processors)[0];
...@@ -743,10 +776,10 @@ interface ProcessJob {...@@ -743,10 +776,10 @@ interface ProcessJob {
743 needs: number;776 needs: number;
744}777}
745778
746export function skipBasename(basename: string): boolean {779function skipBasename(basename: string): boolean {
747 // dot files must be incrementally tracked780 // dot files must be incrementally tracked
748 if (basename === ".dirsort") return true;781 if (basename === ".dirsort") return false;
749 if (basename === ".friends") return true;782 if (basename === ".friends") return false;
750783
751 return (784 return (
752 basename.startsWith(".") ||785 basename.startsWith(".") ||
...@@ -758,13 +791,12 @@ export function skipBasename(basename: string): boolean {...@@ -758,13 +791,12 @@ export function skipBasename(basename: string): boolean {
758 );791 );
759}792}
760793
761export function toPublicPath(absPath: string) {794function toPublicPath(diskPath: Path) {
762 ASSERT(path.isAbsolute(absPath), "non-absolute " + absPath);795 if (diskPath.toString() === root) return "/";
763 if (absPath === root) return "/";796 return "/" + path.relative(root, diskPath.toString()).replaceAll("\\", "/");
764 return "/" + path.relative(root, absPath).replaceAll("\\", "/");
765}797}
766798
767export function testProgram(name: string, helpArgument: string) {799function testProgram(name: string, helpArgument: string) {
768 try {800 try {
769 child_process.spawnSync(name, [helpArgument]);801 child_process.spawnSync(name, [helpArgument]);
770 return name;802 return name;
...@@ -774,6 +806,118 @@ export function testProgram(name: string, helpArgument: string) {...@@ -774,6 +806,118 @@ export function testProgram(name: string, helpArgument: string) {
774 return null;806 return null;
775}807}
776808
809// Helper function to check and remove location metadata
810async function scrubLocationMetadata(
811 path: Path,
812 stats: fs.Stats,
813): Promise<boolean> {
814 const ext = path.ext.toLowerCase();
815 if (!rules.extsScrubExif.has(ext)) return false;
816
817 let hasLocation = false;
818 let args: string[] = [];
819
820 // Check for location metadata based on file type
821 const tempOutput = UNWRAP(path.parent).join(`.tmp.${path.base}`);
822 switch (ext) {
823 case ".jpg":
824 case ".jpeg":
825 case ".png":
826 const { stdout: gpsCheck } = await subprocess.exec("exiftool", [
827 "-gps:all",
828 path.toString(),
829 ]);
830 hasLocation = gpsCheck.trim().length > 0;
831 args = ["-gps:all=", path.toString(), "-o", tempOutput.toString()];
832 break;
833 case ".mov":
834 case ".mp4":
835 const { stdout: videoCheck } = await subprocess.exec("exiftool", [
836 "-ee",
837 "-G3",
838 "-s",
839 path.toString(),
840 ]);
841 hasLocation = videoCheck.includes("GPS") ||
842 videoCheck.includes("Location");
843 args = [
844 "-gps:all=",
845 "-xmp:all=",
846 path.toString(),
847 "-o",
848 tempOutput.toString(),
849 ];
850 break;
851 case ".m4a":
852 const { stdout: m4aCheck } = await subprocess.exec("exiftool", [
853 "-ee",
854 "-G3",
855 "-s",
856 path.toString(),
857 ]);
858 hasLocation = m4aCheck.includes("GPS") ||
859 m4aCheck.includes("Location") ||
860 m4aCheck.includes("Filename") ||
861 m4aCheck.includes("Title");
862
863 if (hasLocation) {
864 args = [
865 "-gps:all=",
866 "-location:all=",
867 "-filename:all=",
868 "-title=",
869 "-m4a:all=",
870 path.toString(),
871 "-o",
872 tempOutput.toString(),
873 ];
874 }
875 break;
876 }
877
878 const accessTime = stats.atime;
879 const modTime = stats.mtime;
880
881 let backup: Path | null = null;
882 try {
883 if (hasLocation) {
884 // Prepare a backup
885 const tmp = UNWRAP(path.parent).join(`.tmp.backup.${path.base}`);
886 await fsp.copyFile(path.toString(), tmp.toString());
887 await fsp.utimes(tmp.toString(), accessTime, modTime);
888 backup = tmp;
889
890 // Remove metadata
891 await subprocess.exec("exiftool", args);
892 if (!tempOutput.ifExistsSync()) {
893 throw new Error(`Failed to create output file: ${tempOutput}`);
894 }
895
896 // Restore original timestamps
897 await fsp.rename(tempOutput.toString(), path);
898 await fsp.utimes(path.toString(), accessTime, modTime);
899
900 // Backup is no longer needed
901 await fsp.unlink(backup.toString());
902
903 console.info(
904 `Scrubbed location metadata in ${path.relative(Path.resolve(root))}`,
905 );
906 return true;
907 }
908 } catch (error) {
909 if (backup) {
910 await fsp.rename(backup.toString(), path.toString());
911 }
912 if (fs.existsSync(tempOutput.toString())) {
913 await fsp.unlink(tempOutput.toString());
914 }
915 throw error;
916 }
917
918 return false;
919}
920
777const monthMilliseconds = 30 * 24 * 60 * 60 * 1000;921const monthMilliseconds = 30 * 24 * 60 * 60 * 1000;
778922
779import { Progress } from "@paperclover/console/Progress";923import { Progress } from "@paperclover/console/Progress";
...@@ -781,15 +925,16 @@ import { Spinner } from "@paperclover/console/Spinner";...@@ -781,15 +925,16 @@ import { Spinner } from "@paperclover/console/Spinner";
781import * as async from "#sitegen/async";925import * as async from "#sitegen/async";
782import * as fs from "#sitegen/fs";926import * as fs from "#sitegen/fs";
783import * as subprocess from "#sitegen/subprocess";927import * as subprocess from "#sitegen/subprocess";
928import { Path } from "#sitegen/path";
784929
785import * as path from "node:path";
786import * as zlib from "node:zlib";
787import * as child_process from "node:child_process";930import * as child_process from "node:child_process";
788import * as crypto from "node:crypto";931import * as crypto from "node:crypto";
932import * as fsp from "node:fs/promises";
933import * as path from "node:path";
789import * as stream from "node:stream";934import * as stream from "node:stream";
935import * as zlib from "node:zlib";
790936
791import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts";937import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts";
792import { AssetRef } from "@/file-viewer/models/AssetRef.ts";
793import { FilePermissions } from "@/file-viewer/models/FilePermissions.ts";938import { FilePermissions } from "@/file-viewer/models/FilePermissions.ts";
794import {939import {
795 formatDate,940 formatDate,
...@@ -800,5 +945,7 @@ import * as rules from "@/file-viewer/rules.ts";...@@ -800,5 +945,7 @@ import * as rules from "@/file-viewer/rules.ts";
800import * as highlight from "@/file-viewer/highlight.ts";945import * as highlight from "@/file-viewer/highlight.ts";
801import * as ffmpeg from "@/file-viewer/ffmpeg.ts";946import * as ffmpeg from "@/file-viewer/ffmpeg.ts";
802import * as rsync from "@/file-viewer/rsync.ts";947import * as rsync from "@/file-viewer/rsync.ts";
948import * as derived from "@/file-viewer/models/derived.ts";
803import * as transcodeRules from "@/file-viewer/transcode-rules.ts";949import * as transcodeRules from "@/file-viewer/transcode-rules.ts";
950
804import { rawFileRoot as root } from "../paths.ts";951import { rawFileRoot as root } from "../paths.ts";
src/file-viewer/ffmpeg.ts+24-4
...@@ -23,7 +23,7 @@ export interface SpawnOptions {...@@ -23,7 +23,7 @@ export interface SpawnOptions {
23 title: string;23 title: string;
24 ffmpeg?: string;24 ffmpeg?: string;
25 progress?: Progress;25 progress?: Progress;
26 cwd: string;26 cwd: Path;
27}27}
2828
29export async function spawn(options: SpawnOptions) {29export async function spawn(options: SpawnOptions) {
...@@ -31,7 +31,7 @@ export async function spawn(options: SpawnOptions) {...@@ -31,7 +31,7 @@ export async function spawn(options: SpawnOptions) {
31 const proc = child_process.spawn(ffmpeg, [...defaultExtraOptions, ...args], {31 const proc = child_process.spawn(ffmpeg, [...defaultExtraOptions, ...args], {
32 stdio: ["ignore", "inherit", "pipe"],32 stdio: ["ignore", "inherit", "pipe"],
33 env: { ...process.env, SVT_LOG: "2" },33 env: { ...process.env, SVT_LOG: "2" },
34 cwd,34 cwd: cwd.toString(),
35 });35 });
36 const parser = new Parse();36 const parser = new Parse();
37 const bar = options.progress ?? new Progress({ text: title });37 const bar = options.progress ?? new Progress({ text: title });
...@@ -66,12 +66,30 @@ export async function spawn(options: SpawnOptions) {...@@ -66,12 +66,30 @@ export async function spawn(options: SpawnOptions) {
66 e.args = [ffmpeg, ...args].join(" ");66 e.args = [ffmpeg, ...args].join(" ");
67 e.code = code;67 e.code = code;
68 e.signal = signal;68 e.signal = signal;
69 bar.error(e.message);69 bar.stop();
70 return e;70 throw e;
71 }71 }
72 bar.success(title);72 bar.success(title);
73}73}
7474
75// this is janky
76export async function probeAudioStreams(path: Path) {
77 const { stdout } = await subprocess.spawnAndWait({
78 cmd: [
79 "ffprobe",
80 "-v",
81 "quiet",
82 "-select_streams",
83 "a",
84 "-show_entries",
85 "stream=index",
86 path.toString(),
87 ],
88 stdio: "pipe",
89 });
90 return stdout?.byteLength ?? 0 > 0;
91}
92
75export class Parse {93export class Parse {
76 parsingStart = true;94 parsingStart = true;
77 inIndentedIgnore: null | "out" | "inp" | "other" = null;95 inIndentedIgnore: null | "out" | "inp" | "other" = null;
...@@ -158,8 +176,10 @@ export class Parse {...@@ -158,8 +176,10 @@ export class Parse {
158}176}
159177
160import * as child_process from "node:child_process";178import * as child_process from "node:child_process";
179import * as subprocess from "#sitegen/subprocess";
161import * as readline from "node:readline";180import * as readline from "node:readline";
162import * as process from "node:process";181import * as process from "node:process";
163import events from "node:events";182import events from "node:events";
164import * as path from "node:path";183import * as path from "node:path";
165import { Progress } from "@paperclover/console/Progress";184import { Progress } from "@paperclover/console/Progress";
185import type { Path } from "#sitegen/path";
src/file-viewer/models/AssetRef.ts deleted-73
...@@ -1,73 +0,0 @@
1const db = getDb("cache.sqlite");
2db.table(
3 "asset_refs",
4 /* SQL */ `
5 create table if not exists asset_refs (
6 id integer primary key autoincrement,
7 key text not null UNIQUE,
8 refs integer not null
9 );
10 create table if not exists asset_ref_files (
11 file text not null,
12 id integer not null,
13 foreign key (id) references asset_refs(id) ON DELETE CASCADE
14 );
15 create index asset_ref_files_id on asset_ref_files(id);
16`,
17);
18
19/**
20 * Uncompressed files are read directly from the media store root. Derivied
21 * assets like compressed files, optimized images, and streamable video are
22 * stored in the `derived` folder. After scanning, the derived assets are
23 * uploaded into the store (storage1/clofi-derived dataset on NAS). Since
24 * multiple files can share the same hash, the number of references is
25 * tracked, and the derived content is only produced once. This means if a
26 * file is deleted, it should only decrement a reference count; deleting it
27 * once all references are removed.
28 */
29export class AssetRef {
30 /** Key which aws referenced */
31 id!: number;
32 key!: string;
33 refs!: number;
34
35 unref() {
36 decrementQuery.run(this.key);
37 deleteUnreferencedQuery.run().changes > 0;
38 }
39
40 addFiles(files: string[]) {
41 for (const file of files) {
42 addFileQuery.run({ id: this.id, file });
43 }
44 }
45
46 static get(key: string) {
47 return getQuery.get(key);
48 }
49
50 static putOrIncrement(key: string) {
51 putOrIncrementQuery.get(key);
52 return UNWRAP(AssetRef.get(key));
53 }
54}
55
56const getQuery = db.prepare<[key: string]>(/* SQL */ `
57 select * from asset_refs where key = ?;
58`).as(AssetRef);
59const putOrIncrementQuery = db.prepare<[key: string]>(/* SQL */ `
60 insert into asset_refs (key, refs) values (?, 1)
61 on conflict(key) do update set refs = refs + 1;
62`);
63const decrementQuery = db.prepare<[key: string]>(/* SQL */ `
64 update asset_refs set refs = refs - 1 where key = ? and refs > 0;
65`);
66const deleteUnreferencedQuery = db.prepare(/* SQL */ `
67 delete from asset_refs where refs <= 0;
68`);
69const addFileQuery = db.prepare<[{ id: number; file: string }]>(/* SQL */ `
70 insert into asset_ref_files (id, file) values ($id, $file);
71`);
72
73import { getDb } from "#sitegen/sqlite";
src/file-viewer/models/MediaFile.ts+2
...@@ -93,6 +93,8 @@ export class MediaFile {...@@ -93,6 +93,8 @@ export class MediaFile {
93 const dimensions = this.dimensions;93 const dimensions = this.dimensions;
94 if (!dimensions) return null;94 if (!dimensions) return null;
95 const [width, height] = dimensions.split("x").map(Number);95 const [width, height] = dimensions.split("x").map(Number);
96 ASSERT(width);
97 ASSERT(height);
96 return { width, height };98 return { width, height };
97 }99 }
98 get basename() {100 get basename() {
src/file-viewer/models/derived.ts created+157
...@@ -0,0 +1,157 @@
1// Uncompressed files are read directly from the media store root. Derivied
2// assets like compressed files, optimized images, and streamable video are
3// stored in the `derived` folder. After scanning, the derived assets are
4// uploaded into the store (storage1/clofi-derived dataset on NAS). Since
5// multiple files can share the same hash, the number of references is
6// tracked, and the derived content is only produced once. This means if a
7// file is deleted, it should only decrement a reference count; deleting it
8// once all references are removed.
9
10const db = getDb("cache.sqlite");
11db.table(
12 "asset_refs",
13 /* SQL */ `
14 create table if not exists derived_roots (
15 id integer primary key autoincrement,
16 date integer not null, -- milliseconds
17 key text not null unique
18 );
19 create table if not exists derived_files (
20 id integer primary key autoincrement,
21 file text not null,
22 root integer not null,
23 size integer not null, -- bytes
24 foreign key(root) references derived_roots(id) on delete cascade
25 );
26 create table if not exists derived_refs (
27 id integer primary key autoincrement,
28 file integer not null,
29 root integer not null,
30 foreign key(root) references derived_roots(id) on delete cascade,
31 foreign key(file) references media_files(id) on delete cascade,
32 unique(file, root) on conflict replace
33 );
34 create index derived_roots_key on derived_roots(key);
35 create index derived_files_root on derived_files(root);
36 create index derived_refs_file on derived_refs(file);
37 create index derived_refs_root on derived_refs(root);
38 `,
39);
40
41export const workDir = Path.resolve(".clover/derived");
42
43let ongoing = new Map<string, Promise<number>>();
44
45/** produce a derived */
46export async function produce(
47 file: MediaFile,
48 subkey: string,
49 producer: (root: Path) => Promise<void>,
50) {
51 const key = `${file.hash}/${subkey}`;
52 const current = ongoing.get(key);
53 let root: number | null = null;
54 if (current) {
55 await current;
56 } else {brk: {
57 root = getRootQuery.get({ key })?.id ?? null;
58 if (root) break brk;
59 const { promise, resolve, reject } = Promise.withResolvers<number>();
60 ongoing.set(key, promise);
61 try {
62 const tmp = workDir.join(key);
63 await tmp.makeOrEmptyDir();
64 await producer(tmp);
65 const filesWithStats = await Promise.all(
66 (await tmp.readDir())
67 .filter((x) => !x.base.startsWith("tmp."))
68 .map(async (x) => [x, await x.stat()] as const),
69 );
70 db.node.exec("BEGIN");
71 try {
72 root = insertRootQuery.getNonNull({ key, date: Date.now() }).id;
73 for (const [path, stats] of filesWithStats) {
74 insertFileQuery.run({
75 root,
76 file: path.relative(tmp),
77 size: stats.size,
78 });
79 }
80 } catch (e) {
81 db.node.exec("ROLLBACK");
82 throw e;
83 }
84 db.node.exec("COMMIT");
85 resolve(root);
86 } catch (e) {
87 if (root) deleteRootQuery.run({ root });
88 reject(e);
89 throw e;
90 } finally {
91 ongoing.delete(key);
92 }
93 }}
94 ASSERT(root);
95 insertRefQuery.run({ root, file: file.id });
96 return {
97 [Symbol.dispose]: () => {
98 deleteRefQuery.run({ root, file: file.id });
99 },
100 };
101}
102
103const getDerivedAssetQuery = db.prepare<
104 [number, string],
105 { size: number; key: string }
106>(/* SQL */ `
107 select df.size, rt.key
108 from derived_refs dr
109 join derived_files df on df.root = dr.root
110 join derived_roots rt on df.root = rt.id
111 where dr.file = ? and df.file like ?
112`);
113
114export function get(file: MediaFile, subPath: string) {
115 const row = getDerivedAssetQuery.get(file.id, subPath) ?? null;
116 if (row) {
117 return {
118 path: Path.resolve(derivedFileRoot).join(row.key, subPath),
119 size: row.size,
120 };
121 }
122 return null;
123}
124
125const insertRootQuery = db.prepare<
126 [{ key: string; date: number }],
127 { id: number }
128>(
129 /* SQL */ `insert into derived_roots (key, date) values ($key, $date) returning id;`,
130);
131
132const getRootQuery = db.prepare<[{ key: string }], { id: number }>(/* SQL */ `
133 select id from derived_roots where key = $key;
134`);
135
136const insertFileQuery = db.prepare<
137 [{ file: string; root: number; size: number }]
138>(/* SQL */ `
139 insert into derived_files (file, root, size) values ($file, $root, $size);
140`);
141
142const insertRefQuery = db.prepare<[{ root: number; file: number }]>(/* SQL */ `
143 insert into derived_refs (file, root) values ($file, $root);
144`);
145
146const deleteRootQuery = db.prepare<[{ root: number }]>(/* SQL */ `
147 delete from derived_roots where id = $root;
148`);
149
150const deleteRefQuery = db.prepare<[{ root: number; file: number }]>(/* SQL */ `
151 delete from derived_refs where root = $root and file = $file;
152`);
153
154import { getDb } from "#sitegen/sqlite";
155import type { MediaFile } from "./MediaFile.ts";
156import { Path } from "#sitegen/path";
157import { derivedFileRoot } from "../paths.ts";
src/file-viewer/rules.ts+1-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1// -- file extension rules --1// -- file extension rules --
22
3/** Extensions that must have EXIF/etc data stripped */3/** Extensions that must have EXIF/etc data stripped */
4export const extScrubExif = new Set([4export const extsScrubExif = new Set([
5 ".jpg",5 ".jpg",
6 ".jpeg",6 ".jpeg",
7 ".png",7 ".png",
src/file-viewer/transcode-rules.ts+205-80
...@@ -1,38 +1,27 @@...@@ -1,38 +1,27 @@
1type VideoEncodePreset = {1interface Av1Preset {
2 id: string;2 id: string;
3 codec: "av1";
4 preset: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;3 preset: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
5 mbitMax?: number;4 mbitMax?: number;
6 crf: number;5 crf: number;
7 maxHeight?: number;6 maxHeight?: number;
8 audioKbit?: number;
9 depth?: 8 | 10;7 depth?: 8 | 10;
10} | {8}
9interface OpusPreset {
11 id: string;10 id: string;
12 codec: "vp9";11 audioKbit: number;
13 crf: number;12}
14 mbitMax?: number;
15 maxHeight?: number;
16 audioKbit?: number;
17};
1813
19export const videoFormats = [14export const videoFormats: Av1Preset[] = [
20 {15 {
21 id: "au", // AV1 Ultra-High16 id: "au", // AV1 Ultra-High
22 codec: "av1",
23 preset: 1,17 preset: 1,
24 crf: 28,18 crf: 28,
25 depth: 10,19 depth: 10,
26 },20 mbitMax: 30,
27 {
28 id: "vu", // VP9 Ultra-High
29 codec: "vp9",
30 crf: 30,
31 },21 },
32 {22 {
33 id: "ah", // AV1 High23 id: "ah", // AV1 High
34 preset: 2,24 preset: 2,
35 codec: "av1",
36 mbitMax: 5,25 mbitMax: 5,
37 crf: 35,26 crf: 35,
38 depth: 10,27 depth: 10,
...@@ -40,49 +29,30 @@ export const videoFormats = [...@@ -40,49 +29,30 @@ export const videoFormats = [
40 {29 {
41 id: "am", // AV1 Medium30 id: "am", // AV1 Medium
42 preset: 2,31 preset: 2,
43 codec: "av1",
44 mbitMax: 2,32 mbitMax: 2,
45 crf: 40,33 crf: 40,
46 maxHeight: 900,34 maxHeight: 900,
47 },35 },
48 {
49 id: "vm", // VP9 Medium
50 codec: "vp9",
51 crf: 35,
52 mbitMax: 4,
53 maxHeight: 1080,
54 },
55 {36 {
56 id: "al", // AV1 Low37 id: "al", // AV1 Low
57 preset: 2,38 preset: 2,
58 codec: "av1",
59 mbitMax: 1.25,39 mbitMax: 1.25,
60 crf: 40,40 crf: 40,
61 maxHeight: 600,41 maxHeight: 600,
62 },42 },
63 {
64 id: "vl", // VP9 Low
65 codec: "vp9",
66 crf: 45,
67 mbitMax: 2,
68 maxHeight: 600,
69 },
70 {43 {
71 id: "ad", // AV1 Data-saving44 id: "ad", // AV1 Data-saving
72 codec: "av1",
73 preset: 1,45 preset: 1,
74 mbitMax: 0.5,46 mbitMax: 0.5,
75 crf: 10,47 crf: 10, // highest quality clamping to 0.5
76 maxHeight: 360,48 maxHeight: 360,
77 },49 },
78 {50];
79 id: "vl", // VP9 Low51export const audioFormats: OpusPreset[] = [
80 codec: "vp9",52 { id: "h", audioKbit: 192 },
81 crf: 10,53 { id: "m", audioKbit: 128 },
82 mbitMax: 0.75,54 { id: "d", audioKbit: 64 },
83 maxHeight: 360,55];
84 },
85] satisfies VideoEncodePreset[] as VideoEncodePreset[];
8656
87export const imageSizes = [64, 128, 256, 512, 1024, 2048];57export const imageSizes = [64, 128, 256, 512, 1024, 2048];
88export const imagePresets = [58export const imagePresets = [
...@@ -127,53 +97,208 @@ export const imagePresets = [...@@ -127,53 +97,208 @@ export const imagePresets = [
127 },97 },
128];98];
12999
130export function getVideoArgs(100export async function getVideoInputArgs(path: Path): Promise<InputArgs> {
131 preset: VideoEncodePreset,101 const config = await path.replaceExt(".json").readIfExists<any>("json");
132 outbase: string,102 if (config) {
133 input: string[],103 if (config.encoder && typeof config.encoder.videoSrc === "string") {
104 const { videoSrc, audioSrc, rate } = config.encoder;
105 return {
106 both: [
107 ...(rate ? ["-r", String(rate)] : []),
108 "-i",
109 String(videoSrc),
110 ...audioSrc ? ["-i", path.toString()] : [],
111 ],
112 video: [
113 ...(rate ? ["-r", String(rate)] : []),
114 "-i",
115 String(videoSrc),
116 ],
117 audio: audioSrc
118 ? ["-i", String(audioSrc)]
119 : (await ffmpeg.probeAudioStreams(path))
120 ? ["-i", path.toString()]
121 : null,
122 };
123 }
124 }
125 return {
126 both: ["-i", path.toString()],
127 video: ["-i", path.toString()],
128 audio: (await ffmpeg.probeAudioStreams(path))
129 ? ["-i", path.toString()]
130 : null,
131 };
132}
133
134export interface InputArgs {
135 video: string[];
136 both: string[];
137 audio: string[] | null;
138}
139
140export const opusFileName = "tmp.opus.webm";
141export const av1FileName = "tmp.av1.mp4";
142
143export function getAv1VideoArgs(
144 av1: Av1Preset,
145 videoInputArgs: string[],
146 outDir: Path,
134) {147) {
135 const cmd = [...input];148 const { preset, mbitMax, crf, maxHeight, depth } = av1;
149 const cmd = [...videoInputArgs];
136150
137 if (preset.codec === "av1") {151 cmd.push("-c:v", "libsvtav1");
138 cmd.push("-c:v", "libsvtav1");152 cmd.push(
139 cmd.push(153 "-svtav1-params",
140 "-svtav1-params",154 [
141 [155 `preset=${preset}`,
142 `preset=${preset.preset}`,156 "keyint=2s",
143 "keyint=2s",157 depth && `input-depth=${depth}`, // EncoderBitDepth
144 preset.depth && `input-depth=${preset.depth}`, // EncoderBitDepth158 `crf=${crf}`, // ConstantRateFactor
145 `crf=${preset.crf}`, // ConstantRateFactor159 mbitMax && `mbr=${mbitMax}m`, // MaxBitRate
146 preset.mbitMax && `mbr=${preset.mbitMax}m`, // MaxBitRate160 "tune=1", // Tune, PSNR
147 "tune=1", // Tune, PSNR161 "enable-overlays=1", // EnableOverlays
148 "enable-overlays=1", // EnableOverlays162 "fast-decode=1", // FastDecode
149 "fast-decode=1", // FastDecode163 "scm=2", // ScreenContentMode, adaptive
150 "scm=2", // ScreenContentMode, adaptive164 ].filter(Boolean).join(":"),
151 ].filter(Boolean).join(":"),165 );
152 );166 if (maxHeight != null) {
153 } else if (preset.codec === "vp9") {167 cmd.push("-vf", `scale=-2:min(${maxHeight}\\,ih)`);
154 // Not much research has gone into this, since it is only going to be used on old Safari browsers.
155 cmd.push("-c:v", "libvpx-vp9");
156 cmd.push("-crf", String(preset.crf));
157 cmd.push("-b:v", preset.mbitMax ? `${preset.mbitMax * 1000}k` : "0");
158 } else preset satisfies never;
159
160 if (preset.maxHeight != null) {
161 cmd.push("-vf", `scale=-2:min(${preset.maxHeight}\\,ih)`);
162 }168 }
163169
170 cmd.push("-y");
171 cmd.push(outDir.join(av1FileName).toString());
172
173 return cmd;
174}
175
176export function getOpusAudioArgs(
177 opus: OpusPreset,
178 audioInputArgs: string[],
179 outDir: Path,
180) {
181 const { audioKbit } = opus;
182 const cmd = [...audioInputArgs];
183
164 cmd.push("-c:a", "libopus");184 cmd.push("-c:a", "libopus");
165 cmd.push("-b:a", (preset.audioKbit ?? 192) + "k");185 cmd.push("-b:a", audioKbit + "k");
166186
167 cmd.push("-y");187 cmd.push("-y");
188 cmd.push(outDir.join(opusFileName).toString());
189
190 return cmd;
191}
192
193export function getMpegDashArgs(
194 videos: Array<Path>,
195 audios: Array<Path>,
196 outDir: Path,
197) {
198 const cmd = [];
199
200 // inputs
201 videos.forEach((file) => cmd.push("-i", file.toString()));
202 audios.forEach((file) => cmd.push("-i", file.toString()));
203
204 // map streams
205 videos.forEach((_, i) => {
206 cmd.push("-map", `${i}:0`);
207 });
208 audios.forEach((_, i) => {
209 cmd.push("-map", `${videos.length + i}:0`);
210 });
211
212 // copy codecs
213 cmd.push("-c", "copy");
214
215 cmd.push("-f", "dash");
216 cmd.push("-seg_duration", "4");
217 cmd.push("-init_seg_name", "d/$RepresentationID$.init.$ext$");
218 cmd.push("-media_seg_name", "d/$RepresentationID$.$Number$.$ext$");
219 cmd.push("-use_timeline", "1");
220 cmd.push("-use_template", "1");
221
222 // build adaptation sets string
223 const videoIndices = videos.map((_, i) => i).join(",");
224 const audioIndices = audios.map((_, i) => videos.length + i).join(",");
225 cmd.push(
226 "-adaptation_sets",
227 `id=0,streams=${videoIndices} id=1,streams=${audioIndices}`,
228 );
229
230 cmd.push("-y");
231 cmd.push(outDir.join("dash.mpd").toString());
232
233 return cmd;
234}
235
236interface HlsPreset {
237 id: string;
238 videoBitrate: string;
239 audioBitrate: string;
240 maxHeight: number;
241}
242
243const hlsPresets: HlsPreset[] = [
244 { id: "0", videoBitrate: "3000k", audioBitrate: "192k", maxHeight: 1080 },
245 { id: "1", videoBitrate: "1200k", audioBitrate: "128k", maxHeight: 720 },
246 { id: "2", videoBitrate: "400k", audioBitrate: "96k", maxHeight: 580 },
247];
248
249export function getH264HlsArgs(
250 inputArgs: InputArgs,
251 outDir: Path,
252) {
253 const cmd = [...inputArgs.both];
254
255 // map streams
256 hlsPresets.forEach(() => {
257 cmd.push("-map", "0:v:0");
258 if (inputArgs.audio) {
259 cmd.push("-map", "0:a:0");
260 }
261 });
262
263 // encode each variant
264 hlsPresets.forEach((preset, i) => {
265 if (inputArgs.audio) {
266 const vIdx = i * 2;
267 const aIdx = i * 2 + 1;
268 cmd.push(`-c:v:${vIdx}`, "libx264");
269 cmd.push(`-b:v:${vIdx}`, preset.videoBitrate);
270 cmd.push(`-c:a:${aIdx}`, "aac");
271 cmd.push(`-b:a:${aIdx}`, preset.audioBitrate);
272 cmd.push(`-filter:v:${vIdx}`, `scale=-2:min(${preset.maxHeight}\\,ih)`);
273 } else {
274 cmd.push(`-c:v:${i}`, "libx264");
275 cmd.push(`-b:v:${i}`, preset.videoBitrate);
276 cmd.push(`-filter:v:${i}`, `scale=-2:min(${preset.maxHeight}\\,ih)`);
277 }
278 });
279
168 cmd.push("-f", "hls");280 cmd.push("-f", "hls");
281 cmd.push("-hls_time", "2");
169 cmd.push("-hls_list_size", "0");282 cmd.push("-hls_list_size", "0");
170 cmd.push("-hls_segment_type", "fmp4");283 cmd.push("-hls_segment_type", "fmp4");
171 cmd.push("-hls_time", "2");
172 cmd.push("-hls_allow_cache", "1");284 cmd.push("-hls_allow_cache", "1");
173 cmd.push("-hls_fmp4_init_filename", preset.id + ".mp4");285
174 cmd.push(path.join(outbase, preset.id + ".m3u8"));286 if (inputArgs.audio) {
287 cmd.push(
288 "-var_stream_map",
289 hlsPresets.map((_, i) => `v:${i * 2},a:${i * 2 + 1}`).join(" "),
290 );
291 } else {
292 cmd.push("-var_stream_map", hlsPresets.map((_, i) => `v:${i}`).join(" "));
293 }
294
295 cmd.push("-master_pl_name", "master.m3u8");
296 cmd.push("-hls_segment_filename", "hls.%v.%03d.ts");
297
298 cmd.push("-y");
299 cmd.push(outDir.join("hls.%v.m3u8").toString());
175300
176 return cmd;301 return cmd;
177}302}
178303import type { Path } from "#sitegen/path";
179import * as path from "node:path";304import * as ffmpeg from "./ffmpeg.ts";