initial commit
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
dist
|
||||
.remotion
|
||||
.env
|
||||
.DS_Store
|
||||
*.log
|
||||
tmp
|
||||
*.tmp
|
||||
.cache
|
||||
build
|
||||
out
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
import { config } from "@remotion/eslint-config-flat";
|
||||
|
||||
export default config;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from "remotion";
|
||||
import { RemotionRoot } from "@/components/Root";
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Vendored
+41
@@ -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;
|
||||
};
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
import { Transcription } from "./transcription";
|
||||
import { CaptionStyleConfig } from "./caption_style";
|
||||
|
||||
export type CompositionProps = {
|
||||
videoSrc: string;
|
||||
transcription: Transcription;
|
||||
fps: number;
|
||||
styleConfig?: CaptionStyleConfig;
|
||||
};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare module "*.css";
|
||||
Vendored
+58
@@ -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[];
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user