@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,224 +0,0 @@
1
- import type { DrawOp, GradientSpec } from "../types";
2
- import { parseHex6 } from "../core/colors";
3
-
4
- /* eslint-disable @typescript-eslint/no-explicit-any */
5
-
6
- export function createWebPainter(canvas: HTMLCanvasElement | OffscreenCanvas) {
7
- // @ts-ignore
8
- const ctx: CanvasRenderingContext2D = (canvas as any).getContext("2d");
9
- if (!ctx) throw new Error("2D context unavailable");
10
-
11
- return {
12
- async render(ops: DrawOp[]) {
13
- const globalBox = computeGlobalTextBounds(ops);
14
-
15
- for (const op of ops) {
16
- if (op.op === "BeginFrame") {
17
- const dpr = op.pixelRatio;
18
- const w = op.width,
19
- h = op.height;
20
-
21
- if ("width" in canvas && "height" in canvas) {
22
- (canvas as any).width = Math.floor(w * dpr);
23
- (canvas as any).height = Math.floor(h * dpr);
24
- }
25
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
26
-
27
- if (op.clear) ctx.clearRect(0, 0, w, h);
28
- if (op.bg) {
29
- const { color, opacity, radius } = op.bg;
30
- if (color) {
31
- const c = parseHex6(color, opacity);
32
- ctx.fillStyle = `rgba(${c.r},${c.g},${c.b},${c.a})`;
33
- if (radius && radius > 0) {
34
- drawRoundedRect(ctx, 0, 0, w, h, radius);
35
- ctx.fill();
36
- } else {
37
- ctx.fillRect(0, 0, w, h);
38
- }
39
- }
40
- }
41
- continue;
42
- }
43
-
44
- if (op.op === "FillPath") {
45
- const p = new Path2D(op.path);
46
- ctx.save();
47
- ctx.translate(op.x, op.y);
48
-
49
- const s = (op as any).scale ?? 1;
50
- ctx.scale(s, -s);
51
-
52
- const bbox = (op as any).gradientBBox ?? globalBox;
53
- const fill = makeGradientFromBBox(ctx, op.fill, bbox);
54
- ctx.fillStyle = fill as any;
55
- ctx.fill(p);
56
- ctx.restore();
57
- continue;
58
- }
59
-
60
- if (op.op === "StrokePath") {
61
- const p = new Path2D(op.path);
62
- ctx.save();
63
- ctx.translate(op.x, op.y);
64
-
65
- const s = (op as any).scale ?? 1;
66
- ctx.scale(s, -s);
67
- const invAbs = 1 / Math.abs(s);
68
-
69
- const c = parseHex6((op as any).color, (op as any).opacity);
70
- ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},${c.a})`;
71
- ctx.lineWidth = (op as any).width * invAbs;
72
- ctx.lineJoin = "round";
73
- ctx.lineCap = "round";
74
- ctx.stroke(p);
75
- ctx.restore();
76
- continue;
77
- }
78
-
79
- if (op.op === "DecorationLine") {
80
- ctx.save();
81
- const c = parseHex6((op as any).color, (op as any).opacity);
82
- ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},${c.a})`;
83
- ctx.lineWidth = (op as any).width;
84
- ctx.beginPath();
85
- ctx.moveTo((op as any).from.x, (op as any).from.y);
86
- ctx.lineTo((op as any).to.x, (op as any).to.y);
87
- ctx.stroke();
88
- ctx.restore();
89
- continue;
90
- }
91
- }
92
- },
93
- };
94
- }
95
-
96
- function drawRoundedRect(
97
- ctx: CanvasRenderingContext2D,
98
- x: number,
99
- y: number,
100
- w: number,
101
- h: number,
102
- r: number
103
- ) {
104
- const p = new Path2D();
105
- p.moveTo(x + r, y);
106
- p.arcTo(x + w, y, x + w, y + h, r);
107
- p.arcTo(x + w, y + h, x, y + h, r);
108
- p.arcTo(x, y + h, x, y, r);
109
- p.arcTo(x, y, x + w, y, r);
110
- p.closePath();
111
- ctx.save();
112
- ctx.fill(p);
113
- ctx.restore();
114
- }
115
-
116
- function makeGradientFromBBox(
117
- ctx: CanvasRenderingContext2D,
118
- spec: GradientSpec,
119
- box: { x: number; y: number; w: number; h: number }
120
- ): CanvasGradient | string {
121
- if (spec.kind === "solid") {
122
- const c = parseHex6((spec as any).color, (spec as any).opacity);
123
- return `rgba(${c.r},${c.g},${c.b},${c.a})`;
124
- }
125
- const cx = box.x + box.w / 2,
126
- cy = box.y + box.h / 2,
127
- r = Math.max(box.w, box.h) / 2;
128
- const addStops = (g: CanvasGradient) => {
129
- const op = (spec as any).opacity ?? 1;
130
- for (const s of (spec as any).stops) {
131
- const c = parseHex6(s.color, op);
132
- g.addColorStop(s.offset, `rgba(${c.r},${c.g},${c.b},${c.a})`);
133
- }
134
- return g;
135
- };
136
- if (spec.kind === "linear") {
137
- const rad = (((spec as any).angle || 0) * Math.PI) / 180;
138
- const x1 = cx + Math.cos(rad + Math.PI) * r;
139
- const y1 = cy + Math.sin(rad + Math.PI) * r;
140
- const x2 = cx + Math.cos(rad) * r;
141
- const y2 = cy + Math.sin(rad) * r;
142
- return addStops(ctx.createLinearGradient(x1, y1, x2, y2));
143
- } else {
144
- return addStops(ctx.createRadialGradient(cx, cy, 0, cx, cy, r));
145
- }
146
- }
147
-
148
- function computeGlobalTextBounds(ops: DrawOp[]) {
149
- let minX = Infinity,
150
- minY = Infinity,
151
- maxX = -Infinity,
152
- maxY = -Infinity;
153
- for (const op of ops) {
154
- if (op.op !== "FillPath" || (op as any).isShadow) continue;
155
- const b = computePathBounds(op.path);
156
- const s = (op as any).scale ?? 1;
157
- const x1 = op.x + s * b.x;
158
- const x2 = op.x + s * (b.x + b.w);
159
- const y1 = op.y - s * (b.y + b.h);
160
- const y2 = op.y - s * b.y;
161
- if (x1 < minX) minX = x1;
162
- if (y1 < minY) minY = y1;
163
- if (x2 > maxX) maxX = x2;
164
- if (y2 > maxY) maxY = y2;
165
- }
166
- if (minX === Infinity) return { x: 0, y: 0, w: 1, h: 1 };
167
- return { x: minX, y: minY, w: Math.max(1, maxX - minX), h: Math.max(1, maxY - minY) };
168
- }
169
-
170
- function computePathBounds(d: string) {
171
- const tokens = tokenizePath(d);
172
- let i = 0;
173
- let minX = Infinity,
174
- minY = Infinity,
175
- maxX = -Infinity,
176
- maxY = -Infinity;
177
- const touch = (x: number, y: number) => {
178
- if (x < minX) minX = x;
179
- if (y < minY) minY = y;
180
- if (x > maxX) maxX = x;
181
- if (y > maxY) maxY = y;
182
- };
183
- while (i < tokens.length) {
184
- const t = tokens[i++];
185
- switch (t) {
186
- case "M":
187
- case "L": {
188
- const x = parseFloat(tokens[i++]);
189
- const y = parseFloat(tokens[i++]);
190
- touch(x, y);
191
- break;
192
- }
193
- case "C": {
194
- const c1x = parseFloat(tokens[i++]);
195
- const c1y = parseFloat(tokens[i++]);
196
- const c2x = parseFloat(tokens[i++]);
197
- const c2y = parseFloat(tokens[i++]);
198
- const x = parseFloat(tokens[i++]);
199
- const y = parseFloat(tokens[i++]);
200
- touch(c1x, c1y);
201
- touch(c2x, c2y);
202
- touch(x, y);
203
- break;
204
- }
205
- case "Q": {
206
- const cx = parseFloat(tokens[i++]);
207
- const cy = parseFloat(tokens[i++]);
208
- const x = parseFloat(tokens[i++]);
209
- const y = parseFloat(tokens[i++]);
210
- touch(cx, cy);
211
- touch(x, y);
212
- break;
213
- }
214
- case "Z":
215
- break;
216
- }
217
- }
218
- if (minX === Infinity) return { x: 0, y: 0, w: 0, h: 0 };
219
- return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
220
- }
221
-
222
- function tokenizePath(d: string): string[] {
223
- return d.match(/[MLCQZ]|-?\d*\.?\d+(?:e[-+]?\d+)?/gi) ?? [];
224
- }
@@ -1,166 +0,0 @@
1
- import Joi from "joi";
2
- import { CANVAS_CONFIG } from "../config/canvas-constants";
3
-
4
- const HEX6 = /^#[A-Fa-f0-9]{6}$/;
5
-
6
- const gradientSchema = Joi.object({
7
- type: Joi.string().valid("linear", "radial").default("linear"),
8
- angle: Joi.number().min(0).max(360).default(0),
9
- stops: Joi.array()
10
- .items(
11
- Joi.object({
12
- offset: Joi.number().min(0).max(1).required(),
13
- color: Joi.string().pattern(HEX6).required()
14
- }).unknown(false)
15
- )
16
- .min(2)
17
- .required()
18
- }).unknown(false);
19
-
20
- const shadowSchema = Joi.object({
21
- offsetX: Joi.number().default(0),
22
- offsetY: Joi.number().default(0),
23
- blur: Joi.number().min(0).default(0),
24
- color: Joi.string().pattern(HEX6).default("#000000"),
25
- opacity: Joi.number().min(0).max(1).default(0.5)
26
- }).unknown(false);
27
-
28
- const strokeSchema = Joi.object({
29
- width: Joi.number().min(0).default(0),
30
- color: Joi.string().pattern(HEX6).default("#000000"),
31
- opacity: Joi.number().min(0).max(1).default(1)
32
- }).unknown(false);
33
-
34
- const fontSchema = Joi.object({
35
- family: Joi.string().default(CANVAS_CONFIG.DEFAULTS.fontFamily),
36
- size: Joi.number()
37
- .min(CANVAS_CONFIG.LIMITS.minFontSize)
38
- .max(CANVAS_CONFIG.LIMITS.maxFontSize)
39
- .default(CANVAS_CONFIG.DEFAULTS.fontSize),
40
- weight: Joi.alternatives().try(Joi.string(), Joi.number()).default("400"),
41
- style: Joi.string().valid("normal", "italic", "oblique").default("normal"),
42
- color: Joi.string().pattern(HEX6).default(CANVAS_CONFIG.DEFAULTS.color),
43
- opacity: Joi.number().min(0).max(1).default(1)
44
- }).unknown(false);
45
-
46
- const styleSchema = Joi.object({
47
- letterSpacing: Joi.number().default(0),
48
- lineHeight: Joi.number().min(0).max(10).default(1.2),
49
- textTransform: Joi.string().valid("none", "uppercase", "lowercase", "capitalize").default("none"),
50
- textDecoration: Joi.string().valid("none", "underline", "line-through").default("none"),
51
- gradient: gradientSchema.optional()
52
- }).unknown(false);
53
-
54
- const alignmentSchema = Joi.object({
55
- horizontal: Joi.string()
56
- .valid("left", "center", "right")
57
- .default(CANVAS_CONFIG.DEFAULTS.textAlign),
58
- vertical: Joi.string().valid("top", "middle", "bottom").default("middle")
59
- }).unknown(false);
60
-
61
- const animationSchema = Joi.object({
62
- preset: Joi.string().valid(...CANVAS_CONFIG.ANIMATION_TYPES as readonly string[]),
63
- speed: Joi.number().min(0.1).max(10).default(1),
64
- duration: Joi.number()
65
- .min(CANVAS_CONFIG.LIMITS.minDuration)
66
- .max(CANVAS_CONFIG.LIMITS.maxDuration)
67
- .optional(),
68
- style: Joi.string()
69
- .valid("character", "word")
70
- .optional()
71
- .when("preset", {
72
- is: Joi.valid("typewriter", "shift"),
73
- then: Joi.optional(),
74
- otherwise: Joi.forbidden()
75
- }),
76
- direction: Joi.string()
77
- .optional()
78
- .when("preset", {
79
- switch: [
80
- { is: "ascend", then: Joi.valid("up", "down") },
81
- { is: "shift", then: Joi.valid("left", "right", "up", "down") },
82
- { is: "slideIn", then: Joi.valid("left", "right", "up", "down") },
83
- { is: "movingLetters", then: Joi.valid("left", "right", "up", "down") }
84
- ],
85
- otherwise: Joi.forbidden()
86
- })
87
- }).unknown(false);
88
-
89
- const backgroundSchema = Joi.object({
90
- color: Joi.string().pattern(HEX6).optional(),
91
- opacity: Joi.number().min(0).max(1).default(1),
92
- borderRadius: Joi.number().min(0).default(0)
93
- }).unknown(false);
94
-
95
- const customFontSchema = Joi.object({
96
- src: Joi.string().uri().required(),
97
- family: Joi.string().required(),
98
- weight: Joi.alternatives().try(Joi.string(), Joi.number()).optional(),
99
- style: Joi.string().optional(),
100
- originalFamily: Joi.string().optional()
101
- }).unknown(false);
102
-
103
- export const RichTextAssetSchema = Joi.object({
104
- type: Joi.string().valid("rich-text").required(),
105
- text: Joi.string().allow("").max(CANVAS_CONFIG.LIMITS.maxTextLength).default(""),
106
- width: Joi.number()
107
- .min(CANVAS_CONFIG.LIMITS.minWidth)
108
- .max(CANVAS_CONFIG.LIMITS.maxWidth)
109
- .default(CANVAS_CONFIG.DEFAULTS.width)
110
- .optional(),
111
- height: Joi.number()
112
- .min(CANVAS_CONFIG.LIMITS.minHeight)
113
- .max(CANVAS_CONFIG.LIMITS.maxHeight)
114
- .default(CANVAS_CONFIG.DEFAULTS.height)
115
- .optional(),
116
- font: fontSchema.optional(),
117
- style: styleSchema.optional(),
118
- stroke: strokeSchema.optional(),
119
- shadow: shadowSchema.optional(),
120
- background: backgroundSchema.optional(),
121
- align: alignmentSchema.optional(),
122
- animation: animationSchema.optional(),
123
- customFonts: Joi.array().items(customFontSchema).optional(),
124
- cacheEnabled: Joi.boolean().default(true),
125
- pixelRatio: Joi.number().min(1).max(3).default(CANVAS_CONFIG.DEFAULTS.pixelRatio)
126
- }).unknown(false);
127
-
128
- export type RichTextValidated = Required<{
129
- type: "rich-text";
130
- text: string;
131
- width?: number;
132
- height?: number;
133
- font?: {
134
- family: string;
135
- size: number;
136
- weight: string | number;
137
- style: "normal" | "italic" | "oblique";
138
- color: string;
139
- opacity: number;
140
- };
141
- style?: {
142
- letterSpacing: number;
143
- lineHeight: number;
144
- textTransform: "none" | "uppercase" | "lowercase" | "capitalize";
145
- textDecoration: "none" | "underline" | "line-through";
146
- gradient?: {
147
- type: "linear" | "radial";
148
- angle: number;
149
- stops: { offset: number; color: string }[];
150
- };
151
- };
152
- stroke?: { width: number; color: string; opacity: number };
153
- shadow?: { offsetX: number; offsetY: number; blur: number; color: string; opacity: number };
154
- background?: { color?: string; opacity: number; borderRadius: number };
155
- align?: { horizontal: "left" | "center" | "right"; vertical: "top" | "middle" | "bottom" };
156
- animation?: {
157
- preset: typeof CANVAS_CONFIG.ANIMATION_TYPES[number];
158
- speed: number;
159
- duration?: number;
160
- style?: "character" | "word";
161
- direction?: "left" | "right" | "up" | "down";
162
- };
163
- customFonts?: { src: string; family: string; weight?: string | number; style?: string; originalFamily?: string }[];
164
- cacheEnabled: boolean;
165
- pixelRatio: number;
166
- }>;
package/src/types.ts DELETED
@@ -1,36 +0,0 @@
1
- import type { RichTextValidated } from "./schema/asset-schema";
2
-
3
- export type RGBA = { r: number; g: number; b: number; a: number };
4
-
5
- export type GradientSpec =
6
- | { kind: "solid"; color: string; opacity: number }
7
- | { kind: "linear"; angle: number; stops: { offset: number; color: string }[]; opacity: number }
8
- | { kind: "radial"; stops: { offset: number; color: string }[]; opacity: number };
9
-
10
- export type Glyph = {
11
- id: number;
12
- xAdvance: number;
13
- xOffset: number;
14
- yOffset: number;
15
- cluster: number;
16
- char?: string;
17
- };
18
-
19
- export type ShapedLine = {
20
- glyphs: Glyph[];
21
- width: number;
22
- y: number; // baseline y
23
- };
24
-
25
- export type DrawOp =
26
- | { op: "BeginFrame"; width: number; height: number; pixelRatio: number; clear: boolean; bg?: { color?: string; opacity: number; radius: number } }
27
- | { op: "FillPath"; path: string; x: number; y: number; fill: GradientSpec }
28
- | { op: "StrokePath"; path: string; x: number; y: number; width: number; color: string; opacity: number }
29
- | { op: "DecorationLine"; from: { x: number; y: number }; to: { x: number; y: number }; width: number; color: string; opacity: number }
30
- ;
31
-
32
- export type EngineInit = { width: number; height: number; pixelRatio?: number; fps?: number };
33
-
34
- export type Renderer = { render(ops: DrawOp[]): Promise<void>; toPNG?: () => Promise<Buffer> };
35
-
36
- export type ValidAsset = RichTextValidated;
@@ -1,31 +0,0 @@
1
- // src/wasm/hb-loader.ts
2
- /* eslint-disable @typescript-eslint/no-explicit-any */
3
-
4
- let hbSingleton: any | null = null;
5
-
6
- export async function initHB(wasmBaseURL?: string): Promise<any> {
7
- if (hbSingleton) return hbSingleton;
8
-
9
- // Simply import harfbuzzjs and let it handle WASM loading
10
- const harfbuzzjs = await import('harfbuzzjs');
11
-
12
- // harfbuzzjs default export is a Promise that resolves to the hb object
13
- const hbPromise = harfbuzzjs.default;
14
-
15
- if (typeof hbPromise === 'function') {
16
- hbSingleton = await hbPromise();
17
- } else if (hbPromise && typeof hbPromise.then === 'function') {
18
- hbSingleton = await hbPromise;
19
- } else {
20
- hbSingleton = hbPromise;
21
- }
22
-
23
- // Validate the API
24
- if (!hbSingleton || typeof hbSingleton.createBuffer !== "function") {
25
- throw new Error("Failed to initialize HarfBuzz: invalid API");
26
- }
27
-
28
- return hbSingleton;
29
- }
30
-
31
- export type HB = any;
@@ -1,22 +0,0 @@
1
- {
2
- "compileOnSave": false,
3
- "compilerOptions": {
4
- "target": "ES2022",
5
- "lib": ["ES2022", "DOM"],
6
- "module": "ESNext",
7
- "moduleResolution": "Bundler",
8
- "verbatimModuleSyntax": true,
9
- "isolatedModules": true,
10
- "noEmit": true,
11
- "strict": true,
12
- "forceConsistentCasingInFileNames": true,
13
- "skipLibCheck": false,
14
- "allowJs": false,
15
- "declaration": true,
16
- "declarationMap": true,
17
- "sourceMap": true,
18
- "esModuleInterop": false,
19
- "resolveJsonModule": true,
20
- "types": []
21
- }
22
- }
package/tsconfig.json DELETED
@@ -1,16 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "ES2022",
5
- "moduleResolution": "Bundler",
6
- "declaration": true,
7
- "declarationMap": true,
8
- "outDir": "dist",
9
- "strict": true,
10
- "esModuleInterop": true,
11
- "skipLibCheck": true,
12
- "resolveJsonModule": true,
13
- "types": ["node"]
14
- },
15
- "include": ["src/**/*", "scripts/**/*"]
16
- }
package/tsup.config.ts DELETED
@@ -1,52 +0,0 @@
1
- import { defineConfig } from 'tsup';
2
-
3
- export default defineConfig([
4
- // Node build
5
- {
6
- name: 'node',
7
- entry: { 'entry.node': 'src/env/entry.node.ts' },
8
- format: ['esm', 'cjs'],
9
- dts: true,
10
- sourcemap: true,
11
- clean: true,
12
- platform: 'node',
13
- target: 'node18',
14
- // Mark these as external to avoid bundling issues
15
- external: [
16
- 'canvas',
17
- 'harfbuzzjs',
18
- 'ffmpeg-static',
19
- 'fluent-ffmpeg',
20
- 'child_process',
21
- 'stream',
22
- 'path',
23
- 'fs',
24
- 'node:fs',
25
- 'node:fs/promises',
26
- 'node:path',
27
- 'node:http',
28
- 'node:https',
29
- 'node:stream',
30
- 'node:child_process'
31
- ],
32
- esbuildOptions(options) {
33
- options.mainFields = ['module', 'main'];
34
- options.conditions = ['node', 'import'];
35
- },
36
- },
37
- // Web build
38
- {
39
- name: 'web',
40
- entry: { 'entry.web': 'src/env/entry.web.ts' },
41
- format: ['esm'],
42
- dts: true,
43
- sourcemap: true,
44
- platform: 'browser',
45
- target: 'es2020',
46
- external: ['harfbuzzjs'],
47
- esbuildOptions(options) {
48
- options.mainFields = ['browser', 'module', 'main'];
49
- options.conditions = ['browser', 'import'];
50
- },
51
- },
52
- ]);