@shotstack/shotstack-canvas 1.0.2 → 1.0.3

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.
@@ -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
- }
@@ -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
- }
@@ -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,3 +0,0 @@
1
- export function clamp(n: number, a: number, b: number) {
2
- return Math.max(a, Math.min(b, n));
3
- }
@@ -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
- }