@shotstack/shotstack-canvas 1.0.2 → 1.0.4
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/dist/entry.node.cjs +1548 -0
- package/dist/entry.node.cjs.map +1 -0
- package/dist/entry.node.d.cts +229 -0
- package/dist/entry.node.d.ts +229 -0
- package/dist/entry.node.js +1511 -0
- package/dist/entry.node.js.map +1 -0
- package/dist/entry.web.d.ts +207 -0
- package/dist/entry.web.js +1285 -0
- package/dist/entry.web.js.map +1 -0
- package/package.json +20 -7
- package/.eslintrc.cjs +0 -7
- package/.prettierrc +0 -1
- package/pnpm-workspace.yaml +0 -3
- package/scripts/vendor-harfbuzz.js +0 -64
- package/src/config/canvas-constants.ts +0 -30
- package/src/core/animations.ts +0 -570
- package/src/core/colors.ts +0 -11
- package/src/core/decoration.ts +0 -9
- package/src/core/drawops.ts +0 -206
- package/src/core/font-registry.ts +0 -77
- package/src/core/gradients.ts +0 -12
- package/src/core/layout.ts +0 -184
- package/src/core/utils.ts +0 -3
- package/src/core/video-generator.ts +0 -157
- package/src/env/entry.node.ts +0 -167
- package/src/env/entry.web.ts +0 -146
- package/src/index.ts +0 -1
- package/src/io/node.ts +0 -45
- package/src/io/web.ts +0 -5
- package/src/painters/node.ts +0 -290
- package/src/painters/web.ts +0 -224
- package/src/schema/asset-schema.ts +0 -166
- package/src/types.ts +0 -36
- package/src/wasm/hb-loader.ts +0 -31
- package/tsconfig.base.json +0 -22
- package/tsconfig.json +0 -16
- package/tsup.config.ts +0 -52
package/src/core/drawops.ts
DELETED
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
import { DrawOp, GradientSpec, ShapedLine } from "../types";
|
|
2
|
-
import { gradientSpecFrom } from "./gradients";
|
|
3
|
-
import { decorationGeometry } from "./decoration";
|
|
4
|
-
|
|
5
|
-
export type PaintParams = {
|
|
6
|
-
canvas: { width: number; height: number; pixelRatio: number };
|
|
7
|
-
textRect: { x: number; y: number; width: number; height: number };
|
|
8
|
-
lines: ShapedLine[];
|
|
9
|
-
font: { family: string; size: number; weight: string | number; style: string; color: string; opacity: number };
|
|
10
|
-
style: {
|
|
11
|
-
lineHeight: number;
|
|
12
|
-
textDecoration: "none" | "underline" | "line-through";
|
|
13
|
-
gradient?: { type: "linear" | "radial"; angle: number; stops: { offset: number; color: string }[] };
|
|
14
|
-
};
|
|
15
|
-
stroke?: { width: number; color: string; opacity: number };
|
|
16
|
-
shadow?: { offsetX: number; offsetY: number; blur: number; color: string; opacity: number };
|
|
17
|
-
align: { horizontal: "left" | "center" | "right"; vertical: "top" | "middle" | "bottom" };
|
|
18
|
-
background?: { color?: string; opacity: number; borderRadius: number };
|
|
19
|
-
glyphPathProvider: (glyphId: number) => string;
|
|
20
|
-
/** UPEM for scaling glyph paths from font units → px */
|
|
21
|
-
getUnitsPerEm?: () => number;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
export function buildDrawOps(p: PaintParams): DrawOp[] {
|
|
25
|
-
const ops: DrawOp[] = [];
|
|
26
|
-
|
|
27
|
-
// Begin frame / background
|
|
28
|
-
ops.push({
|
|
29
|
-
op: "BeginFrame",
|
|
30
|
-
width: p.canvas.width,
|
|
31
|
-
height: p.canvas.height,
|
|
32
|
-
pixelRatio: p.canvas.pixelRatio,
|
|
33
|
-
clear: true,
|
|
34
|
-
bg: p.background
|
|
35
|
-
? { color: p.background.color, opacity: p.background.opacity, radius: p.background.borderRadius }
|
|
36
|
-
: undefined
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
if (p.lines.length === 0) return ops;
|
|
40
|
-
|
|
41
|
-
// Font units → px
|
|
42
|
-
const upem = Math.max(1, p.getUnitsPerEm?.() ?? 1000);
|
|
43
|
-
const scale = p.font.size / upem;
|
|
44
|
-
|
|
45
|
-
// Block metrics
|
|
46
|
-
const blockHeight = p.lines[p.lines.length - 1].y;
|
|
47
|
-
|
|
48
|
-
// Vertical anchor
|
|
49
|
-
let blockY: number;
|
|
50
|
-
switch (p.align.vertical) {
|
|
51
|
-
case "top": blockY = p.font.size; break;
|
|
52
|
-
case "bottom": blockY = p.textRect.height - blockHeight + p.font.size; break;
|
|
53
|
-
case "middle":
|
|
54
|
-
default: blockY = (p.textRect.height - blockHeight) / 2 + p.font.size; break;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Fill style (solid or gradient)
|
|
58
|
-
const fill: GradientSpec = p.style.gradient
|
|
59
|
-
? gradientSpecFrom(p.style.gradient, 1)
|
|
60
|
-
: { kind: "solid", color: p.font.color, opacity: p.font.opacity };
|
|
61
|
-
|
|
62
|
-
// Decoration/cursor color should match text (for gradient, use the last stop as a representative color)
|
|
63
|
-
const decoColor = p.style.gradient
|
|
64
|
-
? p.style.gradient.stops[p.style.gradient.stops.length - 1]?.color ?? p.font.color
|
|
65
|
-
: p.font.color;
|
|
66
|
-
|
|
67
|
-
// Track global text bounds in world space so painters can build a single gradient
|
|
68
|
-
let gMinX = Infinity, gMinY = Infinity, gMaxX = -Infinity, gMaxY = -Infinity;
|
|
69
|
-
|
|
70
|
-
for (const line of p.lines) {
|
|
71
|
-
// Horizontal anchor
|
|
72
|
-
let lineX: number;
|
|
73
|
-
switch (p.align.horizontal) {
|
|
74
|
-
case "left": lineX = 0; break;
|
|
75
|
-
case "right": lineX = p.textRect.width - line.width; break;
|
|
76
|
-
case "center":
|
|
77
|
-
default: lineX = (p.textRect.width - line.width) / 2; break;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
let xCursor = lineX;
|
|
81
|
-
const baselineY = blockY + line.y - p.font.size;
|
|
82
|
-
|
|
83
|
-
for (const glyph of line.glyphs) {
|
|
84
|
-
const path = p.glyphPathProvider(glyph.id);
|
|
85
|
-
if (!path || path === "M 0 0") {
|
|
86
|
-
xCursor += glyph.xAdvance;
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const glyphX = xCursor + glyph.xOffset;
|
|
91
|
-
const glyphY = baselineY + glyph.yOffset;
|
|
92
|
-
|
|
93
|
-
// Update global bbox using path's local bounds mapped to world (scale s, flip Y)
|
|
94
|
-
const pb = computePathBounds(path);
|
|
95
|
-
const x1 = glyphX + scale * pb.x;
|
|
96
|
-
const x2 = glyphX + scale * (pb.x + pb.w);
|
|
97
|
-
const y1 = glyphY - scale * (pb.y + pb.h);
|
|
98
|
-
const y2 = glyphY - scale * pb.y;
|
|
99
|
-
if (x1 < gMinX) gMinX = x1;
|
|
100
|
-
if (y1 < gMinY) gMinY = y1;
|
|
101
|
-
if (x2 > gMaxX) gMaxX = x2;
|
|
102
|
-
if (y2 > gMaxY) gMaxY = y2;
|
|
103
|
-
|
|
104
|
-
// 1) Shadow (under everything)
|
|
105
|
-
if (p.shadow && p.shadow.blur > 0) {
|
|
106
|
-
ops.push({
|
|
107
|
-
isShadow: true,
|
|
108
|
-
op: "FillPath",
|
|
109
|
-
path,
|
|
110
|
-
x: glyphX + p.shadow.offsetX,
|
|
111
|
-
y: glyphY + p.shadow.offsetY,
|
|
112
|
-
// @ts-ignore scale propagated to painters
|
|
113
|
-
scale,
|
|
114
|
-
fill: { kind: "solid", color: p.shadow.color, opacity: p.shadow.opacity }
|
|
115
|
-
} as any);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// 2) Stroke (under fill)
|
|
119
|
-
if (p.stroke && p.stroke.width > 0) {
|
|
120
|
-
ops.push({
|
|
121
|
-
op: "StrokePath",
|
|
122
|
-
path,
|
|
123
|
-
x: glyphX,
|
|
124
|
-
y: glyphY,
|
|
125
|
-
// @ts-ignore scale propagated to painters
|
|
126
|
-
scale,
|
|
127
|
-
width: p.stroke.width,
|
|
128
|
-
color: p.stroke.color,
|
|
129
|
-
opacity: p.stroke.opacity
|
|
130
|
-
} as any);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// 3) Fill (on top)
|
|
134
|
-
ops.push({
|
|
135
|
-
op: "FillPath",
|
|
136
|
-
path,
|
|
137
|
-
x: glyphX,
|
|
138
|
-
y: glyphY,
|
|
139
|
-
// @ts-ignore scale propagated to painters
|
|
140
|
-
scale,
|
|
141
|
-
fill
|
|
142
|
-
} as any);
|
|
143
|
-
|
|
144
|
-
xCursor += glyph.xAdvance;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Decoration lines (use text/deco color)
|
|
148
|
-
if (p.style.textDecoration !== "none") {
|
|
149
|
-
const deco = decorationGeometry(p.style.textDecoration, {
|
|
150
|
-
baselineY,
|
|
151
|
-
fontSize: p.font.size,
|
|
152
|
-
lineWidth: line.width,
|
|
153
|
-
xStart: lineX
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
ops.push({
|
|
157
|
-
op: "DecorationLine",
|
|
158
|
-
from: { x: deco.x1, y: deco.y },
|
|
159
|
-
to: { x: deco.x2, y: deco.y },
|
|
160
|
-
width: deco.width,
|
|
161
|
-
color: decoColor,
|
|
162
|
-
opacity: p.font.opacity
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Attach a full-text gradient bbox to all glyph fills (stable across typewriter)
|
|
168
|
-
if (gMinX !== Infinity) {
|
|
169
|
-
const gbox = { x: gMinX, y: gMinY, w: Math.max(1, gMaxX - gMinX), h: Math.max(1, gMaxY - gMinY) };
|
|
170
|
-
for (const op of ops) {
|
|
171
|
-
if (op.op === "FillPath" && !(op as any).isShadow) {
|
|
172
|
-
(op as any).gradientBBox = gbox;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
return ops;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/* ----------------- local helpers ----------------- */
|
|
181
|
-
function tokenizePath(d: string): string[] {
|
|
182
|
-
return d.match(/[MLCQZ]|-?\d*\.?\d+(?:e[-+]?\d+)?/gi) ?? [];
|
|
183
|
-
}
|
|
184
|
-
function computePathBounds(d: string) {
|
|
185
|
-
const t = tokenizePath(d);
|
|
186
|
-
let i = 0;
|
|
187
|
-
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
188
|
-
const touch = (x: number, y: number) => { if (x < minX) minX = x; if (y < minY) minY = y; if (x > maxX) maxX = x; if (y > maxY) maxY = y; };
|
|
189
|
-
while (i < t.length) {
|
|
190
|
-
const cmd = t[i++];
|
|
191
|
-
switch (cmd) {
|
|
192
|
-
case "M":
|
|
193
|
-
case "L": { const x = parseFloat(t[i++]); const y = parseFloat(t[i++]); touch(x, y); break; }
|
|
194
|
-
case "C": {
|
|
195
|
-
const c1x = parseFloat(t[i++]); const c1y = parseFloat(t[i++]);
|
|
196
|
-
const c2x = parseFloat(t[i++]); const c2y = parseFloat(t[i++]);
|
|
197
|
-
const x = parseFloat(t[i++]); const y = parseFloat(t[i++]);
|
|
198
|
-
touch(c1x, c1y); touch(c2x, c2y); touch(x, y); break;
|
|
199
|
-
}
|
|
200
|
-
case "Q": { const cx = parseFloat(t[i++]); const cy = parseFloat(t[i++]); const x = parseFloat(t[i++]); const y = parseFloat(t[i++]); touch(cx, cy); touch(x, y); break; }
|
|
201
|
-
case "Z": break;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
if (minX === Infinity) return { x: 0, y: 0, w: 0, h: 0 };
|
|
205
|
-
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
|
206
|
-
}
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import { initHB, type HB } from "../wasm/hb-loader";
|
|
2
|
-
|
|
3
|
-
export type FontDescriptor = { family: string; weight?: string | number; style?: string };
|
|
4
|
-
|
|
5
|
-
export class FontRegistry {
|
|
6
|
-
private hb!: HB;
|
|
7
|
-
private faces = new Map<string, any>();
|
|
8
|
-
private fonts = new Map<string, any>();
|
|
9
|
-
private blobs = new Map<string, any>();
|
|
10
|
-
private wasmBaseURL?: string;
|
|
11
|
-
|
|
12
|
-
constructor(wasmBaseURL?: string) {
|
|
13
|
-
this.wasmBaseURL = wasmBaseURL;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
async init() {
|
|
17
|
-
if (!this.hb) this.hb = await initHB(this.wasmBaseURL);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
getHB(): HB {
|
|
21
|
-
if (!this.hb) throw new Error("FontRegistry not initialized - call init() first");
|
|
22
|
-
return this.hb;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
private key(desc: FontDescriptor) {
|
|
26
|
-
return `${desc.family}__${desc.weight ?? "400"}__${desc.style ?? "normal"}`;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async registerFromBytes(bytes: ArrayBuffer, desc: FontDescriptor): Promise<void> {
|
|
30
|
-
if (!this.hb) await this.init();
|
|
31
|
-
const k = this.key(desc);
|
|
32
|
-
if (this.fonts.has(k)) return;
|
|
33
|
-
|
|
34
|
-
const blob = this.hb.createBlob(bytes);
|
|
35
|
-
const face = this.hb.createFace(blob, 0);
|
|
36
|
-
const font = this.hb.createFont(face);
|
|
37
|
-
|
|
38
|
-
// Keep HarfBuzz in font-units; we scale during painting
|
|
39
|
-
const upem = face.upem || 1000;
|
|
40
|
-
font.setScale(upem, upem);
|
|
41
|
-
|
|
42
|
-
this.blobs.set(k, blob);
|
|
43
|
-
this.faces.set(k, face);
|
|
44
|
-
this.fonts.set(k, font);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
getFont(desc: FontDescriptor): any {
|
|
48
|
-
const k = this.key(desc);
|
|
49
|
-
const f = this.fonts.get(k);
|
|
50
|
-
if (!f) throw new Error(`Font not registered for ${k}`);
|
|
51
|
-
return f;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
getFace(desc: FontDescriptor): any {
|
|
55
|
-
const k = this.key(desc);
|
|
56
|
-
return this.faces.get(k);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** NEW: expose units-per-em for scaling glyph paths to px */
|
|
60
|
-
getUnitsPerEm(desc: FontDescriptor): number {
|
|
61
|
-
const face = this.getFace(desc);
|
|
62
|
-
return face?.upem || 1000;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
glyphPath(desc: FontDescriptor, glyphId: number): string {
|
|
66
|
-
const font = this.getFont(desc);
|
|
67
|
-
const path = font.glyphToPath(glyphId);
|
|
68
|
-
return path && path !== "" ? path : "M 0 0";
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
destroy() {
|
|
72
|
-
for (const [, f] of this.fonts) f.destroy?.();
|
|
73
|
-
for (const [, f] of this.faces) f.destroy?.();
|
|
74
|
-
for (const [, b] of this.blobs) b.destroy?.();
|
|
75
|
-
this.fonts.clear(); this.faces.clear(); this.blobs.clear();
|
|
76
|
-
}
|
|
77
|
-
}
|
package/src/core/gradients.ts
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { GradientSpec } from "../types";
|
|
2
|
-
|
|
3
|
-
export function gradientSpecFrom(
|
|
4
|
-
g: { type: "linear" | "radial"; angle: number; stops: { offset: number; color: string }[] },
|
|
5
|
-
opacity: number
|
|
6
|
-
): GradientSpec {
|
|
7
|
-
if (g.type === "linear") {
|
|
8
|
-
return { kind: "linear", angle: g.angle, stops: g.stops, opacity };
|
|
9
|
-
} else {
|
|
10
|
-
return { kind: "radial", stops: g.stops, opacity };
|
|
11
|
-
}
|
|
12
|
-
}
|
package/src/core/layout.ts
DELETED
|
@@ -1,184 +0,0 @@
|
|
|
1
|
-
import { FontRegistry, FontDescriptor } from "./font-registry";
|
|
2
|
-
import { Glyph, ShapedLine } from "../types";
|
|
3
|
-
|
|
4
|
-
export type ShapeParams = {
|
|
5
|
-
text: string;
|
|
6
|
-
width: number;
|
|
7
|
-
letterSpacing: number;
|
|
8
|
-
fontSize: number;
|
|
9
|
-
lineHeight: number;
|
|
10
|
-
desc: FontDescriptor;
|
|
11
|
-
textTransform: "none" | "uppercase" | "lowercase" | "capitalize";
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
type HBRunGlyph = { g: number; ax: number; ay: number; dx: number; dy: number; cl: number };
|
|
15
|
-
|
|
16
|
-
export class LayoutEngine {
|
|
17
|
-
constructor(private fonts: FontRegistry) {}
|
|
18
|
-
|
|
19
|
-
private transformText(t: string, tr: ShapeParams["textTransform"]) {
|
|
20
|
-
switch (tr) {
|
|
21
|
-
case "uppercase":
|
|
22
|
-
return t.toUpperCase();
|
|
23
|
-
case "lowercase":
|
|
24
|
-
return t.toLowerCase();
|
|
25
|
-
case "capitalize":
|
|
26
|
-
return t.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
27
|
-
default:
|
|
28
|
-
return t;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
private shapeFull(text: string, desc: FontDescriptor): HBRunGlyph[] {
|
|
33
|
-
const hb = this.fonts.getHB();
|
|
34
|
-
const buffer = hb.createBuffer();
|
|
35
|
-
|
|
36
|
-
buffer.addText(text);
|
|
37
|
-
buffer.guessSegmentProperties();
|
|
38
|
-
|
|
39
|
-
const font = this.fonts.getFont(desc);
|
|
40
|
-
const face = this.fonts.getFace(desc);
|
|
41
|
-
|
|
42
|
-
// Set proper scale for shaping
|
|
43
|
-
const upem = face?.upem || 1000;
|
|
44
|
-
font.setScale(upem, upem);
|
|
45
|
-
|
|
46
|
-
hb.shape(font, buffer);
|
|
47
|
-
|
|
48
|
-
const result = buffer.json();
|
|
49
|
-
buffer.destroy();
|
|
50
|
-
|
|
51
|
-
return result;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
layout(params: ShapeParams): ShapedLine[] {
|
|
55
|
-
const { textTransform, desc, fontSize, letterSpacing, width } = params;
|
|
56
|
-
const input = this.transformText(params.text, textTransform);
|
|
57
|
-
|
|
58
|
-
if (!input || input.length === 0) {
|
|
59
|
-
return [];
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Shape the text
|
|
63
|
-
const shaped = this.shapeFull(input, desc);
|
|
64
|
-
|
|
65
|
-
// Get font metrics for proper scaling
|
|
66
|
-
const face = this.fonts.getFace(desc);
|
|
67
|
-
const upem = face?.upem || 1000;
|
|
68
|
-
const scale = fontSize / upem;
|
|
69
|
-
|
|
70
|
-
// Convert shaped glyphs to our format
|
|
71
|
-
const glyphs: Glyph[] = shaped.map((g, i) => {
|
|
72
|
-
// Get the actual character from the input text using the cluster index
|
|
73
|
-
const charIndex = g.cl;
|
|
74
|
-
let char: string | undefined;
|
|
75
|
-
|
|
76
|
-
// Cluster index tells us which character in the original text this glyph represents
|
|
77
|
-
if (charIndex >= 0 && charIndex < input.length) {
|
|
78
|
-
char = input[charIndex];
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return {
|
|
82
|
-
id: g.g,
|
|
83
|
-
xAdvance: g.ax * scale + letterSpacing,
|
|
84
|
-
xOffset: g.dx * scale,
|
|
85
|
-
yOffset: -g.dy * scale,
|
|
86
|
-
cluster: g.cl,
|
|
87
|
-
char: char, // This now correctly maps to the original character
|
|
88
|
-
};
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
// Line breaking logic
|
|
92
|
-
const lines: ShapedLine[] = [];
|
|
93
|
-
let currentLine: Glyph[] = [];
|
|
94
|
-
let currentWidth = 0;
|
|
95
|
-
|
|
96
|
-
// Identify space positions
|
|
97
|
-
const spaceIndices = new Set<number>();
|
|
98
|
-
for (let i = 0; i < input.length; i++) {
|
|
99
|
-
if (input[i] === " ") {
|
|
100
|
-
spaceIndices.add(i);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
let lastBreakIndex = -1;
|
|
105
|
-
|
|
106
|
-
for (let i = 0; i < glyphs.length; i++) {
|
|
107
|
-
const glyph = glyphs[i];
|
|
108
|
-
const glyphWidth = glyph.xAdvance;
|
|
109
|
-
|
|
110
|
-
// Check for newline
|
|
111
|
-
if (glyph.char === "\n") {
|
|
112
|
-
if (currentLine.length > 0) {
|
|
113
|
-
lines.push({
|
|
114
|
-
glyphs: currentLine,
|
|
115
|
-
width: currentWidth,
|
|
116
|
-
y: 0,
|
|
117
|
-
});
|
|
118
|
-
}
|
|
119
|
-
currentLine = [];
|
|
120
|
-
currentWidth = 0;
|
|
121
|
-
lastBreakIndex = i;
|
|
122
|
-
continue;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// Check if adding this glyph would exceed width
|
|
126
|
-
if (currentWidth + glyphWidth > width && currentLine.length > 0) {
|
|
127
|
-
// Try to break at last space
|
|
128
|
-
if (lastBreakIndex > -1) {
|
|
129
|
-
// Move glyphs after last space to new line
|
|
130
|
-
const breakPoint = lastBreakIndex - (i - currentLine.length) + 1;
|
|
131
|
-
const nextLine = currentLine.splice(breakPoint);
|
|
132
|
-
|
|
133
|
-
// Recalculate current line width
|
|
134
|
-
const lineWidth = currentLine.reduce((sum, g) => sum + g.xAdvance, 0);
|
|
135
|
-
|
|
136
|
-
lines.push({
|
|
137
|
-
glyphs: currentLine,
|
|
138
|
-
width: lineWidth,
|
|
139
|
-
y: 0,
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
currentLine = nextLine;
|
|
143
|
-
currentWidth = nextLine.reduce((sum, g) => sum + g.xAdvance, 0);
|
|
144
|
-
} else {
|
|
145
|
-
// No space found, break at current position
|
|
146
|
-
lines.push({
|
|
147
|
-
glyphs: currentLine,
|
|
148
|
-
width: currentWidth,
|
|
149
|
-
y: 0,
|
|
150
|
-
});
|
|
151
|
-
currentLine = [];
|
|
152
|
-
currentWidth = 0;
|
|
153
|
-
}
|
|
154
|
-
lastBreakIndex = -1;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// Add glyph to current line
|
|
158
|
-
currentLine.push(glyph);
|
|
159
|
-
currentWidth += glyphWidth;
|
|
160
|
-
|
|
161
|
-
// Track spaces for line breaking
|
|
162
|
-
if (spaceIndices.has(glyph.cluster)) {
|
|
163
|
-
lastBreakIndex = i;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Add remaining glyphs
|
|
168
|
-
if (currentLine.length > 0) {
|
|
169
|
-
lines.push({
|
|
170
|
-
glyphs: currentLine,
|
|
171
|
-
width: currentWidth,
|
|
172
|
-
y: 0,
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// Set line Y positions
|
|
177
|
-
const lineHeight = params.lineHeight * fontSize;
|
|
178
|
-
for (let i = 0; i < lines.length; i++) {
|
|
179
|
-
lines[i].y = (i + 1) * lineHeight;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
return lines;
|
|
183
|
-
}
|
|
184
|
-
}
|
package/src/core/utils.ts
DELETED
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
// src/core/video-generator.ts
|
|
2
|
-
import type { DrawOp } from "../types";
|
|
3
|
-
import { createNodePainter } from "../painters/node";
|
|
4
|
-
import { spawn } from "child_process";
|
|
5
|
-
|
|
6
|
-
export interface VideoGenerationOptions {
|
|
7
|
-
width: number;
|
|
8
|
-
height: number;
|
|
9
|
-
fps: number;
|
|
10
|
-
duration: number;
|
|
11
|
-
outputPath: string;
|
|
12
|
-
pixelRatio?: number;
|
|
13
|
-
|
|
14
|
-
/** Optional quality controls (sane defaults provided) */
|
|
15
|
-
crf?: number; // lower = better quality (and larger file). default 17
|
|
16
|
-
preset?: "ultrafast" | "superfast" | "veryfast" | "faster" | "fast" | "medium" | "slow" | "slower" | "veryslow";
|
|
17
|
-
tune?: "film" | "animation" | "grain" | "stillimage" | "psnr" | "ssim" | "fastdecode" | "zerolatency";
|
|
18
|
-
profile?: "baseline" | "main" | "high";
|
|
19
|
-
level?: string; // e.g. "4.2"
|
|
20
|
-
pixFmt?: string; // e.g. "yuv420p", "yuv444p" (note: yuv444p less compatible)
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export class VideoGenerator {
|
|
24
|
-
private ffmpegPath: string | null = null;
|
|
25
|
-
|
|
26
|
-
async init() {
|
|
27
|
-
if (typeof window !== "undefined") {
|
|
28
|
-
throw new Error("VideoGenerator is only available in Node.js environment");
|
|
29
|
-
}
|
|
30
|
-
try {
|
|
31
|
-
const ffmpegStatic = await import("ffmpeg-static");
|
|
32
|
-
this.ffmpegPath = ffmpegStatic.default as unknown as string;
|
|
33
|
-
} catch (error) {
|
|
34
|
-
console.error("Failed to load ffmpeg-static:", error);
|
|
35
|
-
throw new Error("FFmpeg not available. Please install ffmpeg-static");
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
async generateVideo(
|
|
40
|
-
frameGenerator: (time: number) => Promise<DrawOp[]>,
|
|
41
|
-
options: VideoGenerationOptions
|
|
42
|
-
): Promise<void> {
|
|
43
|
-
if (!this.ffmpegPath) await this.init();
|
|
44
|
-
|
|
45
|
-
const {
|
|
46
|
-
width,
|
|
47
|
-
height,
|
|
48
|
-
fps,
|
|
49
|
-
duration,
|
|
50
|
-
outputPath,
|
|
51
|
-
pixelRatio = 1,
|
|
52
|
-
crf = 17,
|
|
53
|
-
preset = "slow",
|
|
54
|
-
tune = "animation",
|
|
55
|
-
profile = "high",
|
|
56
|
-
level = "4.2",
|
|
57
|
-
pixFmt = "yuv420p",
|
|
58
|
-
} = options;
|
|
59
|
-
|
|
60
|
-
// Inclusive last frame so t hits duration exactly
|
|
61
|
-
const totalFrames = Math.max(2, Math.round(duration * fps) + 1);
|
|
62
|
-
|
|
63
|
-
console.log(
|
|
64
|
-
`🎬 Generating video: ${width}x${height} @ ${fps}fps, ${duration}s (${totalFrames} frames)\n` +
|
|
65
|
-
` CRF=${crf}, preset=${preset}, tune=${tune}, profile=${profile}, level=${level}, pix_fmt=${pixFmt}`
|
|
66
|
-
);
|
|
67
|
-
|
|
68
|
-
return new Promise(async (resolve, reject) => {
|
|
69
|
-
const args = [
|
|
70
|
-
"-y",
|
|
71
|
-
// Input as image pipe
|
|
72
|
-
"-f", "image2pipe",
|
|
73
|
-
"-vcodec", "png",
|
|
74
|
-
"-framerate", String(fps), // input frame rate
|
|
75
|
-
"-i", "-",
|
|
76
|
-
// Encoding params
|
|
77
|
-
"-c:v", "libx264",
|
|
78
|
-
"-preset", preset,
|
|
79
|
-
"-crf", String(crf),
|
|
80
|
-
"-tune", tune,
|
|
81
|
-
"-profile:v", profile,
|
|
82
|
-
"-level", level,
|
|
83
|
-
"-pix_fmt", pixFmt,
|
|
84
|
-
// Set output framerate explicitly
|
|
85
|
-
"-r", String(fps),
|
|
86
|
-
"-movflags", "+faststart",
|
|
87
|
-
outputPath,
|
|
88
|
-
];
|
|
89
|
-
|
|
90
|
-
const ffmpeg = spawn(this.ffmpegPath!, args, { stdio: ["pipe", "inherit", "pipe"] });
|
|
91
|
-
|
|
92
|
-
let ffmpegError = "";
|
|
93
|
-
|
|
94
|
-
ffmpeg.stderr.on("data", (data) => {
|
|
95
|
-
const message = data.toString();
|
|
96
|
-
// Parse progress such as: time=00:00:01.50
|
|
97
|
-
const m = message.match(/time=(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?/);
|
|
98
|
-
if (m) {
|
|
99
|
-
const hh = parseInt(m[1], 10);
|
|
100
|
-
const mm = parseInt(m[2], 10);
|
|
101
|
-
const ss = parseInt(m[3], 10);
|
|
102
|
-
const ff = m[4] ? parseFloat(`0.${m[4]}`) : 0;
|
|
103
|
-
const currentTime = hh * 3600 + mm * 60 + ss + ff;
|
|
104
|
-
const pct = Math.min(100, Math.round((currentTime / duration) * 100));
|
|
105
|
-
if (pct % 10 === 0) console.log(`📹 Encoding: ${pct}%`);
|
|
106
|
-
}
|
|
107
|
-
ffmpegError += message;
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
ffmpeg.on("close", (code) => {
|
|
111
|
-
if (code === 0) {
|
|
112
|
-
console.log("✅ Video generation complete!");
|
|
113
|
-
resolve();
|
|
114
|
-
} else {
|
|
115
|
-
console.error("FFmpeg stderr:", ffmpegError);
|
|
116
|
-
reject(new Error(`FFmpeg exited with code ${code}`));
|
|
117
|
-
}
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
ffmpeg.on("error", (err) => {
|
|
121
|
-
console.error("❌ FFmpeg spawn error:", err);
|
|
122
|
-
reject(err);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
// Generate frames and pipe to ffmpeg
|
|
126
|
-
try {
|
|
127
|
-
// Reuse one canvas/painter for all frames (faster & consistent)
|
|
128
|
-
const painter = await createNodePainter({ width, height, pixelRatio });
|
|
129
|
-
|
|
130
|
-
for (let frame = 0; frame < totalFrames; frame++) {
|
|
131
|
-
// Normalized sampling so last frame hits t=duration exactly
|
|
132
|
-
const t = (frame / (totalFrames - 1)) * duration;
|
|
133
|
-
|
|
134
|
-
const ops = await frameGenerator(t);
|
|
135
|
-
await painter.render(ops);
|
|
136
|
-
const png = await painter.toPNG();
|
|
137
|
-
|
|
138
|
-
const ok = ffmpeg.stdin.write(png);
|
|
139
|
-
if (!ok) {
|
|
140
|
-
await new Promise((r) => ffmpeg.stdin.once("drain", r));
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
if (frame % Math.max(1, Math.floor(fps / 2)) === 0) {
|
|
144
|
-
const pct = Math.round(((frame + 1) / totalFrames) * 100);
|
|
145
|
-
console.log(`🎞️ Rendering frames: ${pct}%`);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
ffmpeg.stdin.end();
|
|
150
|
-
} catch (err) {
|
|
151
|
-
console.error("Frame generation error:", err);
|
|
152
|
-
ffmpeg.kill("SIGKILL");
|
|
153
|
-
reject(err);
|
|
154
|
-
}
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
}
|