Compare commits
6 Commits
b73e14cdd1
...
edb5f85a75
| Author | SHA1 | Date | |
|---|---|---|---|
| edb5f85a75 | |||
| d25e693a4a | |||
| 74b70967a1 | |||
| 0de9c41c7d | |||
| 191077af1c | |||
| c86afdc75a |
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# Binary input assets (per README guideline)
|
||||||
|
# Audio
|
||||||
|
*.mp3
|
||||||
|
*.wav
|
||||||
|
*.flac
|
||||||
|
*.aac
|
||||||
|
*.ogg
|
||||||
|
*.wma
|
||||||
|
*.m4a
|
||||||
|
|
||||||
|
# Images
|
||||||
|
*.png
|
||||||
|
*.jpg
|
||||||
|
*.jpeg
|
||||||
|
*.gif
|
||||||
|
*.webp
|
||||||
|
*.svg
|
||||||
|
*.bmp
|
||||||
|
*.ico
|
||||||
|
|
||||||
|
# Fonts
|
||||||
|
*.ttf
|
||||||
|
*.otf
|
||||||
|
*.woff
|
||||||
|
*.woff2
|
||||||
|
|
||||||
|
# Material video clips
|
||||||
|
*.mp4
|
||||||
|
*.mov
|
||||||
|
*.avi
|
||||||
|
*.mkv
|
||||||
|
*.webm
|
||||||
|
|
||||||
|
# Video output (Remotion out/ + any rendered videos)
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
out
|
||||||
|
.env
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"useTabs": false,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
@@ -1,24 +1,50 @@
|
|||||||
# Video-related files for NCMR songs
|
# Video-related files for NCMR songs
|
||||||
|
|
||||||
This repo houses the video-related, code-like files for NCMR music videos.
|
This repo houses the code-like files for NCMR music videos. Binary assets live elsewhere.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
### `master` branch — shared template
|
||||||
|
|
||||||
|
This branch holds the common scaffolding:
|
||||||
|
|
||||||
|
- **Remotion project** — entry point, root component, and a placeholder composition
|
||||||
|
- **`ffmpeg.sh`** — helper for baking ASS subtitles or bypassing with static image + audio
|
||||||
|
- **`src/lrc2srt.ts`** — legacy LRC → SRT subtitle converter
|
||||||
|
- **Build config** — TypeScript, ESLint, Prettier, Remotion studio
|
||||||
|
|
||||||
|
### Song branches
|
||||||
|
|
||||||
|
For each song, create a branch `<song-name>/master` (and optionally feature branches
|
||||||
|
like `<song-name>/synced-lyrics-remotion`):
|
||||||
|
|
||||||
|
```
|
||||||
|
git checkout -b <song-name>/master
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the shared template into the song branch, then add song-specific files:
|
||||||
|
|
||||||
|
```
|
||||||
|
mkdir -p <song-name>
|
||||||
|
# Place your song-specific compositions, assets manifests, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
The `master` branch remains protected and mostly immutable. Song branches are independent.
|
||||||
|
|
||||||
## Guideline
|
## Guideline
|
||||||
|
|
||||||
### gitignored (not tracked here)
|
### Git-ignored (not tracked here)
|
||||||
- binary input assets (audio, image, fonts, material video clips if any)
|
|
||||||
- video output
|
|
||||||
|
|
||||||
For untracked input assets, a bill of materials listing everything and explaining what each is will be mandatory.
|
- Binary input assets (audio, images, fonts, material video clips)
|
||||||
|
- Video output (`out/`, rendered files)
|
||||||
|
|
||||||
### branching rule
|
For untracked input assets, a **bill of materials** listing everything and explaining
|
||||||
|
what each file is will be mandatory. Keep it as `BOM.md` alongside your song assets.
|
||||||
|
|
||||||
This repo has specialised local branching rules and will supersede any generic git operation conventions.
|
### Branching
|
||||||
|
|
||||||
- `master` serves as the minimal template and remain protected and mostly immutable
|
- `master` — minimal template, protected
|
||||||
- [Remotion](https://www.remotion.dev/) project scaffolding
|
- `<song>/master` — song-specific work
|
||||||
- `ffmpeg` helper script for baking in `ass` styled subtitles or bypassing the Remotion route (static image + audio)
|
- `<song>/<feature>` — parallel features for a song
|
||||||
- legacy lrc->srt conversion script
|
|
||||||
|
|
||||||
- for each song, a song root named `<song-name>/master` should be created, for the very least
|
This repo's branching rules supersede any generic git operation conventions.
|
||||||
- usually, working on a song root incrementally is the norm and totally acceptable
|
|
||||||
- branches for parallel features (e.g. `<song-name>/synced-lyrics-remotion`, `<song-name>/overlay-ass`) may be created.
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Remotion Quickstart
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- ffmpeg installed on your system
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install # install dependencies
|
||||||
|
npm run dev # start Remotion Studio
|
||||||
|
```
|
||||||
|
|
||||||
|
Remotion Studio opens at **http://localhost:3000** in your browser.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|----------------------|----------------------------------------------|
|
||||||
|
| `npm run dev` | Starts Remotion Studio (live preview UI) |
|
||||||
|
| `npm run build` | Bundles the project for production |
|
||||||
|
| `npx remotion render`| Renders a video from the command line |
|
||||||
|
| `npx remotion upgrade` | Upgrades all Remotion packages to latest |
|
||||||
|
|
||||||
|
## Remotion Studio
|
||||||
|
|
||||||
|
`npm run dev` starts the Studio — a browser-based editor where you can:
|
||||||
|
|
||||||
|
- Preview compositions in real time
|
||||||
|
- Scrub through frames
|
||||||
|
- Change props and see changes instantly
|
||||||
|
- Render videos from the browser
|
||||||
|
|
||||||
|
The Studio auto-reloads on file changes. No manual refresh needed.
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── src/
|
||||||
|
│ ├── index.ts # Entry point — calls registerRoot()
|
||||||
|
│ ├── Root.tsx # React root component
|
||||||
|
│ ├── Composition.tsx # Composition definitions (placeholder)
|
||||||
|
│ └── lrc2srt.ts # LRC → SRT subtitle converter
|
||||||
|
├── package.json # Remotion + React + Tailwind deps
|
||||||
|
├── remotion.config.ts # CLI config (rspack, jpeg output format)
|
||||||
|
├── tsconfig.json # TypeScript (strict, ES2018)
|
||||||
|
└── ffmpeg.sh # Helper for ASS subtitle baking
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compositions
|
||||||
|
|
||||||
|
Compositions are defined in `src/Composition.tsx`. Each has:
|
||||||
|
|
||||||
|
- **id** — unique name
|
||||||
|
- **durationInFrames** — length in frames
|
||||||
|
- **fps** — frames per second
|
||||||
|
- **width/height** — resolution
|
||||||
|
|
||||||
|
The placeholder is 1280×720 at 30fps, 60 frames (2 seconds).
|
||||||
|
|
||||||
|
## Rendering a video
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx remotion render src/index.ts MyComp out/video.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `MyComp` with your composition ID. The output goes to `out/`.
|
||||||
|
|
||||||
|
## Next steps
|
||||||
|
|
||||||
|
- [Remotion docs](https://www.remotion.dev/docs)
|
||||||
|
- [Compositions guide](https://www.remotion.dev/docs/the-introduction-video)
|
||||||
|
- [Props guide](https://www.remotion.dev/docs/props)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { config } from "@remotion/eslint-config-flat";
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#!/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
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "ncmr-songs-videos",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Video production templates for NCMR songs — Remotion project, ffmpeg helpers, and subtitle tools",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"dependencies": {
|
||||||
|
"@remotion/cli": "4.0.522",
|
||||||
|
"@remotion/tailwind-v4": "4.0.522",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"remotion": "4.0.522",
|
||||||
|
"tailwindcss": "4.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@remotion/eslint-config-flat": "4.0.522",
|
||||||
|
"@types/react": "19.2.7",
|
||||||
|
"@types/web": "0.0.166",
|
||||||
|
"eslint": "9.19.0",
|
||||||
|
"prettier": "3.8.1",
|
||||||
|
"typescript": "5.9.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "remotion studio",
|
||||||
|
"build": "remotion bundle",
|
||||||
|
"upgrade": "remotion upgrade",
|
||||||
|
"lint": "eslint src && tsc"
|
||||||
|
},
|
||||||
|
"sideEffects": [
|
||||||
|
"*.css"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Note: When using the Node.JS APIs, the config file
|
||||||
|
* doesn't apply. Instead, pass options directly to the APIs.
|
||||||
|
*
|
||||||
|
* All configuration options: https://remotion.dev/docs/config
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Config } from "@remotion/cli/config";
|
||||||
|
import { enableTailwind } from '@remotion/tailwind-v4';
|
||||||
|
|
||||||
|
Config.setRspack(true);
|
||||||
|
Config.setVideoImageFormat("jpeg");
|
||||||
|
Config.setOverwriteOutput(true);
|
||||||
|
Config.overrideBundlerConfig(enableTailwind);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { CalculateMetadataFunction, Composition } from "remotion";
|
||||||
|
|
||||||
|
type Props = {};
|
||||||
|
|
||||||
|
const calculateMetadata: CalculateMetadataFunction<Props> = () => {
|
||||||
|
return {};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MyComposition = () => {
|
||||||
|
return (
|
||||||
|
<Composition
|
||||||
|
id="MyComp"
|
||||||
|
component={MyComponent}
|
||||||
|
durationInFrames={60}
|
||||||
|
fps={30}
|
||||||
|
width={1280}
|
||||||
|
height={720}
|
||||||
|
calculateMetadata={calculateMetadata}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MyComponent: React.FC<Props> = () => {
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import "./index.css";
|
||||||
|
import { MyComposition } from "./Composition";
|
||||||
|
|
||||||
|
export const RemotionRoot: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MyComposition />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { registerRoot } from "remotion";
|
||||||
|
import { RemotionRoot } from "./Root";
|
||||||
|
|
||||||
|
registerRoot(RemotionRoot);
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
#!/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();
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2018",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"lib": ["es2015"],
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noUnusedLocals": true
|
||||||
|
},
|
||||||
|
"exclude": ["remotion.config.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user