refactor: draw the src/scripts boundary, drop dead files
Make each directory answerable by path alone: src/ is what the Remotion build compiles, scripts/ is what you run by hand. Removed: - `ffmpeg.sh` — never worked; every parameter was assigned `$1`, so bake/bypass were broken from the start. Superseded by `scripts/ffmpeg_tools.py bake|still`. - `src/lrc2srt.ts` — the TypeScript port; `scripts/lrc2srt.py` is the script that was actually maintained, and it now handles the multi-timestamp cases the TS version did. - `scripts/bom-gen.ts` — superseded by `scripts/bom_gen.py`. - `scripts/ffmpegusage.txt` — legacy command notebook. Every recipe in it is now reproduced by `ffmpeg_tools.py`, including the `-map 0:v:0 -map 1:a:0 -c:v copy` mux and the FLAC `-compression_level 12` case, so it has no unique content left. Also drops the `bom-gen` alias from the dispatcher (dash-case was TypeScript naming) and documents the boundary in README.md and REMOTION-QUICKSTART.md. `npm run lint` (eslint src && tsc) passes with src/ reduced to Remotion input.
This commit is contained in:
@@ -15,10 +15,21 @@ This repo houses the code-like files for NCMR music videos. Binary assets live e
|
|||||||
This branch holds the common scaffolding:
|
This branch holds the common scaffolding:
|
||||||
|
|
||||||
- **Remotion project** — entry point, root component, and a placeholder composition
|
- **Remotion project** — entry point, root component, and a placeholder composition
|
||||||
- **`ffmpeg.sh`** — helper for baking ASS subtitles or bypassing with static image + audio
|
- **`scripts/`** — Python toolchain (stdlib only) for subtitles, ffmpeg and asset manifests
|
||||||
- **`src/lrc2srt.ts`** — legacy LRC → SRT subtitle converter
|
|
||||||
- **Build config** — TypeScript, ESLint, Prettier, Remotion studio
|
- **Build config** — TypeScript, ESLint, Prettier, Remotion studio
|
||||||
|
|
||||||
|
### `src/` vs `scripts/`
|
||||||
|
|
||||||
|
Two directories, two jobs:
|
||||||
|
|
||||||
|
- **`src/`** — Remotion input only. Everything here is compiled by `tsc`/Remotion
|
||||||
|
and linted by `npm run lint`.
|
||||||
|
- **`scripts/`** — operator tooling. Python, standard library only, run directly
|
||||||
|
(`python3 scripts/tools.py --help`). Nothing here is part of the Remotion build.
|
||||||
|
|
||||||
|
Keep the boundary sharp: anything the video build compiles belongs in `src/`, and
|
||||||
|
anything you run by hand belongs in `scripts/`.
|
||||||
|
|
||||||
### Song branches
|
### Song branches
|
||||||
|
|
||||||
For each song, create a branch `<song-name>/master` (and optionally feature branches
|
For each song, create a branch `<song-name>/master` (and optionally feature branches
|
||||||
|
|||||||
+13
-5
@@ -42,17 +42,25 @@ The Studio auto-reloads on file changes. No manual refresh needed.
|
|||||||
|
|
||||||
```
|
```
|
||||||
.
|
.
|
||||||
├── src/
|
├── src/ # Remotion input — compiled by tsc/Remotion
|
||||||
│ ├── index.ts # Entry point — calls registerRoot()
|
│ ├── index.ts # Entry point — calls registerRoot()
|
||||||
│ ├── Root.tsx # React root component
|
│ ├── Root.tsx # React root component
|
||||||
│ ├── Composition.tsx # Composition definitions (placeholder)
|
│ └── Composition.tsx # Composition definitions (placeholder)
|
||||||
│ └── lrc2srt.ts # LRC → SRT subtitle converter
|
├── scripts/ # Operator tooling — Python, stdlib only
|
||||||
|
│ ├── tools.py # Dispatcher: python3 scripts/tools.py --help
|
||||||
|
│ ├── ffmpeg_tools.py # mux / bake / still / strip / probe
|
||||||
|
│ ├── lrc2srt.py # LRC → SRT
|
||||||
|
│ ├── srt2ass.py # SRT → ASS (the step before baking)
|
||||||
|
│ ├── bom_gen.py # Bill of materials for untracked assets
|
||||||
|
│ └── hwprobe.py # What this machine can actually encode
|
||||||
├── package.json # Remotion + React + Tailwind deps
|
├── package.json # Remotion + React + Tailwind deps
|
||||||
├── remotion.config.ts # CLI config (rspack, jpeg output format)
|
├── remotion.config.ts # CLI config (rspack, jpeg output format)
|
||||||
├── tsconfig.json # TypeScript (strict, ES2018)
|
└── tsconfig.json # TypeScript (strict, ES2018)
|
||||||
└── ffmpeg.sh # Helper for ASS subtitle baking
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`src/` and `scripts/` do not mix: the former is what the video build compiles, the
|
||||||
|
latter is what you run by hand.
|
||||||
|
|
||||||
## Compositions
|
## Compositions
|
||||||
|
|
||||||
Compositions are defined in `src/Composition.tsx`. Each has:
|
Compositions are defined in `src/Composition.tsx`. Each has:
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ffmpeg helper — bake ASS subtitles into video, or bypass with static image + audio
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ffmpeg.sh bake <video> <audio> <subtitles.ass> <output>
|
|
||||||
# ffmpeg.sh bypass <image> <audio> <output>
|
|
||||||
#
|
|
||||||
# Prerequisites: ffmpeg must be on $PATH.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
echo "Usage:"
|
|
||||||
echo " $0 bake <video> <audio> <subtitles.ass> <output>"
|
|
||||||
echo " $0 bypass <image> <audio> <output>"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
[ $# -eq 4 ] || usage
|
|
||||||
|
|
||||||
MODE="$1"
|
|
||||||
shift
|
|
||||||
|
|
||||||
case "$MODE" in
|
|
||||||
bake)
|
|
||||||
VIDEO="$1"
|
|
||||||
AUDIO="$1"
|
|
||||||
ASS="$1"
|
|
||||||
OUTPUT="$1"
|
|
||||||
|
|
||||||
ffmpeg -y \
|
|
||||||
-i "$VIDEO" \
|
|
||||||
-i "$AUDIO" \
|
|
||||||
-vf "ass=$ASS" \
|
|
||||||
-c:v libx264 \
|
|
||||||
-c:a aac \
|
|
||||||
-shortest \
|
|
||||||
"$OUTPUT"
|
|
||||||
;;
|
|
||||||
|
|
||||||
bypass)
|
|
||||||
IMAGE="$1"
|
|
||||||
AUDIO="$1"
|
|
||||||
OUTPUT="$1"
|
|
||||||
|
|
||||||
ffmpeg -y \
|
|
||||||
-loop 1 \
|
|
||||||
-i "$IMAGE" \
|
|
||||||
-i "$AUDIO" \
|
|
||||||
-c:v libx264 \
|
|
||||||
-tune stillimage \
|
|
||||||
-c:a aac \
|
|
||||||
-shortest \
|
|
||||||
"$OUTPUT"
|
|
||||||
;;
|
|
||||||
|
|
||||||
*)
|
|
||||||
usage
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
+5
-4
@@ -60,8 +60,8 @@ imported as a module. The dispatcher is a convenience, not a requirement.
|
|||||||
## Hardware encoding
|
## Hardware encoding
|
||||||
|
|
||||||
This machine is an **AMD Strix Halo** (Radeon 8060S). There is no NVIDIA card,
|
This machine is an **AMD Strix Halo** (Radeon 8060S). There is no NVIDIA card,
|
||||||
so the `nvenc` recipes in `ffmpegusage.txt` are reference only — they will not
|
so `nvenc` is unavailable here. What does work is **VA-API** via the Mesa
|
||||||
run here. What does work is **VA-API** via the Mesa `radeonsi` driver:
|
`radeonsi` driver:
|
||||||
|
|
||||||
| Codec | Encoder | Status |
|
| Codec | Encoder | Status |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -173,8 +173,9 @@ Notes on the choices baked in:
|
|||||||
before it has fully downloaded.
|
before it has fully downloaded.
|
||||||
|
|
||||||
For anything these do not cover, use `--dry-run` to print the generated command,
|
For anything these do not cover, use `--dry-run` to print the generated command,
|
||||||
copy it, and edit it — the wrapper is a convenience, not a cage. The legacy
|
copy it, and edit it — the wrapper is a convenience, not a cage. `hwprobe.py`
|
||||||
recipes this grew out of are preserved in `ffmpegusage.txt`.
|
reports what this host can actually encode, so you can tell at a glance whether a
|
||||||
|
recipe will need a hardware-specific encoder.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// BOM generator — walks asset directories, lists files, prompts for descriptions, outputs BOM.yaml
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// npx tsx scripts/bom-gen.ts [asset-root]
|
|
||||||
//
|
|
||||||
// Expected directory structure:
|
|
||||||
// audio/ — audio files
|
|
||||||
// images/ — image files
|
|
||||||
// fonts/ — font files
|
|
||||||
// videos/ — material video clips
|
|
||||||
//
|
|
||||||
// Output: BOM.yaml (YAML frontmatter-style)
|
|
||||||
|
|
||||||
import { readdirSync, statSync, writeFileSync } from "node:fs";
|
|
||||||
import { resolve, relative, join } from "node:path";
|
|
||||||
import { createInterface } from "node:readline";
|
|
||||||
|
|
||||||
type Asset = { path: string; size: number; description: string };
|
|
||||||
|
|
||||||
const ASSET_DIRS = ["audio", "images", "fonts", "videos"];
|
|
||||||
|
|
||||||
function walk(dir: string): Asset[] {
|
|
||||||
const assets: Asset[] = [];
|
|
||||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
||||||
const full = resolve(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
assets.push(...walk(full));
|
|
||||||
} else {
|
|
||||||
assets.push({
|
|
||||||
path: relative(".", full),
|
|
||||||
size: statSync(full).size,
|
|
||||||
description: "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return assets;
|
|
||||||
}
|
|
||||||
|
|
||||||
function humanSize(bytes: number): string {
|
|
||||||
const units = ["B", "KB", "MB", "GB"];
|
|
||||||
let i = 0;
|
|
||||||
while (bytes >= 1024 && i < units.length - 1) {
|
|
||||||
bytes /= 1024;
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
return `${bytes.toFixed(1)} ${units[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const root = process.argv[2] || ".";
|
|
||||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
||||||
|
|
||||||
console.log("BOM Generator");
|
|
||||||
console.log("=============\n");
|
|
||||||
|
|
||||||
let all: Asset[] = [];
|
|
||||||
|
|
||||||
for (const dir of ASSET_DIRS) {
|
|
||||||
const full = resolve(root, dir);
|
|
||||||
try {
|
|
||||||
const assets = walk(full);
|
|
||||||
all.push(...assets);
|
|
||||||
console.log(` ${dir}/ — ${assets.length} file(s)`);
|
|
||||||
} catch {
|
|
||||||
console.log(` ${dir}/ — not found (skipped)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (all.length === 0) {
|
|
||||||
console.log("\nNo assets found. Nothing to do.\n");
|
|
||||||
rl.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalSize = all.reduce((s, a) => s + a.size, 0);
|
|
||||||
console.log(`\nTotal: ${all.length} file(s), ${humanSize(totalSize)}\n`);
|
|
||||||
|
|
||||||
for (let i = 0; i < all.length; i++) {
|
|
||||||
const a = all[i];
|
|
||||||
console.log(`[${i + 1}/${all.length}] ${a.path} (${humanSize(a.size)})`);
|
|
||||||
a.description = (await new Promise<string>((r) =>
|
|
||||||
rl.question(" Description: ", r)
|
|
||||||
)).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build YAML manually (no dependency)
|
|
||||||
let yaml = "---\nbom:\n";
|
|
||||||
for (const a of all) {
|
|
||||||
const desc = a.description.replace(/"/g, '\\"');
|
|
||||||
yaml += ` ${a.path}:\n description: "${desc}"\n`;
|
|
||||||
}
|
|
||||||
yaml += `total_files: ${all.length}\ntotal_size: ${totalSize}\n...`;
|
|
||||||
|
|
||||||
const bomPath = resolve(root, "BOM.yaml");
|
|
||||||
writeFileSync(bomPath, yaml);
|
|
||||||
console.log(`\n✓ Written to ${relative(root, bomPath)}\n`);
|
|
||||||
|
|
||||||
rl.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(console.error);
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
ffmpeg -hwaccel cuda -i pulse_raw_aac.mp4 -vf "ass=pulse.ass" -acodec copy pulse_sub_aac.mp4
|
|
||||||
|
|
||||||
ffmpeg -hwaccel cuda -i pulse_raw_flac.mkv -vf "ass=pulse.ass" -c:v h264_nvenc -acodec copy pulse_sub_flac.mkv
|
|
||||||
|
|
||||||
ffmpeg -hwaccel cuda -loop 1 -i pulse.png -i pulse.wav -c:v h264_nvenc -r 60 -c:a flac -compression_level 12 -pix_fmt yuv420p -shortest pulse.mkv
|
|
||||||
|
|
||||||
ffmpeg -hwaccel cuda -loop 1 -i pulse.png -i pulse_192.mp3 -c:v h264_nvenc -r 60 -c:a aac -b:a 320k -pix_fmt yuv420p -shortest pulse.mp4
|
|
||||||
|
|
||||||
Get-ChildItem -Filter *.mp4 | ForEach-Object {
|
|
||||||
$name = $_.BaseName
|
|
||||||
& 'ffmpeg' -i "$name.mp4" -vn -ar 44100 -ac 2 -b:a 320k "$name.mp3"
|
|
||||||
}
|
|
||||||
|
|
||||||
ffmpeg -i input_video.mp4 -vn -ar 44100 -ac 2 -q:a 0 -b:a 320k output_audio.mp3
|
|
||||||
|
|
||||||
|
|
||||||
# --- Pulse PV: mux real audio into the Remotion render (no video re-encode) ---
|
|
||||||
# pulse-pv-project/out/pulseraw.mp4 is video-only; copy its video stream as-is
|
|
||||||
# and add pulse.wav. -c:v copy = instant + lossless. Feeds the ass=... step.
|
|
||||||
|
|
||||||
# AAC (mp4), 320k:
|
|
||||||
ffmpeg -i pulse_raw.mp4 -i pulse.wav -map 0:v:0 -map 1:a:0 -c:v copy -c:a aac -b:a 320k -shortest pulse_raw_aac.mp4
|
|
||||||
|
|
||||||
# FLAC (mkv):
|
|
||||||
ffmpeg -i pulse_raw.mp4 -i pulse.wav -map 0:v:0 -map 1:a:0 -c:v copy -c:a flac -compression_level 12 -shortest pulse_raw_flac.mkv
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -35,7 +35,6 @@ ALIASES = {
|
|||||||
"ffmpeg_tools": "ffmpeg",
|
"ffmpeg_tools": "ffmpeg",
|
||||||
"ffmpeg-tools": "ffmpeg",
|
"ffmpeg-tools": "ffmpeg",
|
||||||
"bom_gen": "bom",
|
"bom_gen": "bom",
|
||||||
"bom-gen": "bom",
|
|
||||||
"lrc": "lrc2srt",
|
"lrc": "lrc2srt",
|
||||||
"srt": "srt2ass",
|
"srt": "srt2ass",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// lrc→srt converter — minimal, no external dependencies
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// node lrc2srt.ts <input.lrc> [output.srt]
|
|
||||||
//
|
|
||||||
// If output is omitted, writes to stdout.
|
|
||||||
|
|
||||||
import * as fs from "fs";
|
|
||||||
import * as path from "path";
|
|
||||||
|
|
||||||
function lrcTimeToSeconds(line: string): number | null {
|
|
||||||
const match = line.match(/\[(\d{1,2}):(\d{2})\.(\d{2,3})\]/);
|
|
||||||
if (!match) return null;
|
|
||||||
|
|
||||||
const minutes = parseInt(match[1], 10);
|
|
||||||
const seconds = parseInt(match[2], 10);
|
|
||||||
const centis = match[3].padEnd(3, "0").substring(0, 3);
|
|
||||||
const ms = parseInt(centis, 10);
|
|
||||||
|
|
||||||
return minutes * 60 + seconds + ms / 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
function secondsToSrtTime(t: number): string {
|
|
||||||
const hours = Math.floor(t / 3600);
|
|
||||||
const minutes = Math.floor((t % 3600) / 60);
|
|
||||||
const secs = Math.floor(t % 60);
|
|
||||||
const ms = Math.round((t - Math.floor(t)) * 1000);
|
|
||||||
|
|
||||||
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")},${String(ms).padStart(3, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function main() {
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
if (args.length < 1) {
|
|
||||||
console.error("Usage: lrc2srt.ts <input.lrc> [output.srt]");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = fs.readFileSync(args[0], "utf-8");
|
|
||||||
const lines = input.split(/\r?\n/);
|
|
||||||
const entries: { start: number; end: number; text: string }[] = [];
|
|
||||||
let currentStart: number | null = null;
|
|
||||||
let currentText: string | null = null;
|
|
||||||
|
|
||||||
for (const raw of lines) {
|
|
||||||
const trimmed = raw.trim();
|
|
||||||
if (!trimmed) continue;
|
|
||||||
|
|
||||||
const timeMatch = trimmed.match(/\[(\d{1,2}):(\d{2})\.(\d{2,3})\]/);
|
|
||||||
const text = trimmed.replace(/\[\d{1,2}:\d{2}\.\d{2,3}\]/g, "").trim();
|
|
||||||
|
|
||||||
if (timeMatch && !currentStart) {
|
|
||||||
currentStart = lrcTimeToSeconds(raw);
|
|
||||||
currentText = text;
|
|
||||||
} else if (currentStart && currentText) {
|
|
||||||
// If there's a time tag, the current entry ends here
|
|
||||||
entries.push({ start: currentStart, end: currentStart + 1, text: currentText });
|
|
||||||
currentStart = lrcTimeToSeconds(raw);
|
|
||||||
currentText = text;
|
|
||||||
} else if (currentStart && !currentText) {
|
|
||||||
currentText = text;
|
|
||||||
} else if (!currentStart) {
|
|
||||||
// line without time tag, skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentStart && currentText) {
|
|
||||||
entries.push({ start: currentStart, end: currentStart + 1, text: currentText });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix end times: each entry ends when the next one starts
|
|
||||||
for (let i = 0; i < entries.length; i++) {
|
|
||||||
if (i + 1 < entries.length) {
|
|
||||||
entries[i].end = entries[i + 1].start;
|
|
||||||
} else {
|
|
||||||
entries[i].end = entries[i].start + 5; // default 5s for last line
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const srtBlocks = entries.map((e, idx) => {
|
|
||||||
return `${idx + 1}\n${secondsToSrtTime(e.start)} --> ${secondsToSrtTime(e.end)}\n${e.text}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const output = srtBlocks.join("\n\n") + "\n";
|
|
||||||
const outPath = args[1];
|
|
||||||
if (outPath) {
|
|
||||||
fs.writeFileSync(outPath, output, "utf-8");
|
|
||||||
} else {
|
|
||||||
process.stdout.write(output);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
Reference in New Issue
Block a user