feat: add Remotion project scaffolding and subtitle tools
Remotion project: - package.json with Remotion 4.0.522, React 19, Tailwind v4 - tsconfig.json (strict, ES2018, Bundler module resolution) - remotion.config.ts (rspack, jpeg image format) - ESLint flat config, Prettier config - Root component and MyComposition placeholder (1280x720, 30fps, 60 frames) Subtitle tools: - src/lrc2srt.ts — LRC to SRT converter (minimal, no dependencies) - ffmpeg.sh — helper for baking ASS subtitles or static-image+audio bypass
This commit is contained in:
+6
-20
@@ -1,4 +1,4 @@
|
||||
# --- Binary input assets (per README guideline) ---
|
||||
# Binary input assets (per README guideline)
|
||||
# Audio
|
||||
*.mp3
|
||||
*.wav
|
||||
@@ -31,22 +31,8 @@
|
||||
*.mkv
|
||||
*.webm
|
||||
|
||||
# --- Video output ---
|
||||
*.mp4
|
||||
*.mov
|
||||
*.avi
|
||||
*.mkv
|
||||
*.webm
|
||||
|
||||
# --- Remotion / Node.js ---
|
||||
node_modules/
|
||||
out/
|
||||
dist/
|
||||
remotion-studio/
|
||||
|
||||
# --- OS / IDE ---
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
# Video output (Remotion out/ + any rendered videos)
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
.env
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"useTabs": false,
|
||||
"bracketSpacing": true,
|
||||
"tabWidth": 2
|
||||
}
|
||||
@@ -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