@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.
- package/assets/Wood Block CG.otf +0 -0
- package/assets/logo.png +0 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/renderer.d.ts +43 -0
- package/dist/renderer.js +115 -0
- package/package.json +34 -0
- package/public/WoodBlockCG.otf +0 -0
- package/public/logo.png +0 -0
- package/remotion/LowerThird.tsx +187 -0
- package/remotion/Root.tsx +20 -0
- package/remotion/index.ts +4 -0
|
Binary file
|
package/assets/logo.png
ADDED
|
Binary file
|
package/dist/index.d.ts
ADDED
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
|
package/dist/renderer.js
ADDED
|
@@ -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
|
package/public/logo.png
ADDED
|
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
|
+
};
|