initial commit

This commit is contained in:
Daniil
2026-05-14 02:23:02 +03:00
commit b8b8247ff3
34 changed files with 3297 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
node_modules
npm-debug.log
.yarn*
.pnpm-debug.log
.git
.gitignore
.DS_Store
.cache
coverage
dist
build
Dockerfile
Dockerfile.*
docker-compose.yml
.env
.env.*
**/*.tsbuildinfo
+16
View File
@@ -0,0 +1,16 @@
# Server Configuration
PORT=3001
HOST=0.0.0.0
# S3/MinIO Configuration
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_BUCKET_NAME=coffee-bucket
S3_ENDPOINT_URL=http://localhost:9000
# Redis (required for BullMQ render queue)
REDIS_URL=redis://localhost:6379
# Remotion Configuration
REMOTION_COMPOSITION_ID=CaptionedVideo
MAX_CONCURRENT_RENDERS=2
+11
View File
@@ -0,0 +1,11 @@
node_modules
dist
.remotion
.env
.DS_Store
*.log
tmp
*.tmp
.cache
build
out
+15
View File
@@ -0,0 +1,15 @@
# AGENTS.md — Coffee Project Remotion Service
Primary workflow guidance lives in `../AGENTS.md`.
Use `./CLAUDE.md` as the service-specific source of truth for:
- Remotion service commands
- server/composition architecture
- rendering constraints and service gotchas
OpenCode/Codex notes:
- Keep `../AGENTS.md` as the workflow and delegation source of truth.
- The previously referenced `.codex/services/remotion.md` guide does not exist. Use `CLAUDE.md` until a dedicated Codex service guide is added.
- Do not rely on `.claude/` directory contents.
+53
View File
@@ -0,0 +1,53 @@
# syntax=docker/dockerfile:1.7-labs
FROM oven/bun:1.3.10 AS base
ENV APP_HOME=/app \
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1 \
REMOTION_PUPPETEER_NO_SANDBOX=1 \
NODE_ENV=production
WORKDIR ${APP_HOME}
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates \
ffmpeg \
chromium \
libglib2.0-0 \
libnss3 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libdrm2 \
libxkbcommon0 \
libgbm1 \
fonts-noto-color-emoji \
curl \
&& rm -rf /var/lib/apt/lists/*
FROM base AS deps
WORKDIR ${APP_HOME}
COPY package.json bun.lock ./
RUN NODE_ENV=development bun install --frozen-lockfile
FROM base AS runner
WORKDIR ${APP_HOME}
COPY --from=deps ${APP_HOME}/node_modules ./node_modules
COPY package.json bun.lock ./
COPY tsconfig.json remotion.config.ts ./
COPY public ./public
COPY src ./src
COPY server ./server
RUN mkdir -p out
# Reuse the existing bun:bun user (UID/GID 1000) from the base image
RUN chown -R bun:bun /app
USER bun
EXPOSE 3001
CMD ["bun", "run", "server"]
+194
View File
@@ -0,0 +1,194 @@
# Remotion Render Service
A high-performance video rendering service built with ElysiaJS and Remotion. This service accepts S3 URLs for input videos, renders them using Remotion compositions, and returns S3 URLs for the rendered outputs.
## Features
- **S3 Integration**: Direct integration with S3-compatible storage (MinIO)
- **Remotion Rendering**: Leverages Remotion for high-quality video rendering
- **ElysiaJS Server**: Fast, type-safe API with automatic validation
- **Bun Runtime**: Utilizes Bun for optimal performance
## Project Structure
```
server/
├── server.ts # Main server entry point
├── shared/
│ └── config.ts # Global configuration
└── routers/
├── render_file/
│ ├── index.ts # Render endpoint
│ ├── service.ts # Render logic
│ ├── types.ts # Type definitions
│ └── constants.ts # Endpoint constants
└── services/
└── s3_storage/
├── index.ts # S3 service implementation
├── types.ts # Type definitions
├── utils.ts # Helper functions
└── constants.ts # Service constants
```
## Setup
1. **Install Dependencies**
```bash
bun install
```
2. **Configure Environment**
Copy `.env.example` to `.env` and fill in your configuration:
```bash
cp .env.example .env
```
Required environment variables:
- `S3_ACCESS_KEY`: Your S3/MinIO access key
- `S3_SECRET_KEY`: Your S3/MinIO secret key
- `S3_BUCKET_NAME`: Target bucket name
- `S3_ENDPOINT_URL`: S3/MinIO endpoint URL
- `PORT`: Server port (default: 3001)
3. **Start the Server**
```bash
bun run server
```
## API Endpoints
### `POST /render/file`
Renders a video from an S3 input URL and returns the rendered output S3 URL.
**Request Body:**
```json
{
"inputS3Url": "https://minio.example.com/bucket/input/video.mp4",
"compositionId": "Main",
"outputFormat": "mp4"
}
```
**Response:**
```json
{
"success": true,
"outputS3Url": "https://minio.example.com/bucket/rendered/output.mp4",
"metadata": {
"inputFile": "https://minio.example.com/bucket/input/video.mp4",
"outputFile": "https://minio.example.com/bucket/rendered/output.mp4",
"renderTime": 12500,
"fileSize": 5242880
}
}
```
**Error Response:**
```json
{
"success": false,
"error": "Error message description"
}
```
### `GET /health`
Health check endpoint.
**Response:**
```json
{
"status": "healthy",
"uptime": 123.456,
"timestamp": "2024-01-01T00:00:00.000Z"
}
```
### `GET /`
Service information endpoint.
**Response:**
```json
{
"service": "Remotion Render Service",
"version": "1.0.0",
"status": "running",
"timestamp": "2024-01-01T00:00:00.000Z"
}
```
## S3 Storage Service
The S3 storage service provides a comprehensive interface for interacting with S3-compatible storage:
- `uploadFile()`: Upload files to S3
- `downloadFile()`: Download files from S3
- `getFileUrl()`: Generate presigned URLs
- `deleteFile()`: Remove files from S3
- `checkFileExists()`: Verify file existence
- `getFileInfo()`: Retrieve file metadata
- `getLocalCopy()`: Download file to temp location
- `uploadFromLocalPath()`: Upload from local filesystem
## Development
### Run in Development Mode
```bash
bun run server
```
### Type Checking
```bash
bun run lint
```
## Architecture
The service follows a modular architecture:
1. **Server Layer** (`server.ts`): Main application setup and routing
2. **Router Layer** (`routers/`): Endpoint definitions and request handling
3. **Service Layer** (`services/`): Business logic and external integrations
4. **Shared Layer** (`shared/`): Global configuration and utilities
## Integration with Django Backend
This service is designed to work alongside the Django backend in `coffee_project_backend_v2`. The Django backend can make requests to this service to render videos stored in the shared MinIO storage.
Example Django integration:
```python
import requests
response = requests.post(
'http://localhost:3001/render/file',
json={
'inputS3Url': input_url,
'compositionId': 'Main',
'outputFormat': 'mp4'
}
)
result = response.json()
if result['success']:
rendered_url = result['outputS3Url']
```
## Technologies
- **Bun**: Fast JavaScript runtime
- **ElysiaJS**: High-performance web framework
- **Remotion**: Programmatic video rendering
- **AWS SDK**: S3 client for storage operations
- **TypeScript**: Type-safe development
+1068
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
services:
remotion:
build:
context: .
dockerfile: Dockerfile
target: runner
command: >
sh -lc "NODE_ENV=development bun install --frozen-lockfile && bun run server"
restart: unless-stopped
env_file: .env
environment:
# Use Docker service aliases so generated URLs contain valid hostnames.
S3_ENDPOINT_URL: http://minio:9000
REDIS_URL: redis://redis:6379/0
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001/api/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
ports:
- "127.0.0.1:3001:3001"
volumes:
- .:/app:cached
- remotion_node_modules:/app/node_modules
networks:
- backend
stdin_open: true
tty: true
deploy:
resources:
limits:
memory: 4g
cpus: "2"
reservations:
memory: 1g
cpus: "0.5"
volumes:
remotion_node_modules:
networks:
backend:
external: true
name: cofee_backend_app-net
+3
View File
@@ -0,0 +1,3 @@
import { config } from "@remotion/eslint-config-flat";
export default config;
+49
View File
@@ -0,0 +1,49 @@
{
"name": "RemotionService",
"version": "1.0.0",
"description": "My Remotion video",
"repository": {},
"license": "UNLICENSED",
"private": true,
"packageManager": "bun@1.3.10",
"dependencies": {
"@aws-sdk/client-s3": "^3.892.0",
"@aws-sdk/lib-storage": "^3.1009.0",
"@aws-sdk/s3-request-presigner": "^3.892.0",
"@elysiajs/openapi": "^1.4.14",
"@elysiajs/swagger": "^1.3.1",
"@remotion/bundler": "4.0.435",
"@remotion/cli": "4.0.435",
"@remotion/google-fonts": "4.0.435",
"@remotion/media": "4.0.435",
"@remotion/media-parser": "4.0.435",
"@remotion/renderer": "4.0.435",
"bullmq": "^5.71.0",
"elysia": "^1.4.27",
"lodash": "^4.17.23",
"react": "19.2.4",
"react-dom": "19.2.4",
"remotion": "4.0.435",
"uuid": "^13.0.0"
},
"devDependencies": {
"@remotion/eslint-config-flat": "4.0.435",
"@types/lodash": "^4.17.24",
"@types/node": "^25.5.0",
"@types/react": "19.2.14",
"@types/uuid": "^11.0.0",
"@types/web": "0.0.342",
"bun-types": "^1.3.10",
"eslint": "10.0.3",
"prettier": "3.8.1",
"typescript": "5.9.3"
},
"scripts": {
"dev": "bunx remotion studio",
"build": "bunx remotion bundle",
"upgrade": "bunx remotion upgrade",
"render": "bunx remotion render",
"lint": "bunx eslint src && bunx tsc",
"server": "bun run server/index.ts"
}
}
View File
+26
View File
@@ -0,0 +1,26 @@
/**
* 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 path from "path";
Config.setVideoImageFormat("jpeg");
Config.setOverwriteOutput(true);
Config.overrideWebpackConfig((currentConfiguration) => {
return {
...currentConfiguration,
resolve: {
...currentConfiguration.resolve,
alias: {
...(currentConfiguration.resolve?.alias ?? {}),
"@/public": path.join(process.cwd(), "public"),
"@": path.join(process.cwd(), "src"),
},
},
};
});
+31
View File
@@ -0,0 +1,31 @@
interface ServerConfig {
PORT: number;
HOST: string;
S3_ACCESS_KEY: string;
S3_SECRET_KEY: string;
S3_BUCKET_NAME: string;
S3_ENDPOINT_URL: string;
REMOTION_COMPOSITION_ID: string;
REDIS_URL: string;
MAX_CONCURRENT_RENDERS: number;
}
function requireEnv(key: string): string {
const value = Bun.env[key];
if (!value) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}
export const serverConfig: ServerConfig = {
PORT: Number(Bun.env.PORT) || 8001,
HOST: Bun.env.HOST || "0.0.0.0",
S3_ACCESS_KEY: requireEnv("S3_ACCESS_KEY"),
S3_SECRET_KEY: requireEnv("S3_SECRET_KEY"),
S3_BUCKET_NAME: requireEnv("S3_BUCKET_NAME"),
S3_ENDPOINT_URL: Bun.env.S3_ENDPOINT_URL || "http://localhost:9000",
REMOTION_COMPOSITION_ID: Bun.env.REMOTION_COMPOSITION_ID || "CaptionedVideo",
REDIS_URL: Bun.env.REDIS_URL || "redis://localhost:6379",
MAX_CONCURRENT_RENDERS: Number(Bun.env.MAX_CONCURRENT_RENDERS) || 2,
};
+153
View File
@@ -0,0 +1,153 @@
import Elysia, { t } from "elysia";
import { Document } from "@/srv/types/DocumentSchema";
import { CaptionStyleSchema } from "@/srv/types/CaptionStyleSchema";
import { S3Service } from "@/srv/services/s3";
import { renderCaptionedVideo } from "@/srv/services/render_video";
import {
cancelRender,
enqueueRender,
renderQueue,
startRenderWorker,
} from "@/srv/services/render_queue";
import { serverConfig } from "@/srv/config";
// Start the BullMQ worker
const worker = startRenderWorker();
console.log(
`Render worker started (concurrency: ${serverConfig.MAX_CONCURRENT_RENDERS})`,
);
const app = new Elysia({ prefix: "/api" });
app.post(
"/render",
async ({ body, set }) => {
const renderId = body.renderId || crypto.randomUUID();
// If callbackUrl is provided, enqueue async render
if (body.callbackUrl) {
await enqueueRender({
renderId,
folder: body.folder,
videoSrc: body.videoSrc,
transcription: body.transcription,
styleConfig: body.styleConfig,
callbackUrl: body.callbackUrl,
});
set.status = 202;
return { renderId, status: "queued" };
}
// Sync fallback (no callbackUrl) — render immediately and return result
const s3Service = new S3Service("captioned", body.folder);
const filename = s3Service.getFileName(body.videoSrc);
const videoUrl = await s3Service.getFileURL(body.videoSrc);
const res = await renderCaptionedVideo(
body.transcription,
videoUrl,
filename,
body.styleConfig,
);
try {
const s3OutPath = await s3Service.uploadFile(res.output, filename);
return { output: s3OutPath };
} finally {
await Bun.file(res.output)
.delete()
.catch(() => {});
}
},
{
body: t.Object({
folder: t.Optional(t.String()),
renderId: t.Optional(t.String()),
videoSrc: t.String(),
transcription: Document,
styleConfig: t.Optional(CaptionStyleSchema),
callbackUrl: t.Optional(t.String()),
}),
},
);
app.get(
"/render/:renderId",
async ({ params }) => {
const job = await renderQueue.getJob(params.renderId);
if (!job) {
return { status: "not_found", renderId: params.renderId };
}
const state = await job.getState();
const progress = typeof job.progress === "number" ? job.progress : 0;
if (state === "completed") {
return {
status: "done",
renderId: params.renderId,
progress_pct: 100,
output_path: job.returnvalue?.outputPath,
callback_delivered: job.returnvalue?.callbackDelivered ?? false,
};
}
if (state === "failed") {
return {
status: "failed",
renderId: params.renderId,
error: job.failedReason,
};
}
return {
status: state, // "waiting", "active", "delayed"
renderId: params.renderId,
progress_pct: progress,
};
},
{
params: t.Object({
renderId: t.String(),
}),
},
);
app.delete(
"/render/:renderId",
async ({ params, set }) => {
const result = await cancelRender(params.renderId);
if (result.status === "not_found") {
set.status = 404;
return result;
}
return result;
},
{
params: t.Object({
renderId: t.String(),
}),
},
);
app.get("/render", async () => "Hello");
app.get("/health", async () => {
return { status: "ok" };
});
app.listen(serverConfig.PORT, () => {
console.log(
`Remotion service listening on ${serverConfig.HOST}:${serverConfig.PORT}`,
);
});
// Graceful shutdown
process.on("SIGTERM", async () => {
console.log("Shutting down render worker...");
await worker.close();
process.exit(0);
});
+355
View File
@@ -0,0 +1,355 @@
import { Queue, Worker, type Job } from "bullmq";
import { serverConfig } from "@/srv/config";
import {
S3Service,
type UploadProgress,
} from "@/srv/services/s3";
import {
RENDER_CANCELLED_ERROR_MESSAGE,
renderCaptionedVideo,
type RenderProgress,
} from "@/srv/services/render_video";
import { sendWebhook } from "@/srv/services/webhook";
import type { DocumentInterface } from "@/srv/types/DocumentSchema";
import type { CaptionStyleConfigType } from "@/srv/types/CaptionStyleSchema";
const QUEUE_NAME = "caption-renders";
const PROGRESS_THROTTLE_MS = 3_000;
const UPLOAD_PROGRESS_THROTTLE_MS = 1_500;
const RENDER_PROGRESS_MIN_PCT = 5;
const RENDER_PROGRESS_MAX_PCT = 95;
const UPLOAD_PROGRESS_START_PCT = 95;
const UPLOAD_PROGRESS_END_PCT = 99;
const KILOBYTE = 1024;
const MEGABYTE = 1024 * KILOBYTE;
const RENDER_CANCELLATION_KEY_PREFIX = "caption-render-cancel:";
const RENDER_CANCELLATION_TTL_SECONDS = 60 * 60;
const RENDER_CANCELLATION_POLL_MS = 1_000;
export type RenderJobData = {
renderId: string;
folder?: string;
videoSrc: string;
transcription: DocumentInterface;
styleConfig?: CaptionStyleConfigType;
callbackUrl?: string;
};
export type RenderJobResult = {
outputPath: string;
callbackDelivered: boolean;
};
export type CancelRenderResult = {
renderId: string;
status:
| "cancelled"
| "cancellation_requested"
| "completed"
| "failed"
| "not_found";
};
function formatBytes(bytes: number): string {
if (bytes >= MEGABYTE) {
return `${(bytes / MEGABYTE).toFixed(1)} MB`;
}
if (bytes >= KILOBYTE) {
return `${(bytes / KILOBYTE).toFixed(1)} KB`;
}
return `${bytes} B`;
}
function getRenderCancellationKey(renderId: string): string {
return `${RENDER_CANCELLATION_KEY_PREFIX}${renderId}`;
}
async function requestRenderCancellation(renderId: string): Promise<void> {
const client = await renderQueue.client;
await client.set(
getRenderCancellationKey(renderId),
"1",
"EX",
RENDER_CANCELLATION_TTL_SECONDS,
);
}
async function isRenderCancellationRequested(renderId: string): Promise<boolean> {
const client = await renderQueue.client;
const value = await client.get(getRenderCancellationKey(renderId));
return value === "1";
}
async function clearRenderCancellation(renderId: string): Promise<void> {
const client = await renderQueue.client;
await client.del(getRenderCancellationKey(renderId));
}
// ---------------------------------------------------------------------------
// Queue (enqueue side)
// ---------------------------------------------------------------------------
const redisConnection = { url: serverConfig.REDIS_URL };
export const renderQueue = new Queue<RenderJobData, RenderJobResult>(
QUEUE_NAME,
{ connection: redisConnection },
);
export async function enqueueRender(data: RenderJobData): Promise<string> {
const job = await renderQueue.add("render", data, {
jobId: data.renderId,
attempts: 2,
backoff: { type: "exponential", delay: 5_000 },
removeOnComplete: { age: 3600 },
removeOnFail: { age: 7200 },
});
return job.id!;
}
export async function cancelRender(renderId: string): Promise<CancelRenderResult> {
const job = await renderQueue.getJob(renderId);
if (!job) {
return { renderId, status: "not_found" };
}
const state = await job.getState();
if (state === "completed") {
await clearRenderCancellation(renderId);
return { renderId, status: "completed" };
}
if (state === "failed") {
await clearRenderCancellation(renderId);
return { renderId, status: "failed" };
}
if (state === "active") {
await requestRenderCancellation(renderId);
return { renderId, status: "cancellation_requested" };
}
await clearRenderCancellation(renderId);
await job.remove();
return { renderId, status: "cancelled" };
}
// ---------------------------------------------------------------------------
// Worker (processing side)
// ---------------------------------------------------------------------------
async function processRenderJob(job: Job<RenderJobData, RenderJobResult>) {
const { folder, videoSrc, transcription, styleConfig, callbackUrl } =
job.data;
const logPrefix = `[render-job:${job.id}]`;
const renderId = String(job.id);
const s3Service = new S3Service("captioned", folder);
const filename = s3Service.getFileName(videoSrc);
const videoUrl = await s3Service.getFileURL(videoSrc);
const uploadAbortController = new AbortController();
let cancellationPollTimer: ReturnType<typeof setInterval> | null = null;
console.log(`${logPrefix} Starting job for ${videoSrc}`);
const ensureNotCancelled = async () => {
if (await isRenderCancellationRequested(renderId)) {
throw new Error(RENDER_CANCELLED_ERROR_MESSAGE);
}
};
await ensureNotCancelled();
cancellationPollTimer = setInterval(() => {
void isRenderCancellationRequested(renderId).then((cancelRequested) => {
if (!cancelRequested) {
return;
}
uploadAbortController.abort(RENDER_CANCELLED_ERROR_MESSAGE);
});
}, RENDER_CANCELLATION_POLL_MS);
// Send RUNNING webhook
if (callbackUrl) {
const delivered = await sendWebhook(callbackUrl, {
status: "RUNNING",
progress_pct: RENDER_PROGRESS_MIN_PCT,
current_message: "Рендер субтитров",
started_at: new Date().toISOString(),
});
console.log(`${logPrefix} Start webhook delivered: ${delivered}`);
}
await job.updateProgress(RENDER_PROGRESS_MIN_PCT);
let lastProgressSend = 0;
let lastUploadProgressSend = 0;
let lastUploadPct = UPLOAD_PROGRESS_START_PCT;
const pushRunningProgress = (progressPct: number, currentMessage: string) => {
if (callbackUrl) {
void sendWebhook(callbackUrl, {
status: "RUNNING",
progress_pct: progressPct,
current_message: currentMessage,
});
}
void job.updateProgress(progressPct).catch((error) => {
console.error(`${logPrefix} Failed to update BullMQ progress:`, error);
});
};
const onProgress = (progress: RenderProgress) => {
if (progress.totalFrames <= 0) {
return;
}
const now = Date.now();
if (now - lastProgressSend >= PROGRESS_THROTTLE_MS) {
lastProgressSend = now;
const pct = Math.min(
Math.round(
(progress.renderedFrames / progress.totalFrames) *
(RENDER_PROGRESS_MAX_PCT - RENDER_PROGRESS_MIN_PCT),
) + RENDER_PROGRESS_MIN_PCT,
RENDER_PROGRESS_MAX_PCT,
);
pushRunningProgress(
pct,
`Рендер субтитров: ${progress.renderedFrames}/${progress.totalFrames}`,
);
}
};
const onUploadProgress = (progress: UploadProgress) => {
const now = Date.now();
const uploadPct = Math.min(
UPLOAD_PROGRESS_START_PCT +
Math.round(
progress.percent *
(UPLOAD_PROGRESS_END_PCT - UPLOAD_PROGRESS_START_PCT),
),
UPLOAD_PROGRESS_END_PCT,
);
const shouldSend =
uploadPct > lastUploadPct ||
now - lastUploadProgressSend >= UPLOAD_PROGRESS_THROTTLE_MS;
if (!shouldSend) {
return;
}
lastUploadProgressSend = now;
lastUploadPct = uploadPct;
pushRunningProgress(
uploadPct,
`Загрузка видео: ${Math.round(progress.percent * 100)}% (${formatBytes(progress.uploadedBytes)} / ${formatBytes(progress.totalBytes)})`,
);
};
let outputLocalPath: string | undefined;
try {
const result = await renderCaptionedVideo(
transcription,
videoUrl,
filename,
styleConfig,
onProgress,
() => isRenderCancellationRequested(renderId),
);
outputLocalPath = result.output;
console.log(`${logPrefix} Render finished, starting upload`);
await ensureNotCancelled();
pushRunningProgress(
UPLOAD_PROGRESS_START_PCT,
"Рендер завершён, начинаем загрузку видео",
);
const s3OutPath = await s3Service.uploadFile(
result.output,
filename,
onUploadProgress,
uploadAbortController.signal,
);
console.log(`${logPrefix} Upload completed: ${s3OutPath}`);
pushRunningProgress(
UPLOAD_PROGRESS_END_PCT,
"Загрузка завершена, публикуем результат",
);
// Send DONE webhook
let callbackDelivered = false;
if (callbackUrl) {
callbackDelivered = await sendWebhook(callbackUrl, {
status: "DONE",
progress_pct: 100,
current_message: "Готово",
output_data: { output_path: s3OutPath },
finished_at: new Date().toISOString(),
});
console.log(`${logPrefix} Done webhook delivered: ${callbackDelivered}`);
}
return { outputPath: s3OutPath, callbackDelivered };
} catch (err) {
if (
err instanceof Error &&
err.message === RENDER_CANCELLED_ERROR_MESSAGE
) {
console.log(`${logPrefix} Cancellation requested`);
throw err;
}
console.error(`${logPrefix} Job failed:`, err);
// Send FAILED webhook
if (callbackUrl) {
await sendWebhook(callbackUrl, {
status: "FAILED",
progress_pct: 0,
current_message: "Ошибка рендера",
error_message: err instanceof Error ? err.message : String(err),
finished_at: new Date().toISOString(),
});
}
throw err;
} finally {
if (cancellationPollTimer) {
clearInterval(cancellationPollTimer);
}
await clearRenderCancellation(renderId);
if (outputLocalPath) {
await Bun.file(outputLocalPath).delete().catch(() => {});
console.log(`${logPrefix} Cleaned up local output ${outputLocalPath}`);
}
}
}
export function startRenderWorker(): Worker<RenderJobData, RenderJobResult> {
const worker = new Worker<RenderJobData, RenderJobResult>(
QUEUE_NAME,
processRenderJob,
{
connection: redisConnection,
concurrency: serverConfig.MAX_CONCURRENT_RENDERS,
},
);
worker.on("failed", (job, err) => {
console.error(`Render job ${job?.id} failed:`, err.message);
});
worker.on("completed", (job) => {
console.log(`Render job ${job.id} completed`);
});
return worker;
}
+243
View File
@@ -0,0 +1,243 @@
import type { DocumentInterface } from "@/srv/types/DocumentSchema";
import type { CaptionStyleConfigType } from "@/srv/types/CaptionStyleSchema";
import { serverConfig } from "@/srv/config";
import { statSync } from "node:fs";
const OUTPUT_DIR = "out";
const KILOBYTE = 1024;
const MEGABYTE = 1024 * KILOBYTE;
const MAX_CAPTURED_STREAM_OUTPUT_CHARS = 20_000;
const RENDER_CANCELLATION_POLL_MS = 1_000;
export const RENDER_CANCELLED_ERROR_MESSAGE = "Render cancelled";
export type RenderResult = {
output: string;
};
export type RenderProgress = {
renderedFrames: number;
totalFrames: number;
};
function formatBytes(bytes: number): string {
if (bytes >= MEGABYTE) {
return `${(bytes / MEGABYTE).toFixed(1)} MB`;
}
if (bytes >= KILOBYTE) {
return `${(bytes / KILOBYTE).toFixed(1)} KB`;
}
return `${bytes} B`;
}
function formatDuration(durationMs: number): string {
return `${(durationMs / 1000).toFixed(1)}s`;
}
function appendCapturedOutput(current: string, nextLine: string): string {
const combined = current
? `${current}\n${nextLine}`
: nextLine;
if (combined.length <= MAX_CAPTURED_STREAM_OUTPUT_CHARS) {
return combined;
}
return combined.slice(-MAX_CAPTURED_STREAM_OUTPUT_CHARS);
}
function parseRenderProgressFromLine(
line: string,
onProgress?: (progress: RenderProgress) => void,
): void {
if (!onProgress) {
return;
}
const frameMatch = line.match(
/(?:Rendered?\s+frame|Rendered|Encoded)\s+(\d+)\s*\/\s*(\d+)/i,
);
if (!frameMatch) {
return;
}
onProgress({
renderedFrames: parseInt(frameMatch[1], 10),
totalFrames: parseInt(frameMatch[2], 10),
});
}
async function consumeProcessStream(
stream: ReadableStream<Uint8Array> | null | undefined,
label: "stdout" | "stderr",
onLine?: (line: string) => void,
): Promise<string> {
if (!stream) {
return "";
}
const reader = stream.getReader();
const decoder = new TextDecoder();
let buffer = "";
let captured = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const normalizedLine = line.trimEnd();
if (!normalizedLine) {
continue;
}
captured = appendCapturedOutput(captured, normalizedLine);
console.log(`[render:${label}] ${normalizedLine}`);
onLine?.(normalizedLine);
}
}
const tail = buffer.trim();
if (tail) {
captured = appendCapturedOutput(captured, tail);
console.log(`[render:${label}] ${tail}`);
onLine?.(tail);
}
} catch (error) {
console.warn(
`[render:${label}] Stream reader closed unexpectedly: ${error instanceof Error ? error.message : String(error)}`,
);
}
return captured;
}
export async function renderCaptionedVideo(
transcription: DocumentInterface,
videoSrc: string,
filename: string,
styleConfig?: CaptionStyleConfigType,
onProgress?: (progress: RenderProgress) => void,
shouldCancel?: () => boolean | Promise<boolean>,
): Promise<RenderResult> {
const uniqueFilename = `${crypto.randomUUID()}-${filename}`;
const outputPath = `${OUTPUT_DIR}/${uniqueFilename}`;
const propsPath = `${OUTPUT_DIR}/${crypto.randomUUID()}.json`;
const props: Record<string, unknown> = {
videoSrc,
transcription,
fps: 30,
};
if (styleConfig) {
props.styleConfig = styleConfig;
}
await Bun.write(propsPath, JSON.stringify(props));
try {
const renderStartedAt = Date.now();
const args = [
"render",
"src/index.ts",
serverConfig.REMOTION_COMPOSITION_ID,
outputPath,
"--props",
propsPath,
];
const cmd = ["./node_modules/.bin/remotion", ...args];
console.log(`[render] Starting: ${cmd.join(" ")}`);
console.log(`[render] Source video: ${videoSrc}`);
console.log(`[render] Output: ${outputPath}, Props: ${propsPath}`);
const proc = Bun.spawn(cmd, {
stdout: "pipe",
stderr: "pipe",
});
let renderCancelled = false;
const stopRenderIfCancelled = async () => {
if (!shouldCancel || renderCancelled) {
return;
}
const cancellationRequested = await shouldCancel();
if (!cancellationRequested) {
return;
}
renderCancelled = true;
proc.kill();
};
const cancellationTimer = shouldCancel
? setInterval(() => {
void stopRenderIfCancelled();
}, RENDER_CANCELLATION_POLL_MS)
: null;
const handleProcessLine = (line: string) => {
parseRenderProgressFromLine(line, onProgress);
};
const stdoutPromise = consumeProcessStream(
proc.stdout,
"stdout",
handleProcessLine,
);
const stderrPromise = consumeProcessStream(
proc.stderr,
"stderr",
handleProcessLine,
);
const exitCode = await proc.exited;
if (cancellationTimer) {
clearInterval(cancellationTimer);
}
const [capturedStdout, capturedStderr] = await Promise.all([
stdoutPromise,
stderrPromise,
]);
if (renderCancelled) {
throw new Error(RENDER_CANCELLED_ERROR_MESSAGE);
}
if (exitCode !== 0) {
console.error(
`[render] Failed after ${formatDuration(Date.now() - renderStartedAt)} (exit ${exitCode})`,
);
if (capturedStdout) {
console.error(`[render] Last stdout:\n${capturedStdout}`);
}
if (capturedStderr) {
console.error(`[render] Last stderr:\n${capturedStderr}`);
}
throw new Error(
`Render process exited with code ${exitCode}: ${(capturedStderr || capturedStdout).slice(0, 2000)}`,
);
}
const outputSize = statSync(outputPath).size;
console.log(
`[render] Completed in ${formatDuration(Date.now() - renderStartedAt)} (${formatBytes(outputSize)})`,
);
return { output: outputPath };
} finally {
await Bun.file(propsPath).delete().catch(() => {});
}
}
+152
View File
@@ -0,0 +1,152 @@
import { serverConfig } from "@/srv/config";
import {
GetObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { createReadStream, statSync } from "node:fs";
import path from "path";
const PRESIGN_EXPIRY_SECONDS = 3600 * 24;
const S3_REGION = "us-east-1";
const KILOBYTE = 1024;
const MEGABYTE = 1024 * KILOBYTE;
const UPLOAD_LOG_INTERVAL_BYTES = 25 * MEGABYTE;
const MULTIPART_PART_SIZE = 5 * MEGABYTE;
export type UploadProgress = {
uploadedBytes: number;
totalBytes: number;
percent: number;
};
function formatBytes(bytes: number): string {
if (bytes >= MEGABYTE) {
return `${(bytes / MEGABYTE).toFixed(1)} MB`;
}
if (bytes >= KILOBYTE) {
return `${(bytes / KILOBYTE).toFixed(1)} KB`;
}
return `${bytes} B`;
}
export class S3Service {
private client: S3Client;
private folder: string;
private folderToStore: string;
constructor(folderToStore: string, folder?: string) {
this.folderToStore = folderToStore;
this.folder = folder || "";
this.client = new S3Client({
credentials: {
accessKeyId: serverConfig.S3_ACCESS_KEY,
secretAccessKey: serverConfig.S3_SECRET_KEY,
},
endpoint: serverConfig.S3_ENDPOINT_URL,
forcePathStyle: true,
region: S3_REGION,
});
}
async getFileURL(s3Path: string): Promise<string> {
return getSignedUrl(
this.client,
new GetObjectCommand({
Bucket: serverConfig.S3_BUCKET_NAME,
Key: s3Path,
}),
{ expiresIn: PRESIGN_EXPIRY_SECONDS },
);
}
getFileName(s3Path: string): string {
return path.basename(s3Path);
}
async uploadFile(
localFilePath: string,
key: string,
onProgress?: (progress: UploadProgress) => void,
abortSignal?: AbortSignal,
): Promise<string> {
const s3Path = path.join(this.folder, this.folderToStore, key);
const totalBytes = statSync(localFilePath).size;
let lastLoggedBytes = 0;
let lastLoggedPercent = -1;
console.log(
`[s3] Upload starting: ${localFilePath} -> s3://${serverConfig.S3_BUCKET_NAME}/${s3Path} (${formatBytes(totalBytes)})`,
);
const fileStream = createReadStream(localFilePath);
const upload = new Upload({
client: this.client,
params: {
Body: fileStream,
Bucket: serverConfig.S3_BUCKET_NAME,
Key: s3Path,
},
partSize: MULTIPART_PART_SIZE,
});
const onAbort = () => upload.abort();
if (abortSignal) {
if (abortSignal.aborted) {
upload.abort();
} else {
abortSignal.addEventListener("abort", onAbort, { once: true });
}
}
upload.on("httpUploadProgress", (progress) => {
const uploadedBytes = progress.loaded ?? 0;
const percent = totalBytes > 0 ? uploadedBytes / totalBytes : 1;
const percentValue = Math.floor(percent * 100);
onProgress?.({
uploadedBytes,
totalBytes,
percent: Math.min(percent, 1),
});
if (
uploadedBytes === totalBytes ||
uploadedBytes - lastLoggedBytes >= UPLOAD_LOG_INTERVAL_BYTES ||
percentValue >= lastLoggedPercent + 10
) {
lastLoggedBytes = uploadedBytes;
lastLoggedPercent = percentValue;
console.log(
`[s3] Upload progress: ${Math.min(percentValue, 100)}% (${formatBytes(uploadedBytes)} / ${formatBytes(totalBytes)})`,
);
}
});
try {
await upload.done();
} catch (error) {
console.error(
`[s3] Upload failed after ${formatBytes(lastLoggedBytes)} sent:`,
error,
);
throw error;
} finally {
abortSignal?.removeEventListener("abort", onAbort);
}
if (totalBytes === 0) {
onProgress?.({ uploadedBytes: 0, totalBytes: 0, percent: 1 });
}
console.log(
`[s3] Upload completed: s3://${serverConfig.S3_BUCKET_NAME}/${s3Path}`,
);
return s3Path;
}
}
+36
View File
@@ -0,0 +1,36 @@
type WebhookPayload = {
status: "RUNNING" | "DONE" | "FAILED";
progress_pct: number;
current_message: string;
output_data?: { output_path: string };
error_message?: string;
started_at?: string;
finished_at?: string;
};
const WEBHOOK_TIMEOUT_MS = 10_000;
export async function sendWebhook(
callbackUrl: string,
payload: WebhookPayload,
): Promise<boolean> {
try {
const resp = await fetch(callbackUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS),
});
if (!resp.ok) {
const responseBody = await resp.text().catch(() => "");
console.error(
`Webhook POST to ${callbackUrl} returned ${resp.status}: ${resp.statusText}${responseBody ? `\n${responseBody.slice(0, 1000)}` : ""}`,
);
return false;
}
return true;
} catch (err) {
console.error(`Webhook POST to ${callbackUrl} failed:`, err);
return false;
}
}
+68
View File
@@ -0,0 +1,68 @@
import { t, Static } from "elysia";
export const CaptionTextStyle = t.Object({
font_family: t.String({ default: "Lobster" }),
font_size: t.Number({ default: 40 }),
font_weight: t.Number({ default: 400 }),
text_color: t.String({ default: "#FFFFFF" }),
highlight_color: t.String({ default: "#FFCC00" }),
text_shadow: t.Union([t.String(), t.Null()], {
default: "2px 2px 4px rgba(0,0,0,0.5)",
}),
text_stroke_width: t.Number({ default: 0 }),
text_stroke_color: t.String({ default: "#000000" }),
});
export const CaptionLayoutStyle = t.Object({
vertical_position: t.Union(
[t.Literal("top"), t.Literal("center"), t.Literal("bottom")],
{ default: "bottom" },
),
horizontal_alignment: t.Union(
[t.Literal("left"), t.Literal("center"), t.Literal("right")],
{ default: "center" },
),
padding_px: t.Number({ default: 20 }),
max_width_pct: t.Number({ default: 90 }),
lines_per_screen: t.Number({ default: 2 }),
});
export const CaptionAnimationStyle = t.Object({
highlight_style: t.Union(
[
t.Literal("color"),
t.Literal("scale"),
t.Literal("underline"),
t.Literal("color_scale"),
],
{ default: "color" },
),
highlight_scale: t.Number({ default: 1.1 }),
segment_transition: t.Union(
[t.Literal("fade"), t.Literal("slide"), t.Literal("none")],
{ default: "fade" },
),
fade_duration_frames: t.Number({ default: 3 }),
animation_speed: t.Number({ default: 1.0 }),
});
export const CaptionBackgroundStyle = t.Object({
bg_color: t.String({ default: "rgba(0,0,0,0.6)" }),
bg_blur_px: t.Number({ default: 0 }),
bg_glow_color: t.Union([t.String(), t.Null()], { default: null }),
bg_border_radius_px: t.Number({ default: 15 }),
bg_padding_px: t.Number({ default: 20 }),
});
export const CaptionStyleSchema = t.Object({
text: CaptionTextStyle,
layout: CaptionLayoutStyle,
animation: CaptionAnimationStyle,
background: CaptionBackgroundStyle,
});
export type CaptionTextStyleType = Static<typeof CaptionTextStyle>;
export type CaptionLayoutStyleType = Static<typeof CaptionLayoutStyle>;
export type CaptionAnimationStyleType = Static<typeof CaptionAnimationStyle>;
export type CaptionBackgroundStyleType = Static<typeof CaptionBackgroundStyle>;
export type CaptionStyleConfigType = Static<typeof CaptionStyleSchema>;
+49
View File
@@ -0,0 +1,49 @@
import { t, Static } from "elysia";
// 1. Leaf Nodes (Building blocks)
export const Tag = t.Object({
name: t.String(),
});
export const TimeRange = t.Object({
start: t.Number(),
end: t.Number(),
});
// 2. Word Node
export const WordNode = t.Object({
text: t.String(),
semantic_tags: t.Array(Tag),
structure_tags: t.Array(Tag),
time: TimeRange,
});
// 3. Line Node (Contains Words)
export const LineNode = t.Object({
text: t.String(),
semantic_tags: t.Array(Tag),
structure_tags: t.Array(Tag),
time: TimeRange,
words: t.Array(WordNode),
});
// 4. Segment Node (Contains Lines)
export const SegmentNode = t.Object({
text: t.String(),
semantic_tags: t.Array(Tag),
structure_tags: t.Array(Tag),
time: TimeRange,
lines: t.Array(LineNode),
});
// 5. Root Document
export const Document = t.Object({
segments: t.Array(SegmentNode),
});
export type TagInterface = Static<typeof Tag>;
export type TimeRangeInterface = Static<typeof TimeRange>;
export type WordNodeInterface = Static<typeof WordNode>;
export type LineNodeInterface = Static<typeof LineNode>;
export type SegmentNodeInterface = Static<typeof SegmentNode>;
export type DocumentInterface = Static<typeof Document>;
+301
View File
@@ -0,0 +1,301 @@
import React from "react";
import { interpolate } from "remotion";
import { loadFont } from "@remotion/google-fonts/Lobster";
import {
LineWithFrames,
SegmentWithFrames,
WordWithFrames,
} from "@/types/transcription";
import { CaptionStyleConfig } from "@/types/caption_style";
import { useTheme } from "@/hooks/useTheme";
loadFont();
const MIN_INTERPOLATE_SPAN = 0.001;
// ---------------------------------------------------------------------------
// CSS theme mode (backward compat — no styleConfig)
// ---------------------------------------------------------------------------
const CssWord = ({ text, isCurrent }: { text: string; isCurrent: boolean }) => (
<span className={isCurrent ? "word current-word" : "word"}>{text} </span>
);
const CssLine = ({
line,
currentWord,
}: {
line: LineWithFrames;
currentWord: WordWithFrames | null;
}) => {
if (!line.words.length) return null;
return (
<div className="line">
{line.words.map((word, index) => {
const isCurrent = currentWord
? word.text === currentWord.text &&
word.time.start === currentWord.time.start
: false;
return (
<CssWord
key={`${index}-${word.text}`}
text={word.text}
isCurrent={isCurrent}
/>
);
})}
</div>
);
};
// ---------------------------------------------------------------------------
// Inline style mode (with styleConfig)
// ---------------------------------------------------------------------------
const StyledWord = ({
text,
isCurrent,
currentFrame,
wordFrameTime,
style,
}: {
text: string;
isCurrent: boolean;
currentFrame: number;
wordFrameTime: { start: number; end: number };
style: CaptionStyleConfig;
}) => {
const { text: textStyle, animation } = style;
const baseStyle: React.CSSProperties = {
fontFamily: `"${textStyle.font_family}", sans-serif`,
fontSize: textStyle.font_size,
fontWeight: textStyle.font_weight,
color: textStyle.text_color,
textShadow: textStyle.text_shadow || undefined,
display: "inline-block",
transition: "none",
};
if (textStyle.text_stroke_width > 0) {
baseStyle.WebkitTextStroke = `${textStyle.text_stroke_width}px ${textStyle.text_stroke_color}`;
}
if (isCurrent) {
baseStyle.color = textStyle.highlight_color;
// Highlight shadow for neon-like glow
if (textStyle.highlight_color !== textStyle.text_color && textStyle.text_shadow) {
const glowShadow = textStyle.text_shadow
.replace(/#[0-9a-fA-F]{3,8}/g, textStyle.highlight_color)
.replace(/rgba?\([^)]+\)/g, textStyle.highlight_color);
baseStyle.textShadow = glowShadow;
}
const needsScale =
animation.highlight_style === "scale" ||
animation.highlight_style === "color_scale";
if (needsScale) {
const wordDuration = wordFrameTime.end - wordFrameTime.start;
const scale =
wordDuration > MIN_INTERPOLATE_SPAN * 2
? interpolate(
currentFrame,
[
wordFrameTime.start,
wordFrameTime.start + wordDuration / 2,
wordFrameTime.end,
],
[1, animation.highlight_scale, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
)
: animation.highlight_scale;
baseStyle.transform = `scale(${scale})`;
}
if (animation.highlight_style === "underline") {
const wordDuration = wordFrameTime.end - wordFrameTime.start;
const progress =
wordDuration > MIN_INTERPOLATE_SPAN
? interpolate(
currentFrame,
[wordFrameTime.start, wordFrameTime.end],
[0, 1],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
)
: 1;
baseStyle.borderBottom = `2px solid ${textStyle.highlight_color}`;
baseStyle.borderImage = `linear-gradient(to right, ${textStyle.highlight_color} ${progress * 100}%, transparent ${progress * 100}%) 1`;
}
}
return <span style={baseStyle}>{text} </span>;
};
const StyledLine = ({
line,
currentWord,
currentFrame,
style,
}: {
line: LineWithFrames;
currentWord: WordWithFrames | null;
currentFrame: number;
style: CaptionStyleConfig;
}) => {
if (!line.words.length) return null;
const lineStyle: React.CSSProperties = {
display: "flex",
flexWrap: "wrap",
justifyContent: "center",
gap: 10,
marginBottom: 10,
};
return (
<div style={lineStyle}>
{line.words.map((word, index) => {
const isCurrent = currentWord
? word.text === currentWord.text &&
word.time.start === currentWord.time.start
: false;
return (
<StyledWord
key={`${index}-${word.text}`}
text={word.text}
isCurrent={isCurrent}
currentFrame={currentFrame}
wordFrameTime={word.frameTime}
style={style}
/>
);
})}
</div>
);
};
// ---------------------------------------------------------------------------
// Main Captions export (dual mode)
// ---------------------------------------------------------------------------
export const Captions = ({
currentFrame,
currentWord,
currentSegment,
styleConfig,
theme = "neon",
}: {
currentFrame: number;
currentWord: WordWithFrames | null;
currentSegment: SegmentWithFrames | null;
styleConfig?: CaptionStyleConfig;
theme?: string;
}) => {
// CSS theme mode: load theme CSS, render with class names
const isStyleLoaded = useTheme(styleConfig ? "__skip__" : theme);
if (!styleConfig && !isStyleLoaded) return null;
const hasContent =
currentSegment &&
currentSegment.lines.some((line) => line.words.length > 0);
if (!hasContent || !currentSegment) return null;
const { start, end } = currentSegment.frameTime;
const fadeDuration = styleConfig
? styleConfig.animation.fade_duration_frames
: 3;
const segmentDuration = end - start;
const hasShortSegment = segmentDuration <= MIN_INTERPOLATE_SPAN * 2;
const middleFrame = start + segmentDuration / 2;
const fadeIn = Math.min(start + fadeDuration, end);
const fadeOut = Math.max(end - fadeDuration, start);
const hasFadePlateau = fadeOut - fadeIn > MIN_INTERPOLATE_SPAN;
// Segment transition
let opacity = 1;
let translateY = 0;
const transition = styleConfig?.animation.segment_transition ?? "fade";
if ((transition === "fade" || transition === "slide") && !hasShortSegment) {
opacity = interpolate(
currentFrame,
hasFadePlateau
? [start, fadeIn, fadeOut, end]
: [start, middleFrame, end],
hasFadePlateau ? [0, 1, 1, 0] : [0, 1, 0],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
);
}
if (transition === "slide" && !hasShortSegment) {
translateY = interpolate(
currentFrame,
hasFadePlateau
? [start, fadeIn, fadeOut, end]
: [start, middleFrame, end],
hasFadePlateau ? [20, 0, 0, -20] : [20, 0, -20],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
);
}
// Inline style mode
if (styleConfig) {
const { background } = styleConfig;
const segmentStyle: React.CSSProperties = {
opacity,
transform: translateY !== 0 ? `translateY(${translateY}px)` : undefined,
display: "flex",
flexDirection: "column",
alignItems: "center",
textAlign: "center",
width: "100%",
padding: background.bg_padding_px,
background: background.bg_color,
borderRadius: background.bg_border_radius_px,
};
if (background.bg_blur_px > 0) {
segmentStyle.backdropFilter = `blur(${background.bg_blur_px}px)`;
}
if (background.bg_glow_color) {
segmentStyle.boxShadow = `0 0 20px ${background.bg_glow_color}, 0 0 40px ${background.bg_glow_color}`;
}
return (
<div style={segmentStyle}>
{currentSegment.lines.map((line, index) => (
<StyledLine
key={`${index}-${line.text}`}
line={line}
currentWord={currentWord}
currentFrame={currentFrame}
style={styleConfig}
/>
))}
</div>
);
}
// CSS theme mode
return (
<div className="segment" style={{ opacity }}>
{currentSegment.lines.map((line, index) => (
<CssLine
key={`${index}-${line.text}`}
line={line}
currentWord={currentWord}
/>
))}
</div>
);
};
+61
View File
@@ -0,0 +1,61 @@
import React from "react";
import { AbsoluteFill, useCurrentFrame } from "remotion";
import { useCaptions } from "@/hooks/useCaptions";
import { Captions } from "./Captions";
import { Video } from "@remotion/media";
import { CompositionProps } from "@/types/captions_composition";
const VERTICAL_ALIGN_MAP = {
top: "flex-start",
center: "center",
bottom: "flex-end",
} as const;
export const CaptionsComposition: React.FC<CompositionProps> = ({
videoSrc,
transcription,
fps,
styleConfig,
}) => {
const frame = useCurrentFrame();
const { currentWord, currentSegment } = useCaptions(
fps,
frame,
transcription,
);
const alignItems = styleConfig
? VERTICAL_ALIGN_MAP[styleConfig.layout.vertical_position]
: "center";
const padding = styleConfig ? styleConfig.layout.padding_px : undefined;
const maxWidth = styleConfig ? `${styleConfig.layout.max_width_pct}%` : undefined;
const justifyContent = styleConfig
? styleConfig.layout.horizontal_alignment
: "center";
return (
<AbsoluteFill>
<AbsoluteFill>
<Video src={videoSrc} />
</AbsoluteFill>
<AbsoluteFill
style={{
display: "flex",
justifyContent,
alignItems,
padding,
}}
>
<div style={{ maxWidth, width: "100%" }}>
<Captions
currentFrame={frame}
currentWord={currentWord}
currentSegment={currentSegment}
styleConfig={styleConfig}
/>
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
+22
View File
@@ -0,0 +1,22 @@
import React from "react";
import { Composition } from "remotion";
import { CaptionsComposition } from "@/components/Composition";
import { getVideoMeta } from "@/hooks/useVideoMeta";
export const RemotionRoot: React.FC = () => {
return (
<>
<Composition
id="CaptionedVideo"
component={CaptionsComposition}
defaultProps={{
videoSrc: "video.mp4",
transcription: { segments: [] },
fps: 30,
styleConfig: undefined,
}}
calculateMetadata={getVideoMeta}
/>
</>
);
};
+60
View File
@@ -0,0 +1,60 @@
import { useMemo } from "react";
import { find, flatMap } from "lodash";
import {
LineNode,
SegmentNode,
SegmentWithFrames,
Transcription,
TranscriptionFramed,
WordNode,
WordWithFrames,
} from "@/types/transcription";
export const useCaptions = (
fps: number,
currentFrame: number,
transcription: Transcription | null,
) => {
const transcriptionWithFrames: TranscriptionFramed = useMemo(() => {
const addFrameTime = (node: SegmentNode | LineNode | WordNode) => {
const { start, end } = node.time;
return {
...node,
frameTime: { start: start * fps, end: end * fps },
};
};
const segments =
transcription?.segments.map((segment) => ({
...addFrameTime(segment),
lines: segment.lines.map((line) => ({
...addFrameTime(line),
words: line.words.map((word) => addFrameTime(word)),
})),
})) || [];
return { segments };
}, [fps, transcription]);
const { currentSegment, currentWord } = useMemo(() => {
const isCurrentFrame = (node: SegmentWithFrames | WordWithFrames) => {
if (!node.frameTime) return false;
const { start, end } = node.frameTime;
return currentFrame >= start && currentFrame < end;
};
const currentSegment =
find(transcriptionWithFrames?.segments, isCurrentFrame) ?? null;
const currentWord =
find(
flatMap(currentSegment?.lines, (line) => line.words),
isCurrentFrame,
) ?? null;
return { currentSegment, currentWord };
}, [currentFrame, transcriptionWithFrames]);
return { currentSegment, currentWord };
};
+29
View File
@@ -0,0 +1,29 @@
import { useEffect, useState } from "react";
import { continueRender, delayRender } from "remotion";
export const useTheme = (theme: string) => {
const [isStyleLoaded, setIsStyleLoaded] = useState(theme === "__skip__");
useEffect(() => {
// When using inline styles (styleConfig), skip CSS theme loading entirely
if (theme === "__skip__") {
setIsStyleLoaded(true);
return;
}
const handle = delayRender(`Loading theme: ${theme}`);
import(`@/themes/${theme}.css`)
.then(() => {
setIsStyleLoaded(true);
continueRender(handle);
})
.catch((err) => {
console.error(`Failed to load theme: ${theme}`, err);
setIsStyleLoaded(true);
continueRender(handle);
});
}, [theme]);
return isStyleLoaded;
};
+38
View File
@@ -0,0 +1,38 @@
import { CompositionProps } from "@/types/captions_composition";
import { parseMedia } from "@remotion/media-parser";
import { CalculateMetadataFunction } from "remotion";
const DEFAULT_FPS = 30;
const MIN_DURATION_FRAMES = 1;
export const getVideoMeta: CalculateMetadataFunction<
CompositionProps
> = async ({ props: { videoSrc } }) => {
const meta = await parseMedia({
src: videoSrc,
fields: {
slowDurationInSeconds: true,
dimensions: true,
fps: true,
},
acknowledgeRemotionLicense: true,
});
if (meta.dimensions === null) {
throw new Error("File is not a valid video.");
}
const fps = meta.fps ?? DEFAULT_FPS;
const durationInSeconds = meta.slowDurationInSeconds ?? 0;
const durationInFrames = Math.max(
Math.ceil(durationInSeconds * fps),
MIN_DURATION_FRAMES,
);
return {
durationInFrames,
width: meta.dimensions.width,
height: meta.dimensions.height,
fps,
};
};
+4
View File
@@ -0,0 +1,4 @@
import { registerRoot } from "remotion";
import { RemotionRoot } from "@/components/Root";
registerRoot(RemotionRoot);
+30
View File
@@ -0,0 +1,30 @@
.segment {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
width: 100%;
padding: 20px;
}
.line {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
margin-bottom: 10px;
}
.word {
font-family: "Lobster", sans-serif;
font-size: 40px;
color: white;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
transition: transform 0.1s ease;
}
.current-word {
color: #ffcc00;
transform: scale(1.1);
font-weight: bold;
}
+37
View File
@@ -0,0 +1,37 @@
.segment {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
width: 100%;
padding: 20px;
background: rgba(0, 0, 0, 0.6);
border-radius: 15px;
}
.line {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 15px;
margin-bottom: 15px;
}
.word {
font-family: "Courier New", monospace;
font-size: 45px;
color: #0ff;
text-shadow:
0 0 5px #0ff,
0 0 10px #0ff,
0 0 20px #0ff;
}
.current-word {
color: #f0f;
text-shadow:
0 0 5px #f0f,
0 0 10px #f0f,
0 0 20px #f0f;
transform: scale(1.2) rotate(-2deg);
}
+41
View File
@@ -0,0 +1,41 @@
export type CaptionTextStyle = {
font_family: string;
font_size: number;
font_weight: number;
text_color: string;
highlight_color: string;
text_shadow: string | null;
text_stroke_width: number;
text_stroke_color: string;
};
export type CaptionLayoutStyle = {
vertical_position: "top" | "center" | "bottom";
horizontal_alignment: "left" | "center" | "right";
padding_px: number;
max_width_pct: number;
lines_per_screen: number;
};
export type CaptionAnimationStyle = {
highlight_style: "color" | "scale" | "underline" | "color_scale";
highlight_scale: number;
segment_transition: "fade" | "slide" | "none";
fade_duration_frames: number;
animation_speed: number;
};
export type CaptionBackgroundStyle = {
bg_color: string;
bg_blur_px: number;
bg_glow_color: string | null;
bg_border_radius_px: number;
bg_padding_px: number;
};
export type CaptionStyleConfig = {
text: CaptionTextStyle;
layout: CaptionLayoutStyle;
animation: CaptionAnimationStyle;
background: CaptionBackgroundStyle;
};
+9
View File
@@ -0,0 +1,9 @@
import { Transcription } from "./transcription";
import { CaptionStyleConfig } from "./caption_style";
export type CompositionProps = {
videoSrc: string;
transcription: Transcription;
fps: number;
styleConfig?: CaptionStyleConfig;
};
+1
View File
@@ -0,0 +1 @@
declare module "*.css";
+58
View File
@@ -0,0 +1,58 @@
export type TimeRange = {
start: number;
end: number;
};
export type StructureTag = {
name: string;
};
export type WordNode = {
text: string;
semantic_tags: string[];
structure_tags: StructureTag[];
time: TimeRange;
};
export type LineNode = {
text: string;
semantic_tags: string[];
structure_tags: StructureTag[];
time: TimeRange;
words: WordNode[];
};
export type SegmentNode = {
text: string;
semantic_tags: string[];
structure_tags: StructureTag[];
time: TimeRange;
lines: LineNode[];
};
export type Transcription = {
segments: SegmentNode[];
};
export type FrameTime = {
start: number;
end: number;
};
export type WordWithFrames = WordNode & {
frameTime: TimeRange;
};
export type LineWithFrames = Omit<LineNode, "words"> & {
frameTime: TimeRange;
words: WordWithFrames[];
};
export type SegmentWithFrames = Omit<SegmentNode, "lines"> & {
frameTime: TimeRange;
lines: LineWithFrames[];
};
export type TranscriptionFramed = {
segments: SegmentWithFrames[];
};
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"lib": ["ES2022"],
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noUnusedLocals": true,
"resolveJsonModule": true,
"types": ["bun-types"],
"paths": {
"@/*": ["./src/*"],
"@/public/*": ["./public/*"],
"@/srv/*": ["./server/*"]
}
},
"exclude": ["remotion.config.ts"]
}