@hraness/slopcamera 3.2.7 → 3.3.1
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/NOTICE.md +1 -7
- package/PRIVACY.md +16 -7
- package/README.md +65 -20
- package/apps/desktop/README.md +14 -130
- package/apps/desktop/application/context.ts +6 -9
- package/apps/desktop/application/default-registry.ts +26 -10
- package/apps/desktop/application/operation.ts +14 -5
- package/apps/desktop/application/operations/index.ts +4 -1
- package/apps/desktop/application/operations/spatial-behavior.ts +151 -0
- package/apps/desktop/application/operations/spatial-direction.ts +222 -0
- package/apps/desktop/application/operations/spatial-rendered-audit.ts +174 -0
- package/apps/desktop/application/operations/spatial-review.ts +194 -0
- package/apps/desktop/application/operations/spatial-scene.ts +25 -1
- package/apps/desktop/application/registry.ts +2 -2
- package/apps/desktop/application/spatial-asset-admission.ts +201 -0
- package/apps/desktop/application/spatial-assets.ts +160 -31
- package/apps/desktop/application/spatial-render.ts +217 -20
- package/apps/desktop/application/spatial-rendered-audit.ts +273 -0
- package/apps/desktop/application/spatial-review.ts +361 -0
- package/apps/desktop/application/spatial-spz.ts +48 -3
- package/apps/desktop/application/spatial-world-import.ts +26 -5
- package/apps/desktop/application/spatial-world-metadata.ts +105 -0
- package/apps/desktop/cli/args.ts +528 -97
- package/apps/desktop/cli/capability-manifest.ts +374 -0
- package/apps/desktop/cli/cinema-renderer.ts +608 -0
- package/apps/desktop/cli/command-host-resources.ts +31 -2
- package/apps/desktop/cli/commands.ts +1215 -70
- package/apps/desktop/cli/gateway-review-provider.ts +334 -0
- package/apps/desktop/cli/help.ts +183 -23
- package/apps/desktop/cli/html-scene.ts +139 -28
- package/apps/desktop/cli/io.ts +2 -1
- package/apps/desktop/cli/main.ts +26 -50
- package/apps/desktop/cli/menubar.ts +244 -0
- package/apps/desktop/cli/music-analysis-service.ts +61 -0
- package/apps/desktop/cli/portable-surface.ts +18 -5
- package/apps/desktop/cli/spatial-asset-service.ts +124 -0
- package/apps/desktop/cli/spatial-design-service.ts +237 -0
- package/apps/desktop/cli/spatial-generate-service.ts +370 -0
- package/apps/desktop/cli/spatial-scene-service.ts +268 -4
- package/apps/desktop/cli/workflow-code.ts +1 -1
- package/apps/desktop/code/application-node-planner.ts +0 -47
- package/apps/desktop/code/host-source-layout.ts +46 -0
- package/apps/desktop/code/public.ts +0 -1
- package/apps/desktop/code/runtime-identity.ts +50 -8
- package/apps/desktop/code/semantic-builder.ts +24 -43
- package/apps/desktop/code/source-bundle.ts +23 -6
- package/apps/desktop/code/source-typecheck.ts +131 -43
- package/apps/desktop/code/worker-client.ts +14 -2
- package/apps/desktop/contracts/cinema.ts +736 -0
- package/apps/desktop/contracts/index.ts +1 -0
- package/apps/desktop/contracts/spatial-asset.ts +16 -0
- package/apps/desktop/contracts/spatial-world.ts +26 -3
- package/apps/desktop/core/cinema-plan.ts +1320 -0
- package/apps/desktop/core/index.ts +1 -0
- package/apps/desktop/core/music-analysis.ts +124 -1
- package/apps/desktop/core/storage.ts +75 -0
- package/apps/desktop/dist/cli/main.js +737 -291
- package/apps/desktop/html-overlay/audio-reactivity.ts +127 -0
- package/apps/desktop/html-overlay/index.ts +9 -0
- package/apps/desktop/html-overlay/runtime.ts +2 -0
- package/apps/desktop/html-overlay/scene.ts +29 -0
- package/apps/desktop/html-overlay/spatial.ts +619 -57
- package/apps/desktop/workflows/cinematic-world.ts +127 -0
- package/apps/desktop/workflows/index.ts +6 -0
- package/dist/cli.js +32 -434
- package/dist/code/advanced.js +1 -269
- package/dist/code/index.js +16 -2239
- package/dist/generate.js +1 -23
- package/dist/host-resources.js +1 -25
- package/dist/index-0bbesn4m.js +35 -0
- package/dist/index-296qhpf2.js +3 -0
- package/dist/index-3cxf2c58.js +3 -0
- package/dist/index-77fjfg9f.js +3 -0
- package/dist/index-80a36bc0.js +3 -0
- package/dist/index-a3vvs3rf.js +3 -0
- package/dist/index-b3geqczn.js +5 -0
- package/dist/index-cgpkyjqa.js +3 -0
- package/dist/index-f0ptv8dm.js +7 -0
- package/dist/index-grk47tgs.js +3 -0
- package/dist/index-qry58nj2.js +10 -0
- package/dist/index.js +1 -240
- package/dist/operations.js +1 -28
- package/dist/skill-install-gx7afxsd.js +2 -0
- package/dist/vectorize/worker.js +1 -141
- package/dist/workflow.js +1 -15
- package/docs/README.md +5 -1
- package/docs/studio.md +1 -1
- package/package.json +11 -24
- package/skills/slopcamera/SKILL.md +14 -1
- package/skills/slopcamera/references/diagrams.md +5 -14
- package/skills/slopcamera/references/directed-scenes.md +22 -2
- package/skills/slopcamera/references/gateway-media.md +15 -1
- package/skills/slopcamera/references/image-galleries.md +166 -0
- package/skills/slopcamera/references/install.md +2 -2
- package/skills/slopcamera/references/music-video.md +23 -3
- package/skills/slopcamera/references/parametric-design.md +14 -0
- package/skills/slopcamera/references/reference-led-3d.md +1 -1
- package/skills/slopcamera/references/scene-building.md +32 -0
- package/skills/slopcamera/references/social-collage-banners.md +204 -0
- package/skills/slopcamera/references/support.md +25 -0
- package/skills/slopcamera/references/web-media-excerpts.md +120 -0
- package/skills/slopcamera/references/workflows-sdk.md +3 -3
- package/skills/slopcamera/scripts/compose-social-collage-banner.ts +728 -0
- package/src/capability-manifest.ts +361 -0
- package/src/cli.ts +250 -102
- package/src/code/canonical-json.ts +9 -0
- package/src/generate.ts +2 -2
- package/src/icon.ts +1111 -0
- package/src/image-gallery.ts +1049 -0
- package/src/index.ts +11 -23
- package/src/mcp/boundary.ts +11 -11
- package/src/mcp/index.ts +3 -0
- package/src/mcp/server.ts +2 -1
- package/src/mcp/tools.ts +1642 -130
- package/src/mcp/types.ts +19 -0
- package/src/operations.ts +412 -0
- package/src/portable-capability-manifest.ts +67 -0
- package/src/scene-gallery.ts +184 -0
- package/src/spatial-scene/asset-admission.ts +183 -0
- package/src/spatial-scene/audit-rendered.ts +692 -0
- package/src/spatial-scene/audit.ts +617 -0
- package/src/spatial-scene/behavior-audit.ts +252 -0
- package/src/spatial-scene/behavior-bake.ts +233 -0
- package/src/spatial-scene/behavior-fns.ts +585 -0
- package/src/spatial-scene/behavior-gallery.ts +109 -0
- package/src/spatial-scene/behavior-stdlib.ts +323 -0
- package/src/spatial-scene/behavior-trace.ts +325 -0
- package/src/spatial-scene/behavior.ts +618 -0
- package/src/spatial-scene/build.ts +632 -0
- package/src/spatial-scene/camera-rig.ts +125 -0
- package/src/spatial-scene/camera-track.ts +4 -2
- package/src/spatial-scene/character.ts +124 -0
- package/src/spatial-scene/contracts.ts +75 -7
- package/src/spatial-scene/design-templates.ts +351 -0
- package/src/spatial-scene/design.ts +327 -0
- package/src/spatial-scene/direction-compile.ts +584 -0
- package/src/spatial-scene/direction.ts +122 -0
- package/src/spatial-scene/effects.ts +149 -0
- package/src/spatial-scene/evaluate.ts +109 -29
- package/src/spatial-scene/gallery.ts +277 -0
- package/src/spatial-scene/generate.ts +239 -0
- package/src/spatial-scene/geometry-native.ts +165 -0
- package/src/spatial-scene/geometry.ts +1216 -0
- package/src/spatial-scene/gltf.ts +600 -77
- package/src/spatial-scene/identity.ts +55 -2
- package/src/spatial-scene/index.ts +36 -0
- package/src/spatial-scene/inspect.ts +35 -6
- package/src/spatial-scene/material-lighting.ts +609 -0
- package/src/spatial-scene/math.ts +21 -4
- package/src/spatial-scene/motion-evidence.ts +88 -0
- package/src/spatial-scene/parametric.ts +765 -0
- package/src/spatial-scene/particle-preparation.ts +183 -0
- package/src/spatial-scene/particle.ts +195 -0
- package/src/spatial-scene/patch.ts +109 -15
- package/src/spatial-scene/performance.ts +1613 -0
- package/src/spatial-scene/probe.ts +137 -0
- package/src/spatial-scene/recipe-pack.ts +86 -0
- package/src/spatial-scene/render-effects.ts +240 -0
- package/src/spatial-scene/review.ts +639 -0
- package/src/spatial-scene/simulation-bake.ts +206 -0
- package/src/spatial-scene/simulation.ts +187 -0
- package/src/spatial-scene/solve.ts +366 -0
- package/src/spatial-scene/temporal-audit.ts +354 -0
- package/src/support-completion.ts +16 -0
- package/src/support.ts +42 -0
- package/src/version.ts +1 -1
- package/apps/desktop/application/operations/recording/index.ts +0 -5
- package/apps/desktop/application/operations/recording/pause.ts +0 -34
- package/apps/desktop/application/operations/recording/resume.ts +0 -34
- package/apps/desktop/application/operations/recording/shared.ts +0 -199
- package/apps/desktop/application/operations/recording/start.ts +0 -169
- package/apps/desktop/application/operations/recording/stop.ts +0 -34
- package/apps/desktop/capture/protocol.ts +0 -689
- package/apps/desktop/cli/capture-bundle.ts +0 -1117
- package/apps/desktop/cli/recording-controller.ts +0 -1567
- package/apps/desktop/cli/recording-daemon.ts +0 -931
- package/dist/index-42zsesc1.js +0 -2257
- package/dist/index-7308egqr.js +0 -48
- package/dist/index-8txs6fkn.js +0 -712
- package/dist/index-mcy8z0br.js +0 -192
- package/dist/index-p63wavx0.js +0 -2029
- package/dist/index-r7gdhmsp.js +0 -424
- package/dist/index-rvghzt7b.js +0 -2013
- package/dist/index-sh6xbav6.js +0 -1219
- package/dist/index-z1w83f81.js +0 -4
- package/dist/index-z7239b4h.js +0 -2363
- package/dist/skill-install-dh0ntqba.js +0 -10
- package/src/desktop.ts +0 -253
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import sharp from "sharp";
|
|
6
|
+
|
|
7
|
+
const MAX_MANIFEST_BYTES = 256 * 1024;
|
|
8
|
+
const MAX_INPUT_BYTES = 64 * 1024 * 1024;
|
|
9
|
+
const MAX_TOTAL_INPUT_BYTES = 256 * 1024 * 1024;
|
|
10
|
+
const MAX_PIXELS = 32_000_000;
|
|
11
|
+
const MAX_LAYERS = 96;
|
|
12
|
+
const MAX_DIMENSION = 8_192;
|
|
13
|
+
const colorPattern = /^#[0-9a-f]{6}(?:[0-9a-f]{2})?$/iu;
|
|
14
|
+
|
|
15
|
+
type Point = { x: number; y: number };
|
|
16
|
+
type Shadow = { blur: number; color: string; dx: number; dy: number; opacity: number };
|
|
17
|
+
type PlainTreatment = { kind: "plain" };
|
|
18
|
+
type StickerTreatment = { kind: "sticker"; border: number; borderColor: string; shadow: Shadow };
|
|
19
|
+
type PaperTreatment = { kind: "paper"; color: string; padding: number; shadow: Shadow };
|
|
20
|
+
type ImageLayer = {
|
|
21
|
+
kind: "image";
|
|
22
|
+
path: string;
|
|
23
|
+
x: number;
|
|
24
|
+
y: number;
|
|
25
|
+
width: number;
|
|
26
|
+
height: number | undefined;
|
|
27
|
+
rotation: number;
|
|
28
|
+
trim: boolean;
|
|
29
|
+
treatment: PlainTreatment | StickerTreatment | PaperTreatment;
|
|
30
|
+
};
|
|
31
|
+
type TextLayer = {
|
|
32
|
+
kind: "text";
|
|
33
|
+
text: string;
|
|
34
|
+
x: number;
|
|
35
|
+
y: number;
|
|
36
|
+
fontSize: number;
|
|
37
|
+
fill: string;
|
|
38
|
+
stroke: string;
|
|
39
|
+
strokeWidth: number;
|
|
40
|
+
rotation: number;
|
|
41
|
+
fontFamily: "sans" | "serif" | "mono";
|
|
42
|
+
fontWeight: 400 | 500 | 600 | 700 | 800 | 900;
|
|
43
|
+
align: "start" | "middle" | "end";
|
|
44
|
+
};
|
|
45
|
+
type ArrowLayer = {
|
|
46
|
+
kind: "arrow";
|
|
47
|
+
from: Point;
|
|
48
|
+
to: Point;
|
|
49
|
+
bend: number;
|
|
50
|
+
color: string;
|
|
51
|
+
width: number;
|
|
52
|
+
};
|
|
53
|
+
type EllipseLayer = {
|
|
54
|
+
kind: "ellipse";
|
|
55
|
+
x: number;
|
|
56
|
+
y: number;
|
|
57
|
+
width: number;
|
|
58
|
+
height: number;
|
|
59
|
+
rotation: number;
|
|
60
|
+
color: string;
|
|
61
|
+
strokeWidth: number;
|
|
62
|
+
};
|
|
63
|
+
type TapeLayer = {
|
|
64
|
+
kind: "tape";
|
|
65
|
+
x: number;
|
|
66
|
+
y: number;
|
|
67
|
+
width: number;
|
|
68
|
+
height: number;
|
|
69
|
+
rotation: number;
|
|
70
|
+
color: string;
|
|
71
|
+
opacity: number;
|
|
72
|
+
};
|
|
73
|
+
type Layer = ImageLayer | TextLayer | ArrowLayer | EllipseLayer | TapeLayer;
|
|
74
|
+
type Manifest = {
|
|
75
|
+
schemaVersion: 1;
|
|
76
|
+
canvas: {
|
|
77
|
+
width: number;
|
|
78
|
+
height: number;
|
|
79
|
+
color: string;
|
|
80
|
+
background?: { path: string; position: "centre" | "north" | "south" | "east" | "west" };
|
|
81
|
+
};
|
|
82
|
+
layers: readonly Layer[];
|
|
83
|
+
effects: { chromaticShift: number; grain: number; grainSeed: number; vignette: number };
|
|
84
|
+
};
|
|
85
|
+
type InputRecord = { bytes: number; path: string; sha256: string };
|
|
86
|
+
type RetainedInput = { bytes: Buffer; record: InputRecord };
|
|
87
|
+
|
|
88
|
+
type ComposeResult = {
|
|
89
|
+
canvas: { height: number; width: number };
|
|
90
|
+
inputs: readonly InputRecord[];
|
|
91
|
+
layerCount: number;
|
|
92
|
+
manifestSha256: string;
|
|
93
|
+
output: string;
|
|
94
|
+
outputBytes: number;
|
|
95
|
+
outputSha256: string;
|
|
96
|
+
receipt: string;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
function usage(): never {
|
|
100
|
+
console.error("usage: compose-social-collage-banner.ts --manifest <path> --output <path.png>");
|
|
101
|
+
process.exit(64);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function record(value: unknown, name: string): Record<string, unknown> {
|
|
105
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError(`${name} must be an object`);
|
|
106
|
+
return value as Record<string, unknown>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function finite(value: unknown, name: string, minimum: number, maximum: number): number {
|
|
110
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) {
|
|
111
|
+
throw new RangeError(`${name} must be between ${minimum} and ${maximum}`);
|
|
112
|
+
}
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function integer(value: unknown, name: string, minimum: number, maximum: number): number {
|
|
117
|
+
const parsed = finite(value, name, minimum, maximum);
|
|
118
|
+
if (!Number.isInteger(parsed)) throw new RangeError(`${name} must be an integer`);
|
|
119
|
+
return parsed;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function string(value: unknown, name: string, maximum: number): string {
|
|
123
|
+
if (typeof value !== "string" || value.length < 1 || value.length > maximum) {
|
|
124
|
+
throw new TypeError(`${name} must be a non-empty string of at most ${maximum} characters`);
|
|
125
|
+
}
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function color(value: unknown, name: string, fallback: string): string {
|
|
130
|
+
if (value === undefined) return fallback;
|
|
131
|
+
const parsed = string(value, name, 9);
|
|
132
|
+
if (!colorPattern.test(parsed)) throw new TypeError(`${name} must be a six- or eight-digit hex color`);
|
|
133
|
+
return parsed;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function enumeration<T extends string | number>(value: unknown, name: string, choices: readonly T[], fallback: T): T {
|
|
137
|
+
if (value === undefined) return fallback;
|
|
138
|
+
if (!choices.includes(value as T)) throw new TypeError(`${name} must be one of ${choices.join(", ")}`);
|
|
139
|
+
return value as T;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function boolean(value: unknown, name: string, fallback: boolean): boolean {
|
|
143
|
+
if (value === undefined) return fallback;
|
|
144
|
+
if (typeof value !== "boolean") throw new TypeError(`${name} must be a boolean`);
|
|
145
|
+
return value;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function point(value: unknown, name: string, canvas: { width: number; height: number }): Point {
|
|
149
|
+
const parsed = record(value, name);
|
|
150
|
+
return {
|
|
151
|
+
x: finite(parsed.x, `${name}.x`, -canvas.width, canvas.width * 2),
|
|
152
|
+
y: finite(parsed.y, `${name}.y`, -canvas.height, canvas.height * 2),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function shadow(value: unknown, name: string): Shadow {
|
|
157
|
+
const parsed = value === undefined ? {} : record(value, name);
|
|
158
|
+
return {
|
|
159
|
+
blur: finite(parsed.blur ?? 14, `${name}.blur`, 0, 64),
|
|
160
|
+
color: color(parsed.color, `${name}.color`, "#000000"),
|
|
161
|
+
dx: finite(parsed.dx ?? 10, `${name}.dx`, -128, 128),
|
|
162
|
+
dy: finite(parsed.dy ?? 14, `${name}.dy`, -128, 128),
|
|
163
|
+
opacity: finite(parsed.opacity ?? 0.5, `${name}.opacity`, 0, 1),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function treatment(value: unknown, name: string): PlainTreatment | StickerTreatment | PaperTreatment {
|
|
168
|
+
if (value === undefined) return { kind: "plain" };
|
|
169
|
+
const parsed = record(value, name);
|
|
170
|
+
const kind = enumeration(parsed.kind, `${name}.kind`, ["plain", "sticker", "paper"] as const, "plain");
|
|
171
|
+
if (kind === "plain") return { kind };
|
|
172
|
+
if (kind === "sticker") {
|
|
173
|
+
return {
|
|
174
|
+
kind,
|
|
175
|
+
border: finite(parsed.border ?? 10, `${name}.border`, 0, 64),
|
|
176
|
+
borderColor: color(parsed.borderColor, `${name}.borderColor`, "#ffffff"),
|
|
177
|
+
shadow: shadow(parsed.shadow, `${name}.shadow`),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
kind,
|
|
182
|
+
color: color(parsed.color, `${name}.color`, "#faf5e8"),
|
|
183
|
+
padding: finite(parsed.padding ?? 24, `${name}.padding`, 0, 128),
|
|
184
|
+
shadow: shadow(parsed.shadow, `${name}.shadow`),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function parseLayer(value: unknown, index: number, canvas: { width: number; height: number }): Layer {
|
|
189
|
+
const name = `layers[${index}]`;
|
|
190
|
+
const parsed = record(value, name);
|
|
191
|
+
const kind = enumeration(parsed.kind, `${name}.kind`, ["image", "text", "arrow", "ellipse", "tape"] as const, "image");
|
|
192
|
+
if (kind === "image") {
|
|
193
|
+
return {
|
|
194
|
+
kind,
|
|
195
|
+
path: string(parsed.path, `${name}.path`, 4_096),
|
|
196
|
+
x: finite(parsed.x, `${name}.x`, -canvas.width, canvas.width * 2),
|
|
197
|
+
y: finite(parsed.y, `${name}.y`, -canvas.height, canvas.height * 2),
|
|
198
|
+
width: finite(parsed.width, `${name}.width`, 1, MAX_DIMENSION),
|
|
199
|
+
height: parsed.height === undefined ? undefined : finite(parsed.height, `${name}.height`, 1, MAX_DIMENSION),
|
|
200
|
+
rotation: finite(parsed.rotation ?? 0, `${name}.rotation`, -360, 360),
|
|
201
|
+
trim: boolean(parsed.trim, `${name}.trim`, true),
|
|
202
|
+
treatment: treatment(parsed.treatment, `${name}.treatment`),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
if (kind === "text") {
|
|
206
|
+
const text = string(parsed.text, `${name}.text`, 800);
|
|
207
|
+
if (text.split("\n").length > 12) throw new RangeError(`${name}.text must contain at most 12 lines`);
|
|
208
|
+
return {
|
|
209
|
+
kind,
|
|
210
|
+
text,
|
|
211
|
+
x: finite(parsed.x, `${name}.x`, -canvas.width, canvas.width * 2),
|
|
212
|
+
y: finite(parsed.y, `${name}.y`, -canvas.height, canvas.height * 2),
|
|
213
|
+
fontSize: finite(parsed.fontSize, `${name}.fontSize`, 8, 512),
|
|
214
|
+
fill: color(parsed.fill, `${name}.fill`, "#fff4d6"),
|
|
215
|
+
stroke: color(parsed.stroke, `${name}.stroke`, "#17111f"),
|
|
216
|
+
strokeWidth: finite(parsed.strokeWidth ?? 3, `${name}.strokeWidth`, 0, 32),
|
|
217
|
+
rotation: finite(parsed.rotation ?? 0, `${name}.rotation`, -360, 360),
|
|
218
|
+
fontFamily: enumeration(parsed.fontFamily, `${name}.fontFamily`, ["sans", "serif", "mono"] as const, "sans"),
|
|
219
|
+
fontWeight: enumeration(parsed.fontWeight, `${name}.fontWeight`, [400, 500, 600, 700, 800, 900] as const, 700),
|
|
220
|
+
align: enumeration(parsed.align, `${name}.align`, ["start", "middle", "end"] as const, "middle"),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
if (kind === "arrow") {
|
|
224
|
+
return {
|
|
225
|
+
kind,
|
|
226
|
+
from: point(parsed.from, `${name}.from`, canvas),
|
|
227
|
+
to: point(parsed.to, `${name}.to`, canvas),
|
|
228
|
+
bend: finite(parsed.bend ?? 0.25, `${name}.bend`, -2, 2),
|
|
229
|
+
color: color(parsed.color, `${name}.color`, "#ff5048"),
|
|
230
|
+
width: finite(parsed.width ?? 8, `${name}.width`, 1, 64),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
if (kind === "ellipse") {
|
|
234
|
+
return {
|
|
235
|
+
kind,
|
|
236
|
+
x: finite(parsed.x, `${name}.x`, -canvas.width, canvas.width * 2),
|
|
237
|
+
y: finite(parsed.y, `${name}.y`, -canvas.height, canvas.height * 2),
|
|
238
|
+
width: finite(parsed.width, `${name}.width`, 1, MAX_DIMENSION),
|
|
239
|
+
height: finite(parsed.height, `${name}.height`, 1, MAX_DIMENSION),
|
|
240
|
+
rotation: finite(parsed.rotation ?? 0, `${name}.rotation`, -360, 360),
|
|
241
|
+
color: color(parsed.color, `${name}.color`, "#ff5048"),
|
|
242
|
+
strokeWidth: finite(parsed.strokeWidth ?? 8, `${name}.strokeWidth`, 1, 64),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
kind,
|
|
247
|
+
x: finite(parsed.x, `${name}.x`, -canvas.width, canvas.width * 2),
|
|
248
|
+
y: finite(parsed.y, `${name}.y`, -canvas.height, canvas.height * 2),
|
|
249
|
+
width: finite(parsed.width, `${name}.width`, 1, MAX_DIMENSION),
|
|
250
|
+
height: finite(parsed.height, `${name}.height`, 1, MAX_DIMENSION),
|
|
251
|
+
rotation: finite(parsed.rotation ?? 0, `${name}.rotation`, -360, 360),
|
|
252
|
+
color: color(parsed.color, `${name}.color`, "#f8ebaa"),
|
|
253
|
+
opacity: finite(parsed.opacity ?? 0.78, `${name}.opacity`, 0, 1),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function parseManifest(value: unknown): Manifest {
|
|
258
|
+
const parsed = record(value, "manifest");
|
|
259
|
+
if (parsed.schemaVersion !== 1) throw new TypeError("schemaVersion must be 1");
|
|
260
|
+
const sourceCanvas = record(parsed.canvas, "canvas");
|
|
261
|
+
const width = integer(sourceCanvas.width, "canvas.width", 320, MAX_DIMENSION);
|
|
262
|
+
const height = integer(sourceCanvas.height, "canvas.height", 160, MAX_DIMENSION);
|
|
263
|
+
if (width * height > MAX_PIXELS) throw new RangeError(`canvas exceeds ${MAX_PIXELS} pixels`);
|
|
264
|
+
const canvas: Manifest["canvas"] = {
|
|
265
|
+
width,
|
|
266
|
+
height,
|
|
267
|
+
color: color(sourceCanvas.color, "canvas.color", "#100a1c"),
|
|
268
|
+
};
|
|
269
|
+
if (sourceCanvas.background !== undefined) {
|
|
270
|
+
const background = record(sourceCanvas.background, "canvas.background");
|
|
271
|
+
canvas.background = {
|
|
272
|
+
path: string(background.path, "canvas.background.path", 4_096),
|
|
273
|
+
position: enumeration(background.position, "canvas.background.position", ["centre", "north", "south", "east", "west"] as const, "centre"),
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
if (!Array.isArray(parsed.layers) || parsed.layers.length > MAX_LAYERS) {
|
|
277
|
+
throw new RangeError(`layers must be an array with at most ${MAX_LAYERS} entries`);
|
|
278
|
+
}
|
|
279
|
+
const sourceEffects = parsed.effects === undefined ? {} : record(parsed.effects, "effects");
|
|
280
|
+
return {
|
|
281
|
+
schemaVersion: 1,
|
|
282
|
+
canvas,
|
|
283
|
+
layers: parsed.layers.map((layer, index) => parseLayer(layer, index, canvas)),
|
|
284
|
+
effects: {
|
|
285
|
+
chromaticShift: integer(sourceEffects.chromaticShift ?? 0, "effects.chromaticShift", 0, 16),
|
|
286
|
+
grain: finite(sourceEffects.grain ?? 0, "effects.grain", 0, 1),
|
|
287
|
+
grainSeed: integer(sourceEffects.grainSeed ?? 7, "effects.grainSeed", 0, 0xffff_ffff),
|
|
288
|
+
vignette: finite(sourceEffects.vignette ?? 0, "effects.vignette", 0, 1),
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function digest(bytes: Uint8Array): string {
|
|
294
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function escapeXml(value: string): string {
|
|
298
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function parseHex(value: string): { alpha: number; b: number; g: number; r: number } {
|
|
302
|
+
return {
|
|
303
|
+
r: Number.parseInt(value.slice(1, 3), 16),
|
|
304
|
+
g: Number.parseInt(value.slice(3, 5), 16),
|
|
305
|
+
b: Number.parseInt(value.slice(5, 7), 16),
|
|
306
|
+
alpha: value.length === 9 ? Number.parseInt(value.slice(7, 9), 16) / 255 : 1,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function admitRaster(width: number, height: number, name: string): void {
|
|
311
|
+
if (
|
|
312
|
+
!Number.isInteger(width)
|
|
313
|
+
|| !Number.isInteger(height)
|
|
314
|
+
|| width < 1
|
|
315
|
+
|| height < 1
|
|
316
|
+
|| width > MAX_DIMENSION
|
|
317
|
+
|| height > MAX_DIMENSION
|
|
318
|
+
|| width * height > MAX_PIXELS
|
|
319
|
+
) {
|
|
320
|
+
throw new RangeError(`${name} exceeds the ${MAX_DIMENSION}px edge or ${MAX_PIXELS}-pixel raster budget`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function rotateLayer(asset: Buffer, angle: number, name: string): Promise<Buffer> {
|
|
325
|
+
if (angle === 0) return asset;
|
|
326
|
+
const metadata = await sharp(asset).metadata();
|
|
327
|
+
const width = metadata.width;
|
|
328
|
+
const height = metadata.height;
|
|
329
|
+
if (width === undefined || height === undefined) throw new TypeError(`${name} has no dimensions`);
|
|
330
|
+
const radians = Math.abs(angle % 180) * Math.PI / 180;
|
|
331
|
+
const rotatedWidth = Math.ceil(Math.abs(width * Math.cos(radians)) + Math.abs(height * Math.sin(radians)));
|
|
332
|
+
const rotatedHeight = Math.ceil(Math.abs(width * Math.sin(radians)) + Math.abs(height * Math.cos(radians)));
|
|
333
|
+
admitRaster(rotatedWidth, rotatedHeight, `${name} rotation`);
|
|
334
|
+
return sharp(asset).rotate(angle, { background: "#00000000" }).png().toBuffer();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function resolveInput(path: string, manifestDirectory: string): string {
|
|
338
|
+
if (/^[a-z][a-z0-9+.-]*:/iu.test(path)) throw new TypeError("collage inputs must be local files, not URLs");
|
|
339
|
+
return resolve(isAbsolute(path) ? path : join(manifestDirectory, path));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function readBounded(path: string): Promise<Buffer> {
|
|
343
|
+
const details = await stat(path);
|
|
344
|
+
if (!details.isFile() || details.size < 1 || details.size > MAX_INPUT_BYTES) {
|
|
345
|
+
throw new RangeError(`input must be a physical file from 1 to ${MAX_INPUT_BYTES} bytes: ${path}`);
|
|
346
|
+
}
|
|
347
|
+
const bytes = await readFile(path);
|
|
348
|
+
if (bytes.byteLength < 1 || bytes.byteLength > MAX_INPUT_BYTES) {
|
|
349
|
+
throw new RangeError(`input changed outside the admitted byte range while reading: ${path}`);
|
|
350
|
+
}
|
|
351
|
+
return bytes;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function retainInput(path: string, inputs: Map<string, RetainedInput>): Promise<Buffer> {
|
|
355
|
+
const retained = inputs.get(path);
|
|
356
|
+
if (retained !== undefined) return retained.bytes;
|
|
357
|
+
const bytes = await readBounded(path);
|
|
358
|
+
const total = [...inputs.values()].reduce((sum, input) => sum + input.bytes.byteLength, bytes.byteLength);
|
|
359
|
+
if (total > MAX_TOTAL_INPUT_BYTES) throw new RangeError(`aggregate inputs exceed ${MAX_TOTAL_INPUT_BYTES} bytes`);
|
|
360
|
+
inputs.set(path, { bytes, record: { bytes: bytes.byteLength, path, sha256: digest(bytes) } });
|
|
361
|
+
return bytes;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function validateRaster(bytes: Buffer, path: string): Promise<{ height: number; width: number }> {
|
|
365
|
+
const metadata = await sharp(bytes, { failOn: "warning", limitInputPixels: MAX_PIXELS }).metadata();
|
|
366
|
+
if (
|
|
367
|
+
!(["png", "jpeg", "webp"] as const).includes(metadata.format as "png" | "jpeg" | "webp")
|
|
368
|
+
|| (metadata.pages !== undefined && metadata.pages !== 1)
|
|
369
|
+
|| metadata.width === undefined
|
|
370
|
+
|| metadata.height === undefined
|
|
371
|
+
|| metadata.width * metadata.height > MAX_PIXELS
|
|
372
|
+
) {
|
|
373
|
+
throw new TypeError(`image input must be one bounded PNG, JPEG, or WebP frame: ${path}`);
|
|
374
|
+
}
|
|
375
|
+
const rotated = metadata.orientation !== undefined && metadata.orientation >= 5 && metadata.orientation <= 8;
|
|
376
|
+
return rotated ? { height: metadata.width, width: metadata.height } : { height: metadata.height, width: metadata.width };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function rgbaWithMask(width: number, height: number, fill: string, mask: Buffer, opacity = 1): Promise<Buffer> {
|
|
380
|
+
const parsed = parseHex(fill);
|
|
381
|
+
const combinedOpacity = opacity * parsed.alpha;
|
|
382
|
+
const alpha = combinedOpacity === 1 ? mask : await sharp(mask).linear(combinedOpacity).png().toBuffer();
|
|
383
|
+
return sharp({ create: { width, height, channels: 3, background: parsed } }).joinChannel(alpha).png().toBuffer();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function addShadow(asset: Buffer, treatmentShadow: Shadow, inset: number): Promise<Buffer> {
|
|
387
|
+
const metadata = await sharp(asset).metadata();
|
|
388
|
+
const width = metadata.width;
|
|
389
|
+
const height = metadata.height;
|
|
390
|
+
if (width === undefined || height === undefined) throw new TypeError("layer has no dimensions");
|
|
391
|
+
const blurPad = Math.ceil(treatmentShadow.blur * 2);
|
|
392
|
+
const offsetPad = Math.ceil(Math.max(Math.abs(treatmentShadow.dx), Math.abs(treatmentShadow.dy)));
|
|
393
|
+
const pad = inset + blurPad + offsetPad;
|
|
394
|
+
admitRaster(width + blurPad * 2, height + blurPad * 2, "shadow mask");
|
|
395
|
+
admitRaster(width + pad * 2, height + pad * 2, "shadow layer");
|
|
396
|
+
let alphaPipeline = sharp(asset).extractChannel("alpha");
|
|
397
|
+
if (blurPad > 0) {
|
|
398
|
+
alphaPipeline = alphaPipeline.extend({
|
|
399
|
+
top: blurPad,
|
|
400
|
+
bottom: blurPad,
|
|
401
|
+
left: blurPad,
|
|
402
|
+
right: blurPad,
|
|
403
|
+
background: "#000000",
|
|
404
|
+
}).blur(treatmentShadow.blur);
|
|
405
|
+
}
|
|
406
|
+
const alpha = await alphaPipeline.png().toBuffer();
|
|
407
|
+
const shadowImage = await rgbaWithMask(width + blurPad * 2, height + blurPad * 2, treatmentShadow.color, alpha, treatmentShadow.opacity);
|
|
408
|
+
return sharp({
|
|
409
|
+
create: { width: width + pad * 2, height: height + pad * 2, channels: 4, background: "#00000000" },
|
|
410
|
+
}).composite([
|
|
411
|
+
{ input: shadowImage, left: Math.round(pad - blurPad + treatmentShadow.dx), top: Math.round(pad - blurPad + treatmentShadow.dy) },
|
|
412
|
+
{ input: asset, left: pad, top: pad },
|
|
413
|
+
]).png().toBuffer();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function dilateAlpha(asset: Buffer, radius: number): Promise<Buffer> {
|
|
417
|
+
const { data, info } = await sharp(asset).extractChannel("alpha").raw().toBuffer({ resolveWithObject: true });
|
|
418
|
+
if (info.channels !== 1) throw new TypeError("sticker alpha must decode to one channel");
|
|
419
|
+
const width = info.width;
|
|
420
|
+
const height = info.height;
|
|
421
|
+
const distance = Math.max(0, Math.round(radius));
|
|
422
|
+
if (distance === 0) return sharp(data, { raw: { width, height, channels: 1 } }).png().toBuffer();
|
|
423
|
+
const horizontal = Buffer.allocUnsafe(width * height);
|
|
424
|
+
const rowQueue = new Int32Array(width);
|
|
425
|
+
for (let y = 0; y < height; y += 1) {
|
|
426
|
+
let head = 0;
|
|
427
|
+
let tail = 0;
|
|
428
|
+
let right = -1;
|
|
429
|
+
for (let x = 0; x < width; x += 1) {
|
|
430
|
+
const desiredRight = Math.min(width - 1, x + distance);
|
|
431
|
+
while (right < desiredRight) {
|
|
432
|
+
right += 1;
|
|
433
|
+
const value = data[y * width + right] ?? 0;
|
|
434
|
+
while (tail > head && (data[y * width + (rowQueue[tail - 1] ?? 0)] ?? 0) <= value) tail -= 1;
|
|
435
|
+
rowQueue[tail] = right;
|
|
436
|
+
tail += 1;
|
|
437
|
+
}
|
|
438
|
+
const left = x - distance;
|
|
439
|
+
while (tail > head && (rowQueue[head] ?? 0) < left) head += 1;
|
|
440
|
+
horizontal[y * width + x] = data[y * width + (rowQueue[head] ?? 0)] ?? 0;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const output = Buffer.allocUnsafe(width * height);
|
|
444
|
+
const columnQueue = new Int32Array(height);
|
|
445
|
+
for (let x = 0; x < width; x += 1) {
|
|
446
|
+
let head = 0;
|
|
447
|
+
let tail = 0;
|
|
448
|
+
let bottom = -1;
|
|
449
|
+
for (let y = 0; y < height; y += 1) {
|
|
450
|
+
const desiredBottom = Math.min(height - 1, y + distance);
|
|
451
|
+
while (bottom < desiredBottom) {
|
|
452
|
+
bottom += 1;
|
|
453
|
+
const value = horizontal[bottom * width + x] ?? 0;
|
|
454
|
+
while (tail > head && (horizontal[(columnQueue[tail - 1] ?? 0) * width + x] ?? 0) <= value) tail -= 1;
|
|
455
|
+
columnQueue[tail] = bottom;
|
|
456
|
+
tail += 1;
|
|
457
|
+
}
|
|
458
|
+
const top = y - distance;
|
|
459
|
+
while (tail > head && (columnQueue[head] ?? 0) < top) head += 1;
|
|
460
|
+
output[y * width + x] = horizontal[(columnQueue[head] ?? 0) * width + x] ?? 0;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return sharp(output, { raw: { width, height, channels: 1 } }).png().toBuffer();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function applySticker(asset: Buffer, layer: StickerTreatment): Promise<Buffer> {
|
|
467
|
+
const metadata = await sharp(asset).metadata();
|
|
468
|
+
const originalWidth = metadata.width;
|
|
469
|
+
const originalHeight = metadata.height;
|
|
470
|
+
if (originalWidth === undefined || originalHeight === undefined) throw new TypeError("sticker has no dimensions");
|
|
471
|
+
const edge = Math.ceil(layer.border);
|
|
472
|
+
const expansion = edge === 0 ? 0 : (edge + 2) * 2;
|
|
473
|
+
admitRaster(originalWidth + expansion, originalHeight + expansion, "sticker border");
|
|
474
|
+
const source = edge === 0 ? asset : await sharp(asset).extend({
|
|
475
|
+
top: edge + 2,
|
|
476
|
+
bottom: edge + 2,
|
|
477
|
+
left: edge + 2,
|
|
478
|
+
right: edge + 2,
|
|
479
|
+
background: "#00000000",
|
|
480
|
+
}).png().toBuffer();
|
|
481
|
+
const sourceMetadata = await sharp(source).metadata();
|
|
482
|
+
const width = sourceMetadata.width;
|
|
483
|
+
const height = sourceMetadata.height;
|
|
484
|
+
if (width === undefined || height === undefined) throw new TypeError("sticker has no expanded dimensions");
|
|
485
|
+
const mask = await dilateAlpha(source, edge);
|
|
486
|
+
const border = await rgbaWithMask(width, height, layer.borderColor, mask);
|
|
487
|
+
const combined = await sharp({ create: { width, height, channels: 4, background: "#00000000" } })
|
|
488
|
+
.composite([{ input: border, left: 0, top: 0 }, { input: source, left: 0, top: 0 }])
|
|
489
|
+
.png()
|
|
490
|
+
.toBuffer();
|
|
491
|
+
return addShadow(combined, layer.shadow, 0);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function applyPaper(asset: Buffer, layer: PaperTreatment): Promise<Buffer> {
|
|
495
|
+
const metadata = await sharp(asset).metadata();
|
|
496
|
+
const width = metadata.width;
|
|
497
|
+
const height = metadata.height;
|
|
498
|
+
if (width === undefined || height === undefined) throw new TypeError("paper layer has no dimensions");
|
|
499
|
+
const padding = Math.round(layer.padding);
|
|
500
|
+
admitRaster(width + padding * 2, height + padding * 2, "paper layer");
|
|
501
|
+
const paper = await sharp({
|
|
502
|
+
create: { width: width + padding * 2, height: height + padding * 2, channels: 4, background: parseHex(layer.color) },
|
|
503
|
+
}).composite([{ input: asset, left: padding, top: padding }]).png().toBuffer();
|
|
504
|
+
return addShadow(paper, layer.shadow, 0);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function prepareImageLayer(layer: ImageLayer, manifestDirectory: string, inputs: Map<string, RetainedInput>): Promise<Buffer> {
|
|
508
|
+
const path = resolveInput(layer.path, manifestDirectory);
|
|
509
|
+
const bytes = await retainInput(path, inputs);
|
|
510
|
+
await validateRaster(bytes, path);
|
|
511
|
+
let normalized = sharp(bytes, { failOn: "warning", limitInputPixels: MAX_PIXELS }).rotate().ensureAlpha();
|
|
512
|
+
if (layer.trim) normalized = normalized.trim({ background: "#00000000", threshold: 2 });
|
|
513
|
+
const normalizedBytes = await normalized.png().toBuffer();
|
|
514
|
+
const normalizedMetadata = await sharp(normalizedBytes).metadata();
|
|
515
|
+
const normalizedWidth = normalizedMetadata.width;
|
|
516
|
+
const normalizedHeight = normalizedMetadata.height;
|
|
517
|
+
if (normalizedWidth === undefined || normalizedHeight === undefined) throw new TypeError(`image layer has no dimensions: ${path}`);
|
|
518
|
+
const targetWidth = Math.round(layer.width);
|
|
519
|
+
const targetHeight = layer.height === undefined
|
|
520
|
+
? Math.max(1, Math.round(normalizedHeight * targetWidth / normalizedWidth))
|
|
521
|
+
: Math.round(layer.height);
|
|
522
|
+
admitRaster(targetWidth, targetHeight, "image layer");
|
|
523
|
+
const asset = await sharp(normalizedBytes).resize(targetWidth, layer.height === undefined ? undefined : targetHeight, {
|
|
524
|
+
fit: layer.height === undefined ? "inside" : "contain",
|
|
525
|
+
background: "#00000000",
|
|
526
|
+
withoutEnlargement: false,
|
|
527
|
+
}).png().toBuffer();
|
|
528
|
+
const treated = layer.treatment.kind === "sticker"
|
|
529
|
+
? await applySticker(asset, layer.treatment)
|
|
530
|
+
: layer.treatment.kind === "paper"
|
|
531
|
+
? await applyPaper(asset, layer.treatment)
|
|
532
|
+
: asset;
|
|
533
|
+
return rotateLayer(treated, layer.rotation, "image layer");
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function prepareTextLayer(layer: TextLayer): Promise<Buffer> {
|
|
537
|
+
const lines = layer.text.split("\n");
|
|
538
|
+
const longest = Math.max(...lines.map((line) => Array.from(line).length));
|
|
539
|
+
const width = Math.max(32, Math.ceil(longest * layer.fontSize * 0.78 + layer.strokeWidth * 6));
|
|
540
|
+
const lineHeight = layer.fontSize * 1.15;
|
|
541
|
+
const height = Math.max(32, Math.ceil(lines.length * lineHeight + layer.strokeWidth * 6));
|
|
542
|
+
admitRaster(width, height, "text layer");
|
|
543
|
+
const family = layer.fontFamily === "mono" ? "monospace" : layer.fontFamily === "serif" ? "serif" : "sans-serif";
|
|
544
|
+
const anchor = layer.align;
|
|
545
|
+
const x = anchor === "start" ? layer.strokeWidth * 3 : anchor === "end" ? width - layer.strokeWidth * 3 : width / 2;
|
|
546
|
+
const tspans = lines.map((line, index) => `<tspan x="${x}" y="${layer.strokeWidth * 3 + layer.fontSize + index * lineHeight}">${escapeXml(line)}</tspan>`).join("");
|
|
547
|
+
const svg = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><text text-anchor="${anchor}" font-family="${family}" font-size="${layer.fontSize}" font-weight="${layer.fontWeight}" fill="${layer.fill}" stroke="${layer.stroke}" stroke-width="${layer.strokeWidth}" stroke-linejoin="round" paint-order="stroke">${tspans}</text></svg>`);
|
|
548
|
+
const rendered = await sharp(svg).png().toBuffer();
|
|
549
|
+
return rotateLayer(rendered, layer.rotation, "text layer");
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function prepareTapeLayer(layer: TapeLayer): Promise<Buffer> {
|
|
553
|
+
const width = Math.round(layer.width);
|
|
554
|
+
const height = Math.round(layer.height);
|
|
555
|
+
admitRaster(width, height, "tape layer");
|
|
556
|
+
const fill = parseHex(layer.color);
|
|
557
|
+
const lines = Array.from({ length: Math.min(96, Math.ceil(width / 5)) }, (_, index) => {
|
|
558
|
+
const x = (index * 37) % width;
|
|
559
|
+
return `<line x1="${x}" y1="0" x2="${Math.max(0, Math.min(width, x + (index % 5) - 2))}" y2="${height}" stroke="#ffffff" stroke-opacity="0.14"/>`;
|
|
560
|
+
}).join("");
|
|
561
|
+
const svg = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect width="100%" height="100%" rx="2" fill="rgb(${fill.r},${fill.g},${fill.b})" fill-opacity="${layer.opacity * fill.alpha}"/>${lines}</svg>`);
|
|
562
|
+
const rendered = await sharp(svg).png().toBuffer();
|
|
563
|
+
return rotateLayer(rendered, layer.rotation, "tape layer");
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function geometrySvg(layer: ArrowLayer | EllipseLayer, canvas: Manifest["canvas"]): Buffer {
|
|
567
|
+
if (layer.kind === "ellipse") {
|
|
568
|
+
const transform = `rotate(${layer.rotation} ${layer.x} ${layer.y})`;
|
|
569
|
+
return Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${canvas.width}" height="${canvas.height}"><ellipse cx="${layer.x}" cy="${layer.y}" rx="${layer.width / 2}" ry="${layer.height / 2}" fill="none" stroke="${layer.color}" stroke-width="${layer.strokeWidth}" stroke-linecap="round" transform="${transform}"/></svg>`);
|
|
570
|
+
}
|
|
571
|
+
const dx = layer.to.x - layer.from.x;
|
|
572
|
+
const dy = layer.to.y - layer.from.y;
|
|
573
|
+
const controlX = (layer.from.x + layer.to.x) / 2 + dy * layer.bend;
|
|
574
|
+
const controlY = (layer.from.y + layer.to.y) / 2 - dx * layer.bend;
|
|
575
|
+
const angle = Math.atan2(layer.to.y - controlY, layer.to.x - controlX);
|
|
576
|
+
const head = Math.max(18, layer.width * 3);
|
|
577
|
+
const leftX = layer.to.x + Math.cos(angle + 2.55) * head;
|
|
578
|
+
const leftY = layer.to.y + Math.sin(angle + 2.55) * head;
|
|
579
|
+
const rightX = layer.to.x + Math.cos(angle - 2.55) * head;
|
|
580
|
+
const rightY = layer.to.y + Math.sin(angle - 2.55) * head;
|
|
581
|
+
return Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${canvas.width}" height="${canvas.height}"><g fill="none" stroke="${layer.color}" stroke-width="${layer.width}" stroke-linecap="round" stroke-linejoin="round"><path d="M ${layer.from.x} ${layer.from.y} Q ${controlX} ${controlY} ${layer.to.x} ${layer.to.y}"/><path d="M ${leftX} ${leftY} L ${layer.to.x} ${layer.to.y} L ${rightX} ${rightY}"/></g></svg>`);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function place(canvas: Buffer, layer: Buffer, x: number, y: number, width: number, height: number): Promise<Buffer> {
|
|
585
|
+
const metadata = await sharp(layer).metadata();
|
|
586
|
+
const layerWidth = metadata.width;
|
|
587
|
+
const layerHeight = metadata.height;
|
|
588
|
+
if (layerWidth === undefined || layerHeight === undefined) throw new TypeError("layer has no dimensions");
|
|
589
|
+
const left = Math.round(x - layerWidth / 2);
|
|
590
|
+
const top = Math.round(y - layerHeight / 2);
|
|
591
|
+
const sourceLeft = Math.max(0, -left);
|
|
592
|
+
const sourceTop = Math.max(0, -top);
|
|
593
|
+
const targetLeft = Math.max(0, left);
|
|
594
|
+
const targetTop = Math.max(0, top);
|
|
595
|
+
const clippedWidth = Math.min(layerWidth - sourceLeft, width - targetLeft);
|
|
596
|
+
const clippedHeight = Math.min(layerHeight - sourceTop, height - targetTop);
|
|
597
|
+
if (clippedWidth <= 0 || clippedHeight <= 0) return canvas;
|
|
598
|
+
const clipped = sourceLeft === 0 && sourceTop === 0 && clippedWidth === layerWidth && clippedHeight === layerHeight
|
|
599
|
+
? layer
|
|
600
|
+
: await sharp(layer).extract({ left: sourceLeft, top: sourceTop, width: clippedWidth, height: clippedHeight }).png().toBuffer();
|
|
601
|
+
return sharp(canvas).composite([{ input: clipped, left: targetLeft, top: targetTop }]).png().toBuffer();
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function random(seed: number): () => number {
|
|
605
|
+
let state = seed >>> 0 || 0x9e37_79b9;
|
|
606
|
+
return () => {
|
|
607
|
+
state ^= state << 13;
|
|
608
|
+
state ^= state >>> 17;
|
|
609
|
+
state ^= state << 5;
|
|
610
|
+
return (state >>> 0) / 0x1_0000_0000;
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function applyEffects(source: Buffer, manifest: Manifest): Promise<Buffer> {
|
|
615
|
+
let image = source;
|
|
616
|
+
if (manifest.effects.vignette > 0) {
|
|
617
|
+
const alpha = Math.round(manifest.effects.vignette * 220);
|
|
618
|
+
const svg = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${manifest.canvas.width}" height="${manifest.canvas.height}"><defs><radialGradient id="v"><stop offset="45%" stop-color="#000000" stop-opacity="0"/><stop offset="100%" stop-color="#080510" stop-opacity="${alpha / 255}"/></radialGradient></defs><rect width="100%" height="100%" fill="url(#v)"/></svg>`);
|
|
619
|
+
image = await sharp(image).composite([{ input: svg, left: 0, top: 0 }]).png().toBuffer();
|
|
620
|
+
}
|
|
621
|
+
if (manifest.effects.grain > 0) {
|
|
622
|
+
const next = random(manifest.effects.grainSeed);
|
|
623
|
+
const count = manifest.canvas.width * manifest.canvas.height;
|
|
624
|
+
const noise = Buffer.allocUnsafe(count * 4);
|
|
625
|
+
for (let index = 0; index < count; index += 1) {
|
|
626
|
+
const value = Math.round(next() * 255);
|
|
627
|
+
noise[index * 4] = value;
|
|
628
|
+
noise[index * 4 + 1] = value;
|
|
629
|
+
noise[index * 4 + 2] = value;
|
|
630
|
+
noise[index * 4 + 3] = Math.round(manifest.effects.grain * 42);
|
|
631
|
+
}
|
|
632
|
+
const overlay = await sharp(noise, { raw: { width: manifest.canvas.width, height: manifest.canvas.height, channels: 4 } }).png().toBuffer();
|
|
633
|
+
image = await sharp(image).composite([{ input: overlay, left: 0, top: 0, blend: "overlay" }]).png().toBuffer();
|
|
634
|
+
}
|
|
635
|
+
if (manifest.effects.chromaticShift > 0) {
|
|
636
|
+
const { data, info } = await sharp(image).removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
637
|
+
const shifted = Buffer.allocUnsafe(data.length);
|
|
638
|
+
const channels = info.channels;
|
|
639
|
+
const shift = manifest.effects.chromaticShift;
|
|
640
|
+
for (let y = 0; y < info.height; y += 1) {
|
|
641
|
+
for (let x = 0; x < info.width; x += 1) {
|
|
642
|
+
const target = (y * info.width + x) * channels;
|
|
643
|
+
const red = (y * info.width + Math.min(info.width - 1, x + shift)) * channels;
|
|
644
|
+
const blue = (y * info.width + Math.max(0, x - shift)) * channels;
|
|
645
|
+
shifted[target] = data[red] ?? 0;
|
|
646
|
+
shifted[target + 1] = data[target + 1] ?? 0;
|
|
647
|
+
shifted[target + 2] = data[blue + 2] ?? 0;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
image = await sharp(shifted, { raw: { width: info.width, height: info.height, channels: 3 } }).png().toBuffer();
|
|
651
|
+
}
|
|
652
|
+
return image;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export async function composeSocialCollageBanner(manifestPath: string, outputPath: string): Promise<ComposeResult> {
|
|
656
|
+
const resolvedManifest = resolve(manifestPath);
|
|
657
|
+
const resolvedOutput = resolve(outputPath);
|
|
658
|
+
if (extname(resolvedOutput).toLowerCase() !== ".png") throw new TypeError("output must use the .png extension");
|
|
659
|
+
const manifestBytes = await readBounded(resolvedManifest);
|
|
660
|
+
if (manifestBytes.byteLength > MAX_MANIFEST_BYTES) throw new RangeError(`manifest exceeds ${MAX_MANIFEST_BYTES} bytes`);
|
|
661
|
+
const manifest = parseManifest(JSON.parse(manifestBytes.toString("utf8")) as unknown);
|
|
662
|
+
const receiptPath = resolvedOutput.replace(/\.png$/iu, ".receipt.json");
|
|
663
|
+
for (const path of [resolvedOutput, receiptPath]) {
|
|
664
|
+
try {
|
|
665
|
+
await stat(path);
|
|
666
|
+
throw new Error(`refusing to replace existing output: ${path}`);
|
|
667
|
+
} catch (error) {
|
|
668
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
const inputs = new Map<string, RetainedInput>();
|
|
672
|
+
let canvas: Buffer;
|
|
673
|
+
if (manifest.canvas.background === undefined) {
|
|
674
|
+
canvas = await sharp({
|
|
675
|
+
create: { width: manifest.canvas.width, height: manifest.canvas.height, channels: 4, background: parseHex(manifest.canvas.color) },
|
|
676
|
+
}).png().toBuffer();
|
|
677
|
+
} else {
|
|
678
|
+
const path = resolveInput(manifest.canvas.background.path, dirname(resolvedManifest));
|
|
679
|
+
const bytes = await retainInput(path, inputs);
|
|
680
|
+
await validateRaster(bytes, path);
|
|
681
|
+
canvas = await sharp(bytes, { failOn: "warning", limitInputPixels: MAX_PIXELS }).rotate().resize(manifest.canvas.width, manifest.canvas.height, {
|
|
682
|
+
fit: "cover",
|
|
683
|
+
position: manifest.canvas.background.position,
|
|
684
|
+
}).ensureAlpha().png().toBuffer();
|
|
685
|
+
}
|
|
686
|
+
for (const layer of manifest.layers) {
|
|
687
|
+
if (layer.kind === "image") {
|
|
688
|
+
const prepared = await prepareImageLayer(layer, dirname(resolvedManifest), inputs);
|
|
689
|
+
canvas = await place(canvas, prepared, layer.x, layer.y, manifest.canvas.width, manifest.canvas.height);
|
|
690
|
+
} else if (layer.kind === "text") {
|
|
691
|
+
const prepared = await prepareTextLayer(layer);
|
|
692
|
+
canvas = await place(canvas, prepared, layer.x, layer.y, manifest.canvas.width, manifest.canvas.height);
|
|
693
|
+
} else if (layer.kind === "tape") {
|
|
694
|
+
const prepared = await prepareTapeLayer(layer);
|
|
695
|
+
canvas = await place(canvas, prepared, layer.x, layer.y, manifest.canvas.width, manifest.canvas.height);
|
|
696
|
+
} else {
|
|
697
|
+
canvas = await sharp(canvas).composite([{ input: geometrySvg(layer, manifest.canvas), left: 0, top: 0 }]).png().toBuffer();
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
const inputRecords = [...inputs.values()].map((input) => input.record).sort((left, right) => left.path.localeCompare(right.path));
|
|
701
|
+
const output = await applyEffects(canvas, manifest);
|
|
702
|
+
const result: ComposeResult = {
|
|
703
|
+
canvas: { height: manifest.canvas.height, width: manifest.canvas.width },
|
|
704
|
+
inputs: inputRecords,
|
|
705
|
+
layerCount: manifest.layers.length,
|
|
706
|
+
manifestSha256: digest(manifestBytes),
|
|
707
|
+
output: resolvedOutput,
|
|
708
|
+
outputBytes: output.byteLength,
|
|
709
|
+
outputSha256: digest(output),
|
|
710
|
+
receipt: receiptPath,
|
|
711
|
+
};
|
|
712
|
+
await mkdir(dirname(resolvedOutput), { recursive: true });
|
|
713
|
+
await writeFile(resolvedOutput, output, { flag: "wx" });
|
|
714
|
+
await writeFile(receiptPath, `${JSON.stringify({ kind: "slopcamera.social-collage-receipt", schemaVersion: 1, ...result }, null, 2)}\n`, { flag: "wx" });
|
|
715
|
+
return result;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (import.meta.main) {
|
|
719
|
+
const argv = process.argv.slice(2);
|
|
720
|
+
const value = (name: string): string | undefined => {
|
|
721
|
+
const index = argv.indexOf(name);
|
|
722
|
+
return index < 0 ? undefined : argv[index + 1];
|
|
723
|
+
};
|
|
724
|
+
const manifest = value("--manifest");
|
|
725
|
+
const output = value("--output");
|
|
726
|
+
if (manifest === undefined || output === undefined) usage();
|
|
727
|
+
console.log(JSON.stringify(await composeSocialCollageBanner(manifest, output)));
|
|
728
|
+
}
|