@thinkable-labs/lower-third-generator 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
Binary file
@@ -0,0 +1,3 @@
1
+ export { renderLowerThird, renderBatch } from "./renderer";
2
+ export type { RenderOptions, RenderResult, BatchOptions, BatchResult, BatchProgressCallback, } from "./renderer";
3
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderBatch = exports.renderLowerThird = void 0;
4
+ var renderer_1 = require("./renderer");
5
+ Object.defineProperty(exports, "renderLowerThird", { enumerable: true, get: function () { return renderer_1.renderLowerThird; } });
6
+ Object.defineProperty(exports, "renderBatch", { enumerable: true, get: function () { return renderer_1.renderBatch; } });
7
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,43 @@
1
+ export interface RenderOptions {
2
+ text: string;
3
+ outputDir: string;
4
+ fileName?: string;
5
+ fps?: number;
6
+ durationInFrames?: number;
7
+ width?: number;
8
+ height?: number;
9
+ }
10
+ export interface RenderResult {
11
+ text: string;
12
+ outputPath: string;
13
+ durationSeconds: number;
14
+ }
15
+ export interface BatchOptions {
16
+ texts: string[];
17
+ outputDir: string;
18
+ fps?: number;
19
+ durationInFrames?: number;
20
+ width?: number;
21
+ height?: number;
22
+ }
23
+ export interface BatchResult {
24
+ results: RenderResult[];
25
+ totalCount: number;
26
+ successCount: number;
27
+ errorCount: number;
28
+ errors: {
29
+ text: string;
30
+ error: string;
31
+ }[];
32
+ }
33
+ export type BatchProgressCallback = (update: {
34
+ current: number;
35
+ total: number;
36
+ text: string;
37
+ status: "rendering" | "success" | "error";
38
+ outputPath?: string;
39
+ error?: string;
40
+ }) => void;
41
+ export declare function renderLowerThird(options: RenderOptions): Promise<RenderResult>;
42
+ export declare function renderBatch(options: BatchOptions, onProgress?: BatchProgressCallback): Promise<BatchResult>;
43
+ //# sourceMappingURL=renderer.d.ts.map
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.renderLowerThird = renderLowerThird;
7
+ exports.renderBatch = renderBatch;
8
+ const path_1 = __importDefault(require("path"));
9
+ const bundler_1 = require("@remotion/bundler");
10
+ const renderer_1 = require("@remotion/renderer");
11
+ // ─── Sanitize filename ──────────────────────────────────────────────────────
12
+ function sanitizeFileName(text) {
13
+ return text
14
+ .replace(/[<>:"/\\|?*]/g, "")
15
+ .replace(/\s+/g, "_")
16
+ .substring(0, 60);
17
+ }
18
+ // ─── Bundle cache ───────────────────────────────────────────────────────────
19
+ let bundlePromise = null;
20
+ async function getBundled() {
21
+ if (!bundlePromise) {
22
+ const remotionEntry = path_1.default.resolve(__dirname, "..", "remotion", "index.ts");
23
+ bundlePromise = (0, bundler_1.bundle)({ entryPoint: remotionEntry });
24
+ }
25
+ return bundlePromise;
26
+ }
27
+ // ─── Render a single lower third ────────────────────────────────────────────
28
+ async function renderLowerThird(options) {
29
+ const fps = options.fps ?? 60;
30
+ const durationInFrames = options.durationInFrames ?? 210;
31
+ const width = options.width ?? 3840;
32
+ const height = options.height ?? 2160;
33
+ const fileName = options.fileName ?? `${sanitizeFileName(options.text)}.mov`;
34
+ const outputPath = path_1.default.join(options.outputDir, fileName);
35
+ const bundled = await getBundled();
36
+ const composition = await (0, renderer_1.selectComposition)({
37
+ serveUrl: bundled,
38
+ id: "LowerThird",
39
+ inputProps: { text: options.text },
40
+ });
41
+ // Override composition settings
42
+ composition.fps = fps;
43
+ composition.durationInFrames = durationInFrames;
44
+ composition.width = width;
45
+ composition.height = height;
46
+ await (0, renderer_1.renderMedia)({
47
+ composition,
48
+ serveUrl: bundled,
49
+ codec: "prores",
50
+ proResProfile: "4444",
51
+ pixelFormat: "yuva444p10le",
52
+ imageFormat: "png",
53
+ outputLocation: outputPath,
54
+ inputProps: { text: options.text },
55
+ });
56
+ return {
57
+ text: options.text,
58
+ outputPath,
59
+ durationSeconds: durationInFrames / fps,
60
+ };
61
+ }
62
+ // ─── Render a batch of lower thirds ─────────────────────────────────────────
63
+ async function renderBatch(options, onProgress) {
64
+ const results = [];
65
+ const errors = [];
66
+ let successCount = 0;
67
+ // Pre-bundle once for all renders
68
+ await getBundled();
69
+ for (let i = 0; i < options.texts.length; i++) {
70
+ const text = options.texts[i];
71
+ onProgress?.({
72
+ current: i + 1,
73
+ total: options.texts.length,
74
+ text,
75
+ status: "rendering",
76
+ });
77
+ try {
78
+ const result = await renderLowerThird({
79
+ text,
80
+ outputDir: options.outputDir,
81
+ fps: options.fps,
82
+ durationInFrames: options.durationInFrames,
83
+ width: options.width,
84
+ height: options.height,
85
+ });
86
+ results.push(result);
87
+ successCount++;
88
+ onProgress?.({
89
+ current: i + 1,
90
+ total: options.texts.length,
91
+ text,
92
+ status: "success",
93
+ outputPath: result.outputPath,
94
+ });
95
+ }
96
+ catch (err) {
97
+ errors.push({ text, error: err?.message || String(err) });
98
+ onProgress?.({
99
+ current: i + 1,
100
+ total: options.texts.length,
101
+ text,
102
+ status: "error",
103
+ error: err?.message || String(err),
104
+ });
105
+ }
106
+ }
107
+ return {
108
+ results,
109
+ totalCount: options.texts.length,
110
+ successCount,
111
+ errorCount: errors.length,
112
+ errors,
113
+ };
114
+ }
115
+ //# sourceMappingURL=renderer.js.map
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@thinkable-labs/lower-third-generator",
3
+ "version": "1.0.0",
4
+ "description": "Generate lower third videos with alpha channel using Remotion",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist/**/*.js",
9
+ "dist/**/*.d.ts",
10
+ "remotion/**/*",
11
+ "public/**/*",
12
+ "assets/**/*"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "prepublishOnly": "tsc",
17
+ "preview": "remotion preview remotion/index.ts",
18
+ "test-render": "remotion render remotion/index.ts LowerThird --output test-output.mov --codec prores --prores-profile 4444 --scale 2"
19
+ },
20
+ "license": "UNLICENSED",
21
+ "dependencies": {
22
+ "@remotion/bundler": "^4.0.0",
23
+ "@remotion/cli": "^4.0.0",
24
+ "@remotion/renderer": "^4.0.0",
25
+ "react": "^18.3.1",
26
+ "react-dom": "^18.3.1",
27
+ "remotion": "^4.0.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^25.5.0",
31
+ "@types/react": "^18.3.0",
32
+ "typescript": "^5.9.3"
33
+ }
34
+ }
Binary file
Binary file
@@ -0,0 +1,187 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import {
3
+ AbsoluteFill,
4
+ continueRender,
5
+ delayRender,
6
+ Img,
7
+ interpolate,
8
+ staticFile,
9
+ useCurrentFrame,
10
+ useVideoConfig,
11
+ Easing,
12
+ } from "remotion";
13
+
14
+ const FONT_FAMILY = "WoodBlockCG";
15
+ const fontUrl = staticFile("WoodBlockCG.otf");
16
+
17
+ const fontFace = `
18
+ @font-face {
19
+ font-family: '${FONT_FAMILY}';
20
+ src: url('${fontUrl}') format('opentype');
21
+ font-weight: normal;
22
+ font-style: normal;
23
+ }
24
+ `;
25
+
26
+ export interface LowerThirdProps {
27
+ text: string;
28
+ }
29
+
30
+ export const LowerThird: React.FC<LowerThirdProps> = ({ text }) => {
31
+ const [handle] = useState(() => delayRender("Loading font"));
32
+
33
+ useEffect(() => {
34
+ const font = new FontFace(FONT_FAMILY, `url('${fontUrl}') format('opentype')`);
35
+ font.load().then(() => {
36
+ document.fonts.add(font);
37
+ continueRender(handle);
38
+ }).catch(() => continueRender(handle));
39
+ }, [handle]);
40
+
41
+ const frame = useCurrentFrame();
42
+ const { fps, width, height } = useVideoConfig();
43
+
44
+ // ─── Animation timeline (3.5 seconds total at 60fps = 210 frames) ───
45
+ // 0.0s - 0.2s: Logo badge fades in
46
+ // 0.2s - 0.5s: Text rectangle unfolds from left to right
47
+ // 0.4s - 0.6s: Text fades in
48
+ // 0.6s - 2.5s: Hold
49
+ // 2.5s - 3.5s: Everything fades out
50
+
51
+ const logoFadeIn = interpolate(frame, [0, fps * 0.2], [0, 1], {
52
+ extrapolateLeft: "clamp",
53
+ extrapolateRight: "clamp",
54
+ easing: Easing.out(Easing.ease),
55
+ });
56
+
57
+ const barUnfold = interpolate(frame, [fps * 0.2, fps * 0.7], [0, 1], {
58
+ extrapolateLeft: "clamp",
59
+ extrapolateRight: "clamp",
60
+ easing: Easing.out(Easing.cubic),
61
+ });
62
+
63
+ const textFadeIn = interpolate(frame, [fps * 0.4, fps * 0.6], [0, 1], {
64
+ extrapolateLeft: "clamp",
65
+ extrapolateRight: "clamp",
66
+ });
67
+
68
+ const fadeOut = interpolate(frame, [fps * 2.5, fps * 3.5], [1, 0], {
69
+ extrapolateLeft: "clamp",
70
+ extrapolateRight: "clamp",
71
+ easing: Easing.in(Easing.ease),
72
+ });
73
+
74
+ // ─── Dimensions (4K) ───────────────────────────────────
75
+ // PART 1: Logo badge — circle on left, square on right
76
+ // The badge is a square with the left half rounded into a circle
77
+ const badgeSize = 300;
78
+ const borderWidth = badgeSize * 0.035;
79
+
80
+ // PART 2: Text rectangle — same height as badge, width fits text
81
+ const textBoxHeight = badgeSize;
82
+ const fontSize = 137;
83
+
84
+ const margin = width * 0.03;
85
+ const bottomMargin = height * 0.08;
86
+
87
+ return (
88
+ <AbsoluteFill style={{ backgroundColor: "transparent" }}>
89
+ <style dangerouslySetInnerHTML={{ __html: fontFace }} />
90
+ <div
91
+ style={{
92
+ position: "absolute",
93
+ bottom: bottomMargin,
94
+ left: margin,
95
+ display: "flex",
96
+ alignItems: "center",
97
+ opacity: fadeOut,
98
+ height: badgeSize,
99
+ }}
100
+ >
101
+ {/* PART 1: Logo badge (circle left + square right) */}
102
+ <div
103
+ style={{
104
+ width: badgeSize,
105
+ height: badgeSize,
106
+ flexShrink: 0,
107
+ opacity: logoFadeIn,
108
+ zIndex: 2,
109
+ position: "relative",
110
+ }}
111
+ >
112
+ {/* Brown background: left half rounded, right half square */}
113
+ <div
114
+ style={{
115
+ position: "absolute",
116
+ inset: 0,
117
+ backgroundColor: "#B36231",
118
+ borderRadius: `${badgeSize / 2}px 0 0 ${badgeSize / 2}px`,
119
+ boxShadow: `inset 0 0 0 ${borderWidth}px #C67F2C`,
120
+ }}
121
+ />
122
+ {/* Circular logo on top with stroke */}
123
+ <div
124
+ style={{
125
+ position: "absolute",
126
+ top: "50%",
127
+ left: "50%",
128
+ transform: "translate(-50%, -50%)",
129
+ width: badgeSize * 0.75,
130
+ height: badgeSize * 0.75,
131
+ borderRadius: "50%",
132
+ overflow: "hidden",
133
+ zIndex: 3,
134
+ boxShadow: `0 0 0 ${borderWidth * 1.5}px #C67F2C, 0 ${badgeSize * 0.02}px ${badgeSize * 0.06}px rgba(0,0,0,0.4)`,
135
+ }}
136
+ >
137
+ <Img
138
+ src={staticFile("logo.png")}
139
+ style={{
140
+ width: "100%",
141
+ height: "100%",
142
+ objectFit: "cover",
143
+ }}
144
+ />
145
+ </div>
146
+ </div>
147
+
148
+ {/* PART 2: Text rectangle — unfolds from left to right */}
149
+ <div
150
+ style={{
151
+ height: textBoxHeight,
152
+ zIndex: 1,
153
+ clipPath: `inset(0 ${(1 - barUnfold) * 100}% 0 0)`,
154
+ marginLeft: 0,
155
+ }}
156
+ >
157
+ <div
158
+ style={{
159
+ height: textBoxHeight,
160
+ backgroundColor: "#B36231",
161
+ boxShadow: `inset 0 0 0 ${borderWidth}px #C67F2C`,
162
+ borderRadius: `0 ${borderWidth * 2}px ${borderWidth * 2}px 0`,
163
+ display: "flex",
164
+ alignItems: "center",
165
+ paddingLeft: fontSize * 0.6,
166
+ paddingRight: fontSize * 0.6,
167
+ }}
168
+ >
169
+ <span
170
+ style={{
171
+ color: "#FFFFFF",
172
+ fontSize,
173
+ fontWeight: "normal",
174
+ fontFamily: `'${FONT_FAMILY}', Arial, sans-serif`,
175
+ letterSpacing: fontSize * 0.02,
176
+ whiteSpace: "nowrap",
177
+ opacity: textFadeIn,
178
+ }}
179
+ >
180
+ {text}
181
+ </span>
182
+ </div>
183
+ </div>
184
+ </div>
185
+ </AbsoluteFill>
186
+ );
187
+ };
@@ -0,0 +1,20 @@
1
+ import React from "react";
2
+ import { Composition } from "remotion";
3
+ import { LowerThird } from "./LowerThird";
4
+
5
+ export const Root: React.FC = () => {
6
+ return (
7
+ <Composition
8
+ id="LowerThird"
9
+ // @ts-expect-error Remotion Composition typing mismatch with delayRender hooks
10
+ component={LowerThird}
11
+ durationInFrames={210}
12
+ fps={60}
13
+ width={3840}
14
+ height={2160}
15
+ defaultProps={{
16
+ text: "JESUS - MATTHEW 26:38",
17
+ }}
18
+ />
19
+ );
20
+ };
@@ -0,0 +1,4 @@
1
+ import { registerRoot } from "remotion";
2
+ import { Root } from "./Root";
3
+
4
+ registerRoot(Root);