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