@freshcoat-js/for-print 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/measure.js ADDED
@@ -0,0 +1,274 @@
1
+ // Reading a printed chart back out of a photograph.
2
+ //
3
+ // What this produces is CAMERA-SPACE, normalized to the bare card in the same
4
+ // shot — not colorimetry. That is deliberate and it is enough for the questions a
5
+ // card printer actually raises: is there a cast, where does a ramp stop
6
+ // responding, which hues come back turned. Each of those is a comparison between
7
+ // patches in one photograph, so the capture device's own response cancels and no
8
+ // reference target is needed. Absolute color would need one; these do not.
9
+ //
10
+ // The bare-stock patches are what make it work. On a dye-sub ribbon white means
11
+ // "lay down no dye", so those patches are the unprinted card, photographed under
12
+ // exactly the lighting everything else was. Dividing by them cancels the camera's
13
+ // white balance and the light's own color at once.
14
+ // Solve Ax = b by Gaussian elimination with partial pivoting. Small and dense —
15
+ // the system here is 8×8.
16
+ function solve(a, b) {
17
+ const n = b.length;
18
+ const m = a.map((row, i) => [...row, b[i]]);
19
+ for (let col = 0; col < n; col++) {
20
+ let pivot = col;
21
+ for (let r = col + 1; r < n; r++) {
22
+ if (Math.abs(m[r][col]) > Math.abs(m[pivot][col]))
23
+ pivot = r;
24
+ }
25
+ if (Math.abs(m[pivot][col]) < 1e-12)
26
+ return null; // degenerate
27
+ [m[col], m[pivot]] = [m[pivot], m[col]];
28
+ for (let r = 0; r < n; r++) {
29
+ if (r === col)
30
+ continue;
31
+ const f = m[r][col] / m[col][col];
32
+ for (let c = col; c <= n; c++)
33
+ m[r][c] -= f * m[col][c];
34
+ }
35
+ }
36
+ return m.map((row, i) => row[n] / row[i]);
37
+ }
38
+ // Whether four points actually describe a card: a convex quadrilateral with real
39
+ // area, taken in order.
40
+ //
41
+ // The linear solve is not enough on its own to reject a bad set of picks. Four
42
+ // points on a line have a perfectly good projective map onto them — the one that
43
+ // collapses the plane onto that line — so the system solves, every patch samples
44
+ // somewhere inside the image, and the reader returns confident nonsense. Same for
45
+ // corners clicked out of order: that maps the card onto a bow tie, folded through
46
+ // itself, and again every sample lands on real pixels.
47
+ //
48
+ // Both are checked here instead, where "is this a card" is a question about the
49
+ // four points rather than about a matrix.
50
+ function isProperQuad(p) {
51
+ const xs = p.map((q) => q.x);
52
+ const ys = p.map((q) => q.y);
53
+ const extent = Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys));
54
+ if (extent <= 0)
55
+ return false;
56
+ // Shoelace area. Scaled against the extent so the test is about shape, not
57
+ // about how big in the frame the card happened to be.
58
+ let twiceArea = 0;
59
+ for (let i = 0; i < 4; i++) {
60
+ const a = p[i];
61
+ const b = p[(i + 1) % 4];
62
+ twiceArea += a.x * b.y - b.x * a.y;
63
+ }
64
+ if (Math.abs(twiceArea) / 2 < extent * extent * 0.01)
65
+ return false;
66
+ // Every corner turning the same way — convex, and in order.
67
+ let winding = 0;
68
+ for (let i = 0; i < 4; i++) {
69
+ const a = p[i];
70
+ const b = p[(i + 1) % 4];
71
+ const c = p[(i + 2) % 4];
72
+ const cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x);
73
+ if (cross === 0)
74
+ return false;
75
+ const turn = Math.sign(cross);
76
+ if (winding === 0)
77
+ winding = turn;
78
+ else if (turn !== winding)
79
+ return false;
80
+ }
81
+ return true;
82
+ }
83
+ // The transform taking the chart's four registration points to the four points
84
+ // picked in the photo, both clockwise from top-left. Null if the picks do not
85
+ // describe a card — collinear, out of order, or two of them the same.
86
+ export function homographyFrom(from, to) {
87
+ if (!isProperQuad(from) || !isProperQuad(to))
88
+ return null;
89
+ const a = [];
90
+ const b = [];
91
+ for (let i = 0; i < 4; i++) {
92
+ const { x, y } = from[i];
93
+ const { x: u, y: v } = to[i];
94
+ a.push([x, y, 1, 0, 0, 0, -x * u, -y * u]);
95
+ b.push(u);
96
+ a.push([0, 0, 0, x, y, 1, -x * v, -y * v]);
97
+ b.push(v);
98
+ }
99
+ const h = solve(a, b);
100
+ return h ? [...h, 1] : null;
101
+ }
102
+ export function applyHomography(h, p) {
103
+ const w = h[6] * p.x + h[7] * p.y + h[8];
104
+ return {
105
+ x: (h[0] * p.x + h[1] * p.y + h[2]) / w,
106
+ y: (h[3] * p.x + h[4] * p.y + h[5]) / w,
107
+ };
108
+ }
109
+ const median = (xs) => {
110
+ if (xs.length === 0)
111
+ return 0;
112
+ const s = [...xs].sort((p, q) => p - q);
113
+ const mid = s.length >> 1;
114
+ return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
115
+ };
116
+ // Fraction of a patch's width/height actually sampled, centered. Keeps the
117
+ // reader off the edges, where a patch bleeds into its neighbour and a slight
118
+ // misregistration would otherwise mix two colors into one reading.
119
+ const SAMPLE_INSET = 0.6;
120
+ // Samples per axis inside that window.
121
+ const SAMPLE_GRID = 7;
122
+ // The median of a patch's samples. Median rather than mean because glossy PVC
123
+ // throws specular highlights: a few blown pixels drag a mean a long way and leave
124
+ // a median where it was.
125
+ function samplePatch(image, h, patch) {
126
+ const rs = [];
127
+ const gs = [];
128
+ const bs = [];
129
+ const x0 = patch.x + (patch.width * (1 - SAMPLE_INSET)) / 2;
130
+ const y0 = patch.y + (patch.height * (1 - SAMPLE_INSET)) / 2;
131
+ const w = patch.width * SAMPLE_INSET;
132
+ const hgt = patch.height * SAMPLE_INSET;
133
+ for (let iy = 0; iy < SAMPLE_GRID; iy++) {
134
+ for (let ix = 0; ix < SAMPLE_GRID; ix++) {
135
+ const p = applyHomography(h, {
136
+ x: x0 + (w * (ix + 0.5)) / SAMPLE_GRID,
137
+ y: y0 + (hgt * (iy + 0.5)) / SAMPLE_GRID,
138
+ });
139
+ const px = Math.round(p.x);
140
+ const py = Math.round(p.y);
141
+ if (px < 0 || py < 0 || px >= image.width || py >= image.height)
142
+ continue;
143
+ const o = (py * image.width + px) * 4;
144
+ rs.push(image.data[o]);
145
+ gs.push(image.data[o + 1]);
146
+ bs.push(image.data[o + 2]);
147
+ }
148
+ }
149
+ if (rs.length === 0)
150
+ return null;
151
+ return [median(rs), median(gs), median(bs)];
152
+ }
153
+ // Read every patch of a chart out of a photograph. `corners` are the four
154
+ // fiducial centers as picked in the image, clockwise from top-left.
155
+ export function readChart(image, spec, corners) {
156
+ const h = homographyFrom(spec.registration, corners);
157
+ if (!h)
158
+ return null;
159
+ const raw = new Map();
160
+ const missed = [];
161
+ for (const patch of spec.patches) {
162
+ if (patch.role === "fiducial")
163
+ continue;
164
+ const v = samplePatch(image, h, patch);
165
+ if (v)
166
+ raw.set(patch.id, v);
167
+ else
168
+ missed.push(patch.id);
169
+ }
170
+ // The white reference: the median of the bare-stock patches, so one blown or
171
+ // shadowed corner doesn't set the balance for the whole card.
172
+ const stockValues = spec.patches
173
+ .filter((p) => p.role === "stock")
174
+ .map((p) => raw.get(p.id))
175
+ .filter((v) => !!v);
176
+ const stock = stockValues.length
177
+ ? [
178
+ median(stockValues.map((v) => v[0])),
179
+ median(stockValues.map((v) => v[1])),
180
+ median(stockValues.map((v) => v[2])),
181
+ ]
182
+ : [255, 255, 255];
183
+ // Per-channel gain that lands the bare card on neutral, keeping its level. Any
184
+ // color left in a patch after this is the print's, not the light's.
185
+ const level = (stock[0] + stock[1] + stock[2]) / 3;
186
+ const gain = stock.map((c) => (c > 0 ? level / c : 1));
187
+ const stockClipped = stock.some((c) => c >= 254 || c <= 1);
188
+ // Measured before the gain is applied: the gain is one number for the whole
189
+ // card, so it shifts every stock patch together and cannot change how far apart
190
+ // they are.
191
+ const lightSpread = stockValues.length
192
+ ? Math.max(...[0, 1, 2].map((c) => {
193
+ const vs = stockValues.map((v) => v[c]);
194
+ return Math.max(...vs) - Math.min(...vs);
195
+ }))
196
+ : 0;
197
+ const patches = [];
198
+ for (const patch of spec.patches) {
199
+ if (patch.role === "fiducial")
200
+ continue;
201
+ const v = raw.get(patch.id);
202
+ if (!v)
203
+ continue;
204
+ patches.push({
205
+ id: patch.id,
206
+ role: patch.role,
207
+ sent: patch.rgb,
208
+ raw: v,
209
+ measured: [v[0] * gain[0], v[1] * gain[1], v[2] * gain[2]],
210
+ ...(patch.repeatOf ? { repeatOf: patch.repeatOf } : {}),
211
+ });
212
+ }
213
+ return {
214
+ chartId: spec.id,
215
+ patches,
216
+ stock,
217
+ stockClipped,
218
+ lightSpread,
219
+ missed,
220
+ };
221
+ }
222
+ // ── Reading the reading ────────────────────────────────────────────────────
223
+ // The color left in patches that were sent neutral, per channel, as a signed
224
+ // deviation from their own mean. A printer with no cast reads ~0 across the
225
+ // board; the sign says which way each channel runs. This is the number that
226
+ // answers "why does everything look magenta".
227
+ export function grayCast(reading) {
228
+ const grays = reading.patches.filter((p) => p.role === "measure" &&
229
+ p.sent[0] === p.sent[1] &&
230
+ p.sent[1] === p.sent[2]);
231
+ if (grays.length === 0)
232
+ return [0, 0, 0];
233
+ const sum = [0, 0, 0];
234
+ for (const p of grays) {
235
+ const mean = (p.measured[0] + p.measured[1] + p.measured[2]) / 3;
236
+ sum[0] += p.measured[0] - mean;
237
+ sum[1] += p.measured[1] - mean;
238
+ sum[2] += p.measured[2] - mean;
239
+ }
240
+ return [sum[0] / grays.length, sum[1] / grays.length, sum[2] / grays.length];
241
+ }
242
+ // The largest disagreement between any patch and a repeat of it, in levels. This
243
+ // is the sheet's evenness, and it bounds what a profile built from the card can
244
+ // be worth — a printer that varies more than it corrects is not ready to profile.
245
+ export function repeatSpread(reading) {
246
+ const byId = new Map(reading.patches.map((p) => [p.id, p]));
247
+ let worst = 0;
248
+ for (const p of reading.patches) {
249
+ if (!p.repeatOf)
250
+ continue;
251
+ const base = byId.get(p.repeatOf);
252
+ if (!base)
253
+ continue;
254
+ for (let c = 0; c < 3; c++) {
255
+ worst = Math.max(worst, Math.abs(p.measured[c] - base.measured[c]));
256
+ }
257
+ }
258
+ return worst;
259
+ }
260
+ // Readings as CSV — the handoff to a spreadsheet, or to whoever is building the
261
+ // LUT. One row per patch, what went in beside what came back.
262
+ export function readingToCsv(reading) {
263
+ const rows = [
264
+ "patch_id,role,sent_r,sent_g,sent_b,raw_r,raw_g,raw_b,measured_r,measured_g,measured_b",
265
+ ...reading.patches.map((p) => [
266
+ p.id,
267
+ p.role,
268
+ ...p.sent,
269
+ ...p.raw.map((v) => v.toFixed(1)),
270
+ ...p.measured.map((v) => v.toFixed(1)),
271
+ ].join(",")),
272
+ ];
273
+ return `${rows.join("\n")}\n`;
274
+ }
package/src/plan.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ import type { Adjust, ImageNode, Node } from "@freshcoat-js/engine";
2
+ import type { ChannelBalance, ImageAnalysis, PixelData, PrintOptimizeOptions } from "./types.js";
3
+ export type LayerIntent = "photo" | "graphic" | "text" | "code" | "container";
4
+ export type LayerIntentResolver = (node: Node) => LayerIntent | undefined;
5
+ export declare function classifyIntent(node: Node): LayerIntent;
6
+ export declare function printAdjust(o: PrintOptimizeOptions): Adjust;
7
+ export type PlanPolicy = {
8
+ intentFor?: LayerIntentResolver;
9
+ photo?: PrintOptimizeOptions | null;
10
+ graphic?: PrintOptimizeOptions | null;
11
+ text?: PrintOptimizeOptions | null;
12
+ code?: PrintOptimizeOptions | null;
13
+ balance?: ChannelBalance;
14
+ };
15
+ export declare function planScene(root: Node, policy?: PlanPolicy): Node;
16
+ export type ImageSampler = (image: ImageNode) => Promise<PixelData>;
17
+ export declare function analyzeScene(sample: ImageSampler, root: Node, policy?: PlanPolicy, onAnalysis?: (analysis: ImageAnalysis, node: ImageNode) => void): Promise<Node>;
package/src/plan.js ADDED
@@ -0,0 +1,216 @@
1
+ // The planner — for-print as a consumer of freshcoat's generic adjustment API.
2
+ //
3
+ // Instead of flattening a card and correcting one image, planScene walks a
4
+ // freshcoat Node tree, classifies each layer by print intent, and attaches a
5
+ // generic freshcoat `Adjust` so the layer is corrected PER-LAYER as freshcoat
6
+ // paints it. Photos get dye-sub compensation; crisp vector/text/QR are left
7
+ // pristine — the "protect text" that per-layer planning gives for free (no mask
8
+ // needed: you simply don't adjust those layers).
9
+ //
10
+ // The dependency points at freshcoat only: for-print maps its policy
11
+ // (PrintOptimizeOptions) down to freshcoat's math (color matrix + LUT + sharpen).
12
+ // freshcoat never learns what "dye-sub" or "K panel" means.
13
+ import { analyzePixels, correctionMatrix } from "./analyze.js";
14
+ import { NO_PROCESSING, YMCKO_PRESET } from "./presets.js";
15
+ // Pure channel helpers the tone LUT needs (the raster steps that once held these
16
+ // moved to freshcoat's output path).
17
+ const clamp = (v) => Math.max(0, Math.min(255, Math.round(v)));
18
+ // Photoshop Overlay blend of a base channel against `blend`.
19
+ const overlayBlendChannel = (base, blend) => base < 128
20
+ ? (2 * base * blend) / 255
21
+ : 255 - (2 * (255 - base) * (255 - blend)) / 255;
22
+ // Classify a node by kind. Images are photographic (need dye-sub compensation);
23
+ // text and QR/pixel bitmaps are crisp intent that must stay untouched; rect/
24
+ // ellipse/path are flat vector graphics whose colors were chosen deliberately;
25
+ // group/mask are containers we only recurse into.
26
+ export function classifyIntent(node) {
27
+ switch (node.kind) {
28
+ case "image":
29
+ return "photo";
30
+ case "text":
31
+ return "text";
32
+ case "bitmap":
33
+ return "code";
34
+ case "rect":
35
+ case "ellipse":
36
+ case "path":
37
+ return "graphic";
38
+ default:
39
+ return "container"; // group | mask
40
+ }
41
+ }
42
+ // One channel's tone LUT, folding gamma then the darkness (Overlay-with-black)
43
+ // step — both per-channel, so they compose into one 256-entry table. Mirrors the
44
+ // order and math of the gamma + darkness pixel steps.
45
+ //
46
+ // `balance` is this channel's cast exponent and applies LAST, after the tone
47
+ // steps: it corrects what the printer does to the finished signal, so it belongs
48
+ // at the end of the chain rather than folded into `gamma`. At 1 the table is
49
+ // bit-identical to what it was before balance existed.
50
+ function toneLut(gamma, darkness, balance = 1) {
51
+ const t = new Uint8Array(256);
52
+ for (let i = 0; i < 256; i++) {
53
+ // gamma is a rounded integer LUT (like gammaStep); darkness then operates on
54
+ // that rounded value — staging the two rounds so this matches the pixel steps
55
+ // run in sequence to the LSB.
56
+ let v = clamp(255 * (i / 255) ** gamma);
57
+ if (darkness > 0) {
58
+ // darkness = Overlay(base, black) at `darkness` opacity, per channel.
59
+ v = clamp(v * (1 - darkness) + overlayBlendChannel(v, 0) * darkness);
60
+ }
61
+ if (balance !== 1)
62
+ v = clamp(255 * (v / 255) ** balance);
63
+ t[i] = v;
64
+ }
65
+ return t;
66
+ }
67
+ const NEUTRAL_BALANCE = (b) => !b || (b.r === 1 && b.g === 1 && b.b === 1);
68
+ // Map print correction options to a generic freshcoat Adjust: saturation +
69
+ // contrast fold into the color matrix (cross-channel/linear), gamma + darkness
70
+ // into the per-channel LUT, sharpness passes through as sharpen. The conjunctive
71
+ // white-clamp / black-extraction ops are NOT here — they need all three channels
72
+ // together, so they live in freshcoat's whole-frame FrameFinish (YMCKO_FINISH).
73
+ export function printAdjust(o) {
74
+ const adjust = {};
75
+ const m = correctionMatrix(o);
76
+ if (m) {
77
+ adjust.colorMatrix = m;
78
+ // Card art is largely saturated brand color, where a boost drives the leading
79
+ // channel past full scale. Clipping it there would rotate the hue — an indigo
80
+ // pins blue and drifts magenta — and flatten the gradient it came from, so the
81
+ // correction gives up saturation instead.
82
+ adjust.gamut = "preserve-hue";
83
+ }
84
+ // One table per channel only when the balance asks for it. With no cast
85
+ // measured, all three are the same table and the same object — the shape
86
+ // freshcoat has always been handed.
87
+ const darkness = o.darkness ?? 0;
88
+ if (NEUTRAL_BALANCE(o.balance)) {
89
+ if (o.gamma !== 1 || darkness > 0) {
90
+ const lut = toneLut(o.gamma, darkness);
91
+ adjust.lut = { r: lut, g: lut, b: lut };
92
+ }
93
+ }
94
+ else {
95
+ const b = o.balance;
96
+ adjust.lut = {
97
+ r: toneLut(o.gamma, darkness, b.r),
98
+ g: toneLut(o.gamma, darkness, b.g),
99
+ b: toneLut(o.gamma, darkness, b.b),
100
+ };
101
+ }
102
+ if (o.sharpness > 0)
103
+ adjust.sharpen = o.sharpness;
104
+ return adjust;
105
+ }
106
+ function intentFor(node, policy) {
107
+ return policy.intentFor?.(node) ?? classifyIntent(node);
108
+ }
109
+ function policyFor(intent, policy) {
110
+ switch (intent) {
111
+ case "photo":
112
+ return policy.photo ?? null;
113
+ case "graphic":
114
+ return policy.graphic ?? null;
115
+ case "text":
116
+ return policy.text ?? null;
117
+ case "code":
118
+ return policy.code ?? null;
119
+ default:
120
+ return null; // container
121
+ }
122
+ }
123
+ // Attach an adjust to a leaf if the options produce a non-empty one; otherwise
124
+ // return the node untouched (no empty `adjust` field). A measured `balance`
125
+ // reaches a layer the intent left alone as the only correction it carries.
126
+ function withAdjust(node, opts, balance) {
127
+ const merged = opts
128
+ ? balance
129
+ ? { ...opts, balance }
130
+ : opts
131
+ : balance
132
+ ? { ...NO_PROCESSING, balance }
133
+ : null;
134
+ if (!merged)
135
+ return node;
136
+ const adjust = printAdjust(merged);
137
+ return Object.keys(adjust).length > 0 ? { ...node, adjust } : node;
138
+ }
139
+ // Walk a tree, mapping every node through `leaf` (for drawables) while recursing
140
+ // into group/mask containers. Containers themselves are never adjusted — only
141
+ // their leaves — so corrections apply per drawable, not to a composited subtree.
142
+ function mapTree(node, leaf) {
143
+ if (node.kind === "group") {
144
+ return { ...node, children: node.children.map((c) => mapTree(c, leaf)) };
145
+ }
146
+ if (node.kind === "mask") {
147
+ return {
148
+ ...node,
149
+ mask: mapTree(node.mask, leaf),
150
+ children: node.children.map((c) => mapTree(c, leaf)),
151
+ };
152
+ }
153
+ return leaf(node);
154
+ }
155
+ const DEFAULT_POLICY = { photo: YMCKO_PRESET };
156
+ // Sync planner: attach a policy-driven Adjust per layer, no image I/O. Photos get
157
+ // the policy's photo preset (YMCKO by default); text/QR/graphics are left pristine
158
+ // unless the policy opts them in. Returns a new tree; the input is not mutated.
159
+ export function planScene(root, policy = {}) {
160
+ const p = { ...DEFAULT_POLICY, ...policy };
161
+ return mapTree(root, (node) => withAdjust(node, policyFor(intentFor(node, p), p), p.balance));
162
+ }
163
+ // Async planner: like planScene, but each photo layer is ANALYZED (per-image
164
+ // brightness/saturation/contrast) and corrected with its own recommendation —
165
+ // the core win over correcting one flattened card. Non-photo intents follow
166
+ // `policy` (default: untouched). Pass `policy.photo` explicitly to override
167
+ // analysis: `null` leaves photos alone, a preset forces a fixed correction.
168
+ // Recommendations are cached per src.
169
+ export async function analyzeScene(sample, root, policy = {},
170
+ // Called once per analyzed photo with what analysis found — the gamut pressure
171
+ // especially, which nothing downstream can recover once the render has clamped.
172
+ // Not called for layers an explicit `policy.photo` opted out of analysis.
173
+ onAnalysis) {
174
+ const analyzePhotos = !("photo" in policy);
175
+ // Cache the in-flight PROMISE, not the resolved value: children walk
176
+ // concurrently (Promise.all), so identical layers would otherwise both miss a
177
+ // value-cache and sample twice. Key on the rendered appearance (src + fit +
178
+ // size), since the same src cropped differently analyzes differently.
179
+ const cache = new Map();
180
+ const sampleKey = (n) => `${n.src}|${n.fit}|${Math.round(n.size?.width ?? 0)}x${Math.round(n.size?.height ?? 0)}`;
181
+ function photoOptions(node) {
182
+ if (!analyzePhotos)
183
+ return Promise.resolve(policy.photo ?? null);
184
+ const key = sampleKey(node);
185
+ let analysis = cache.get(key);
186
+ if (!analysis) {
187
+ // Reported on the miss, so duplicated layers are one analysis and one
188
+ // report — the same pixels counted twice would overstate the pressure.
189
+ analysis = sample(node).then((px) => {
190
+ const result = analyzePixels(px);
191
+ onAnalysis?.(result, node);
192
+ return result;
193
+ });
194
+ cache.set(key, analysis);
195
+ }
196
+ return analysis.then((a) => a.recommendation);
197
+ }
198
+ async function walk(node) {
199
+ if (node.kind === "group") {
200
+ return { ...node, children: await Promise.all(node.children.map(walk)) };
201
+ }
202
+ if (node.kind === "mask") {
203
+ const [mask, children] = await Promise.all([
204
+ walk(node.mask),
205
+ Promise.all(node.children.map(walk)),
206
+ ]);
207
+ return { ...node, mask, children };
208
+ }
209
+ const intent = intentFor(node, policy);
210
+ if (node.kind === "image" && intent === "photo") {
211
+ return withAdjust(node, await photoOptions(node), policy.balance);
212
+ }
213
+ return withAdjust(node, policyFor(intent, policy), policy.balance);
214
+ }
215
+ return walk(root);
216
+ }
@@ -0,0 +1,5 @@
1
+ import type { FrameFinish } from "@freshcoat-js/engine";
2
+ import type { PrintOptimizeOptions } from "./types.js";
3
+ export declare const YMCKO_PRESET: PrintOptimizeOptions;
4
+ export declare const NO_PROCESSING: PrintOptimizeOptions;
5
+ export declare const YMCKO_FINISH: FrameFinish;
package/src/presets.js ADDED
@@ -0,0 +1,26 @@
1
+ // Tuned for YMCKO dye-sublimation ribbon printers.
2
+ export const YMCKO_PRESET = {
3
+ saturation: 1.3,
4
+ contrast: 1.2,
5
+ sharpness: 0.3,
6
+ gamma: 0.85,
7
+ darkness: 0.25,
8
+ };
9
+ export const NO_PROCESSING = {
10
+ saturation: 1,
11
+ contrast: 1,
12
+ sharpness: 0,
13
+ gamma: 1,
14
+ darkness: 0,
15
+ };
16
+ // The whole-frame finishing for-print recommends alongside the per-layer
17
+ // adjustments — the conjunctive/spatial output ops that aren't a per-layer Adjust.
18
+ // Pass to freshcoat's compile/render `finish`. Mirrors the old buffer pipeline's
19
+ // defaults: snap near-black (<30) to resin-friendly black, near-white (>248) to
20
+ // white, and ±2 levels of deterministic monochrome dither to break dye-sub
21
+ // gradient banding without colored grain.
22
+ export const YMCKO_FINISH = {
23
+ blackExtract: 30,
24
+ whiteClamp: 248,
25
+ dither: { amount: 2, seed: 0, mode: "monochrome" },
26
+ };
@@ -0,0 +1,37 @@
1
+ import { type CalibrationAssessment } from "./calibration.js";
2
+ import type { ChartReading } from "./measure.js";
3
+ import type { ChannelBalance, PrintOptimizeOptions } from "./types.js";
4
+ export interface PrintProfileConditions {
5
+ printer?: string;
6
+ ribbon?: string;
7
+ stock?: string;
8
+ }
9
+ export interface PrintProfile {
10
+ version?: 1;
11
+ name: string;
12
+ balance?: ChannelBalance;
13
+ measuredAt?: string;
14
+ notes?: string;
15
+ conditions?: PrintProfileConditions;
16
+ assessment?: CalibrationAssessment;
17
+ }
18
+ export type PrintProfileDetails = {
19
+ name: string;
20
+ measuredAt?: string;
21
+ notes?: string;
22
+ conditions?: PrintProfileConditions;
23
+ };
24
+ export type PrintProfileCreation = {
25
+ ok: true;
26
+ profile: PrintProfile;
27
+ assessment: CalibrationAssessment;
28
+ } | {
29
+ ok: false;
30
+ reason: "unsafe-reading" | "missing-name" | "invalid-conditions";
31
+ assessment: CalibrationAssessment;
32
+ };
33
+ export declare const UNMEASURED_PROFILE: PrintProfile;
34
+ export declare function createPrintProfile(reading: ChartReading, details: PrintProfileDetails): PrintProfileCreation;
35
+ export declare function withProfile(options: PrintOptimizeOptions, profile: PrintProfile | undefined): PrintOptimizeOptions;
36
+ export declare function profileCacheKey(profile: PrintProfile | undefined): string;
37
+ export declare function parsePrintProfile(input: unknown): PrintProfile | Error;