@pixerate/editor 0.260.0 → 0.262.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.
Files changed (35) hide show
  1. package/dist/agent/index.cjs +19 -3
  2. package/dist/agent/index.js +21 -5
  3. package/dist/bridge/cli.cjs +4 -3
  4. package/dist/bridge/cli.js +3 -2
  5. package/dist/bridge/index.cjs +4 -3
  6. package/dist/bridge/index.js +3 -2
  7. package/dist/{chunk-KBNCEZZB.cjs → chunk-5NRD76T7.cjs} +9 -7
  8. package/dist/{chunk-SPPDMY7T.js → chunk-6LWEL5DO.js} +4 -2
  9. package/dist/{chunk-RTPBM46F.cjs → chunk-7TD63DZP.cjs} +1 -294
  10. package/dist/{chunk-ABHOYBZD.js → chunk-K4BGRCSH.js} +1 -294
  11. package/dist/chunk-SYEOA5MS.js +301 -0
  12. package/dist/{chunk-5DLCOQOQ.cjs → chunk-YBC3RKT2.cjs} +177 -16
  13. package/dist/chunk-ZEKI6EPI.cjs +301 -0
  14. package/dist/{chunk-UCYW4M66.js → chunk-ZQI3WUPP.js} +163 -2
  15. package/dist/paint-editor/contract.d.cts +11 -0
  16. package/dist/paint-editor/contract.d.ts +11 -0
  17. package/dist/paint-editor/controller.d.cts +17 -0
  18. package/dist/paint-editor/controller.d.ts +17 -0
  19. package/dist/paint-editor/index.cjs +245 -84
  20. package/dist/paint-editor/index.d.cts +2 -1
  21. package/dist/paint-editor/index.d.ts +2 -1
  22. package/dist/paint-editor/index.js +163 -2
  23. package/dist/paint-editor/path-samples.d.cts +19 -0
  24. package/dist/paint-editor/path-samples.d.ts +19 -0
  25. package/dist/paint-editor/ui.d.cts +6 -0
  26. package/dist/paint-editor/ui.d.ts +6 -0
  27. package/dist/tools/conformance.d.cts +138 -0
  28. package/dist/tools/conformance.d.ts +138 -0
  29. package/dist/tools/index.cjs +19 -3
  30. package/dist/tools/index.d.cts +1 -0
  31. package/dist/tools/index.d.ts +1 -0
  32. package/dist/tools/index.js +21 -5
  33. package/package.json +1 -1
  34. package/skills/pixerate-paint-editor/SKILL.md +2 -0
  35. package/skills/pixerate-paint-sketch-to-finish/SKILL.md +5 -1
@@ -57,4 +57,5 @@ export type { StickerProvider, StickerRef, StickerSummary } from '../stickers/ty
57
57
  export * from './sticker-art.cjs';
58
58
  export * from './collaboration.cjs';
59
59
  export * from './collaboration-tools.cjs';
60
- export { paintContracts, PAINT_CONTRACT_FORMAT_VERSION, PAINT_DOCUMENT_JSON_SCHEMA } from './contract.cjs';
60
+ export { samplePath, MAX_PATH_SAMPLES, type PathSampleOptions } from './path-samples.cjs';
61
+ export { createPaintConformanceHost, paintContracts, PAINT_CONTRACT_FORMAT_VERSION, PAINT_DOCUMENT_JSON_SCHEMA } from './contract.cjs';
@@ -57,4 +57,5 @@ export type { StickerProvider, StickerRef, StickerSummary } from '../stickers/ty
57
57
  export * from './sticker-art.js';
58
58
  export * from './collaboration.js';
59
59
  export * from './collaboration-tools.js';
60
- export { paintContracts, PAINT_CONTRACT_FORMAT_VERSION, PAINT_DOCUMENT_JSON_SCHEMA } from './contract.js';
60
+ export { samplePath, MAX_PATH_SAMPLES, type PathSampleOptions } from './path-samples.js';
61
+ export { createPaintConformanceHost, paintContracts, PAINT_CONTRACT_FORMAT_VERSION, PAINT_DOCUMENT_JSON_SCHEMA } from './contract.js';
@@ -19,6 +19,9 @@ import {
19
19
  import {
20
20
  toVideoFfmpeg
21
21
  } from "../chunk-MWUZCEFT.js";
22
+ import {
23
+ ToolRegistry
24
+ } from "../chunk-K4BGRCSH.js";
22
25
  import {
23
26
  BRUSH_OPTION_CONTROLS,
24
27
  createLayerEffectTools,
@@ -150,7 +153,11 @@ import {
150
153
  stickerIdFor
151
154
  } from "../chunk-APX63ILU.js";
152
155
  import "../chunk-M6DNZNVY.js";
153
- import "../chunk-WEIK3UYB.js";
156
+ import {
157
+ bezierPathToSvg,
158
+ flattenChain,
159
+ pathToContours
160
+ } from "../chunk-WEIK3UYB.js";
154
161
  import "../chunk-PWBZH3FK.js";
155
162
  import {
156
163
  brushColourHex
@@ -274,7 +281,8 @@ import "../chunk-VDGBHYC7.js";
274
281
  import {
275
282
  EDITOR_DOCUMENT_FORMAT,
276
283
  createEditorAssetRegistry,
277
- defineEditorDocument
284
+ defineEditorDocument,
285
+ toEditorDocumentFile
278
286
  } from "../chunk-XBQX76CT.js";
279
287
  import "../chunk-HF5QZS7Z.js";
280
288
  import "../chunk-M2TPMYJE.js";
@@ -5221,6 +5229,55 @@ function engineChanges(base, next) {
5221
5229
  return rest;
5222
5230
  }
5223
5231
 
5232
+ // src/paint-editor/path-samples.ts
5233
+ var FLATTEN_TOLERANCE = 0.1;
5234
+ var MAX_PATH_SAMPLES = 2e4;
5235
+ function profileAt(profile, t) {
5236
+ if (profile.length === 1) return profile[0];
5237
+ const at = t * (profile.length - 1);
5238
+ const i = Math.min(profile.length - 2, Math.floor(at));
5239
+ return profile[i] + (profile[i + 1] - profile[i]) * (at - i);
5240
+ }
5241
+ function samplePath(d, options = {}) {
5242
+ const spacing = options.spacing ?? 2;
5243
+ if (!(spacing >= 0.25 && spacing <= 100)) throw new Error(`spacing ${spacing} is out of range; use 0.25..100 document pixels.`);
5244
+ const profile = options.pressure;
5245
+ if (profile && (!profile.length || profile.some((p) => !(p >= 0 && p <= 1)))) throw new Error("pressure needs one or more values from 0 to 1, e.g. [0.2, 1, 0.2] for a taper.");
5246
+ let contours;
5247
+ try {
5248
+ contours = pathToContours(d);
5249
+ } catch (e) {
5250
+ throw new Error(`path is not SVG path data (${e instanceof Error ? e.message : String(e)}); give e.g. "M 10 10 C 40 0 80 60 120 30".`);
5251
+ }
5252
+ const out = [];
5253
+ for (const contour of contours) {
5254
+ if (!contour.segments.length) continue;
5255
+ const line = flattenChain(contour.segments, FLATTEN_TOLERANCE);
5256
+ if (contour.closed && line.length) line.push(line[0]);
5257
+ const lengths = [0];
5258
+ for (let i = 1; i < line.length; i++) lengths.push(lengths[i - 1] + Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]));
5259
+ const total = lengths[lengths.length - 1];
5260
+ const n = Math.max(1, Math.ceil(total / spacing));
5261
+ if (n + 1 > MAX_PATH_SAMPLES) throw new Error(`The path is ${Math.round(total)} px long: too many samples at spacing ${spacing}; use a larger spacing (at least ${Math.ceil(total / (MAX_PATH_SAMPLES - 1))}).`);
5262
+ const points = [];
5263
+ let j = 0;
5264
+ for (let k = 0; k <= n; k++) {
5265
+ const at = total * k / n;
5266
+ while (j < line.length - 2 && lengths[j + 1] < at) j++;
5267
+ const span = lengths[j + 1] - lengths[j] || 1;
5268
+ const f = line.length > 1 ? Math.min(1, Math.max(0, (at - lengths[j]) / span)) : 0;
5269
+ const a = line[j];
5270
+ const b = line[Math.min(j + 1, line.length - 1)];
5271
+ const x = Math.round((a[0] + (b[0] - a[0]) * f) * 1e3) / 1e3;
5272
+ const y = Math.round((a[1] + (b[1] - a[1]) * f) * 1e3) / 1e3;
5273
+ points.push(profile ? { x, y, pressure: Math.round(profileAt(profile, k / n) * 1e3) / 1e3 } : { x, y });
5274
+ }
5275
+ out.push(points);
5276
+ }
5277
+ if (!out.length) throw new Error('path has no segments to brush; give SVG path data such as "M 10 10 L 200 80".');
5278
+ return out;
5279
+ }
5280
+
5224
5281
  // src/paint-editor/live-strokes.ts
5225
5282
  var LIVE_STROKE_ENGINES = ["dab", "particle", "bristle", "mixer"];
5226
5283
  function liveStrokeRefusal(brush) {
@@ -7857,6 +7914,47 @@ var PaintEditorController = class _PaintEditorController {
7857
7914
  else this.emit([]);
7858
7915
  return result;
7859
7916
  }
7917
+ /**
7918
+ * Brushes vector path data (SVG `d`, document pixels) with the active brush, or `brushId` /
7919
+ * `brush`, as if drawn by hand: one recorded stroke per subpath, sampled every `spacing` px
7920
+ * with an optional `pressure` profile along each. All of it is one undo step. A given `seed`
7921
+ * seeds the first subpath; the next ones take the following seeds.
7922
+ */
7923
+ strokePath(init) {
7924
+ this.requireIdle("painting");
7925
+ if (typeof init.path !== "string" || !init.path.trim()) throw new PaintEditorError('path is empty; give SVG path data in document pixels, e.g. "M 10 10 C 40 0 80 60 120 30".');
7926
+ let subpaths;
7927
+ try {
7928
+ subpaths = samplePath(init.path, init);
7929
+ } catch (e) {
7930
+ throw new PaintEditorError(e instanceof Error ? e.message : String(e));
7931
+ }
7932
+ const { path: _path, spacing: _spacing, pressure: _pressure, ...stroke } = init;
7933
+ const before = this.doc;
7934
+ let doc = this.doc;
7935
+ let bounds = null;
7936
+ let dabs = 0;
7937
+ const strokeIds = [];
7938
+ let last = null;
7939
+ try {
7940
+ subpaths.forEach((points, i) => {
7941
+ last = this.runStroke(doc, { ...stroke, ...stroke.seed !== void 0 ? { seed: stroke.seed + i } : {}, start: points[0] }, points);
7942
+ doc = last.doc;
7943
+ bounds = unionRect(bounds, last.result.bounds);
7944
+ dabs += last.result.dabs;
7945
+ if (last.result.strokeId) strokeIds.push(last.result.strokeId);
7946
+ });
7947
+ } catch (e) {
7948
+ this.wetOp = null;
7949
+ throw e;
7950
+ }
7951
+ const run = last;
7952
+ if (run?.result.mixerLoad) this.keepLoad(run.brush, run.result.mixerLoad);
7953
+ if (doc !== before || this.wetOp) this.commit(`${run?.brush.name ?? "Brush"} path stroke`, this.withWetEvents(doc), before);
7954
+ else this.emit([]);
7955
+ const layerId = run?.result.layerId ?? stroke.layerId ?? this.doc.activeLayerId ?? "";
7956
+ return { layerId, bounds, dabs, strokeIds, ...run?.result.mixerLoad ? { mixerLoad: run.result.mixerLoad } : {}, ...run?.result.wet !== void 0 ? { wet: run.result.wet } : {} };
7957
+ }
7860
7958
  /** Checks stroke samples (finite x and y, pressure 0..1) and returns them; throws a sentence naming the bad point. */
7861
7959
  checkPoints(points) {
7862
7960
  if (!Array.isArray(points) || !points.length) throw new PaintEditorError("points is empty; give at least one {x, y} in document pixels.");
@@ -11579,6 +11677,41 @@ function createPaintEditorToolProvider(controller, options = {}) {
11579
11677
  return r.mixerLoad ? { ...r, mixerLoad: loadResult(r.mixerLoad) } : r;
11580
11678
  }
11581
11679
  });
11680
+ add({
11681
+ name: "stroke_path",
11682
+ title: "Brush along a path",
11683
+ description: "Brushes vector path data (SVG path d in document pixels: M, L, H, V, C, S, Q, T, A and Z) on a pixel layer with a library brush or the active one, as if drawn by hand along it: the outline of a shape, a lettering guide, a curve from a vector tool. Each subpath becomes one recorded stroke, sampled every spacing px, with an optional pressure profile spread along each subpath (a taper such as [0.2, 1, 0.2]); closed subpaths end where they start. Brushes behave as in paint_stroke (grain, pick-up, wet and thick paint, guides off). One undo step for the whole path. Returns the layer id, changed bounds, dab count and the recorded strokeIds (for replay_strokes, update_stroke).",
11684
+ inputSchema: {
11685
+ type: "object",
11686
+ properties: {
11687
+ path: { type: "string", minLength: 1, description: 'SVG path data in document pixels, e.g. "M 40 200 C 120 40 280 40 360 200". Several subpaths paint several strokes.' },
11688
+ spacing: { type: "number", minimum: 0.25, maximum: 100, description: "Distance between samples along the path in document pixels (default 2); the brush spaces its own dabs, so this only sets how closely curves are followed." },
11689
+ pressure: { type: "array", minItems: 1, maxItems: 64, items: { type: "number", minimum: 0, maximum: 1 }, description: "Pen pressure along each subpath, spread evenly from start to end (default full pressure); [0.2, 1, 0.2] tapers both ends." },
11690
+ layerId: LAYER_ID4,
11691
+ brushId: { type: "string", description: "Brush id from list_brush_presets (default the active brush)." },
11692
+ brush: { type: "object", description: "Facets to change for this stroke only, laid over brushId or the active brush (as set_brush takes them).", properties: BRUSH_PATCH_PROPERTIES, additionalProperties: false },
11693
+ color: { ...COLOR3, description: "Paint colour #rrggbb (default the paint colour)." },
11694
+ paint: { ...PAINT_SOURCE_JSON_SCHEMA, description: "What the strokes paint with instead of the editor's paint: a colour, or gradient paint that changes colour as each stroke goes." },
11695
+ seed: { type: "integer", minimum: 0, description: "Jitter seed of the first subpath, the next ones take the following seeds (default new ones); the same seed and path give the same marks." }
11696
+ },
11697
+ required: ["path"],
11698
+ additionalProperties: false
11699
+ },
11700
+ annotations: { title: "Brush along a path", ...WRITE5 },
11701
+ coordinates: { out: ["bounds"] },
11702
+ handler: async (a) => {
11703
+ await c.wetIdle();
11704
+ const base = a.brushId ? c.getBrush(a.brushId) : c.getState().brush;
11705
+ const brush = a.brush ? c.brushChanges(a.brush, a.brushId) : void 0;
11706
+ if (base) {
11707
+ const refs = brushStickerRefs(normalizePaintBrush({ ...brush ?? {}, id: base.id }, base));
11708
+ if (refs.length) await c.prepareStickers(refs);
11709
+ }
11710
+ const path = options.origin && typeof a.path === "string" && a.path.trim() ? bezierPathToSvg(a.path, { offset: { x: -options.origin.x, y: -options.origin.y } }) : a.path;
11711
+ const r = c.strokePath({ path, spacing: a.spacing, pressure: a.pressure, layerId: a.layerId, brushId: a.brushId, brush, color: a.color, paint: a.paint, seed: a.seed });
11712
+ return r.mixerLoad ? { ...r, mixerLoad: loadResult(r.mixerLoad) } : r;
11713
+ }
11714
+ });
11582
11715
  add({
11583
11716
  name: "get_live_stroke",
11584
11717
  title: "Get the stroke being painted",
@@ -12480,6 +12613,12 @@ function toggleDotTexture(textures, stamp) {
12480
12613
  if (textures.includes(stamp)) return textures.filter((s) => s !== stamp);
12481
12614
  return textures.length < MAX_DOT_TEXTURES ? [...textures, stamp] : [...textures];
12482
12615
  }
12616
+ var PATH_STROKE_TAPERS = [
12617
+ { id: "even", label: "Even" },
12618
+ { id: "both", label: "Taper both ends", pressure: [0.2, 1, 0.2] },
12619
+ { id: "in", label: "Fade in", pressure: [0.2, 1] },
12620
+ { id: "out", label: "Fade out", pressure: [1, 0.2] }
12621
+ ];
12483
12622
  var STROKE_SLIDERS = [
12484
12623
  { path: "tip.size", label: "Size", min: 1, max: 400, step: 1 },
12485
12624
  { path: "tip.opacity", label: "Opacity", min: 0, max: 1, step: 0.01 },
@@ -13270,6 +13409,7 @@ function uiTables() {
13270
13409
  blendMenu: BLEND_MENU_GROUPS,
13271
13410
  layerEffects: LAYER_EFFECT_MENU.map(({ kind, label }) => ({ kind, label, controls: layerEffectControls(defaultLayerEffect(kind, kind)) })),
13272
13411
  sliders: { paper: PAPER_SLIDERS, wet: WET_PARAM_SLIDERS, wetTilt: WET_TILT_SLIDERS, wetSpeed: WET_SPEED_SLIDER, light: LIGHT_SLIDERS, stroke: STROKE_SLIDERS, sticker: STICKER_SLIDERS },
13412
+ pathStrokeTapers: PATH_STROKE_TAPERS,
13273
13413
  padSampleSizes: PAD_SAMPLE_SIZES,
13274
13414
  wetGpu: WET_GPU_OPTIONS,
13275
13415
  labels: {
@@ -13310,6 +13450,23 @@ function paintContracts(options) {
13310
13450
  };
13311
13451
  return files;
13312
13452
  }
13453
+ var GENERATED_ID = /^(pl[0-9a-z]{9,}|[a-z]+_[0-9a-z]{8,}_[0-9a-z]+)$/;
13454
+ function createPaintConformanceHost(setup, options) {
13455
+ const controller = new PaintEditorController({ ...setup, wet: { kernel: "typescript", threads: 0, gpu: "off", ...setup.wet } });
13456
+ controller.setEffectsProvider(options.effects);
13457
+ const registry = new ToolRegistry({ validateArguments: true });
13458
+ registry.register(createPaintEditorToolProvider(controller));
13459
+ registry.register(createPaintEffectsToolProvider(controller));
13460
+ registry.register(createPaintBrushLibraryToolProvider(controller));
13461
+ registry.register(createPaintProposalToolProvider(createPaintProposals({ controller })));
13462
+ return {
13463
+ callTool: (name, args) => registry.callTool(name, args),
13464
+ document: () => toEditorDocumentFile(createPaintEditorDocument(controller)),
13465
+ pixels: ({ paper }) => controller.compositePixels({ paper }),
13466
+ isGeneratedId: (value) => GENERATED_ID.test(value),
13467
+ dispose: () => controller.dispose()
13468
+ };
13469
+ }
13313
13470
  export {
13314
13471
  BACKLIGHT_SPILL,
13315
13472
  BLEND_MENU_GROUPS,
@@ -13364,6 +13521,7 @@ export {
13364
13521
  MAX_DOT_TEXTURES,
13365
13522
  MAX_IMAGE_SIDE,
13366
13523
  MAX_LIVE_EFFECT_SOURCES,
13524
+ MAX_PATH_SAMPLES,
13367
13525
  MAX_STICKER_STAMPS,
13368
13526
  MAX_ZOOM,
13369
13527
  MIN_CANVAS_SIDE,
@@ -13407,6 +13565,7 @@ export {
13407
13565
  PARTICLE_KIND_LABELS,
13408
13566
  PARTICLE_RENDERS,
13409
13567
  PARTICLE_RENDER_LABELS,
13568
+ PATH_STROKE_TAPERS,
13410
13569
  PaintCanvasPresenter,
13411
13570
  PaintDabStroke,
13412
13571
  PaintDirector,
@@ -13477,6 +13636,7 @@ export {
13477
13636
  createPaintBrushLibraryToolProvider,
13478
13637
  createPaintChangeFeed,
13479
13638
  createPaintCompositor,
13639
+ createPaintConformanceHost,
13480
13640
  createPaintDocument,
13481
13641
  createPaintEditorDocument,
13482
13642
  createPaintEditorToolProvider,
@@ -13617,6 +13777,7 @@ export {
13617
13777
  sampleArt,
13618
13778
  sampleEffectAt,
13619
13779
  sampleEffectField,
13780
+ samplePath,
13620
13781
  samplePressure,
13621
13782
  scalePaintGradient,
13622
13783
  scaleStencil,
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Pen samples along vector path data, so a path can be brushed like a hand-drawn stroke
3
+ * (`strokePath`, the `stroke_path` tool): each subpath becomes one stroke, sampled at an even
4
+ * spacing, with an optional pressure profile spread along its length (a taper).
5
+ */
6
+ import type { PenPoint } from './engine.cjs';
7
+ /** The most samples one subpath may have (the stroke tools' point limit). */
8
+ export declare const MAX_PATH_SAMPLES = 20000;
9
+ export interface PathSampleOptions {
10
+ /** Distance between samples in document pixels (default 2). */
11
+ spacing?: number;
12
+ /** Pressure along each subpath, spread evenly from its start to its end (default full pressure). */
13
+ pressure?: readonly number[];
14
+ }
15
+ /**
16
+ * Samples SVG path data (document pixels) into pen points, one array per subpath. Closed
17
+ * subpaths end where they start. Throws a sentence when the path has nothing to brush.
18
+ */
19
+ export declare function samplePath(d: string, options?: PathSampleOptions): PenPoint[][];
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Pen samples along vector path data, so a path can be brushed like a hand-drawn stroke
3
+ * (`strokePath`, the `stroke_path` tool): each subpath becomes one stroke, sampled at an even
4
+ * spacing, with an optional pressure profile spread along its length (a taper).
5
+ */
6
+ import type { PenPoint } from './engine.js';
7
+ /** The most samples one subpath may have (the stroke tools' point limit). */
8
+ export declare const MAX_PATH_SAMPLES = 20000;
9
+ export interface PathSampleOptions {
10
+ /** Distance between samples in document pixels (default 2). */
11
+ spacing?: number;
12
+ /** Pressure along each subpath, spread evenly from its start to its end (default full pressure). */
13
+ pressure?: readonly number[];
14
+ }
15
+ /**
16
+ * Samples SVG path data (document pixels) into pen points, one array per subpath. Closed
17
+ * subpaths end where they start. Throws a sentence when the path has nothing to brush.
18
+ */
19
+ export declare function samplePath(d: string, options?: PathSampleOptions): PenPoint[][];
@@ -250,6 +250,12 @@ export declare function toggleStamp(stamps: readonly StampKind[], stamp: StampKi
250
250
  * else adds it while there are fewer than `MAX_DOT_TEXTURES`. Empty means round dots.
251
251
  */
252
252
  export declare function toggleDotTexture(textures: readonly StampKind[], stamp: StampKind): StampKind[];
253
+ /** Pressure profiles the "Brush a path" panel offers for `strokePath` (`pressure` undefined: even). */
254
+ export declare const PATH_STROKE_TAPERS: ReadonlyArray<{
255
+ id: string;
256
+ label: string;
257
+ pressure?: readonly number[];
258
+ }>;
253
259
  /** The sticker panel's sliders for the selected sticker. */
254
260
  /** The brush sliders of a picked stroke on a stroke layer (paths into its brush; see `updateStroke`). */
255
261
  export declare const STROKE_SLIDERS: readonly SliderControl[];
@@ -250,6 +250,12 @@ export declare function toggleStamp(stamps: readonly StampKind[], stamp: StampKi
250
250
  * else adds it while there are fewer than `MAX_DOT_TEXTURES`. Empty means round dots.
251
251
  */
252
252
  export declare function toggleDotTexture(textures: readonly StampKind[], stamp: StampKind): StampKind[];
253
+ /** Pressure profiles the "Brush a path" panel offers for `strokePath` (`pressure` undefined: even). */
254
+ export declare const PATH_STROKE_TAPERS: ReadonlyArray<{
255
+ id: string;
256
+ label: string;
257
+ pressure?: readonly number[];
258
+ }>;
253
259
  /** The sticker panel's sliders for the selected sticker. */
254
260
  /** The brush sliders of a picked stroke on a stroke layer (paths into its brush; see `updateStroke`). */
255
261
  export declare const STROKE_SLIDERS: readonly SliderControl[];
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Conformance scenarios: a recorded sequence of tool calls with what each one returned, so
3
+ * another implementation of an editor (the Swift port, #533) can replay the same calls and check
4
+ * it gets the same results, documents and pixels. `@pixerate/conformance` holds the scenarios
5
+ * (`domains/<domain>/fixtures/*.json`); `scripts/record-conformance.mjs` records them from this
6
+ * core and a core test replays them.
7
+ *
8
+ * Results are compared after {@link normalizeConformanceValue}: ids an implementation generates
9
+ * (from a clock, say) become `@1`, `@2`, … in the order they are first seen, and binary payloads
10
+ * become `"<binary>"`, so neither the id format nor the PNG encoder is part of the contract. Ids
11
+ * that come from counters or names (`s1`, `pad1`, `ink-pen`) stay as they are. Every implementation applies the same rules (written
12
+ * out in the package README).
13
+ */
14
+ import type { McpToolResult } from './registry.cjs';
15
+ /** One tool call of a scenario and, once recorded, what it returned. */
16
+ export interface ConformanceStep {
17
+ /** Tool name, unprefixed (`paint_stroke`). */
18
+ tool: string;
19
+ /** Arguments. A string `@n` is replaced by the id that `@n` stands for. */
20
+ arguments?: Record<string, unknown>;
21
+ /** Also compare the saved document after this call. */
22
+ document?: boolean;
23
+ /**
24
+ * Also compare the rendered pixels after this call (`paper: false` leaves the paper out;
25
+ * `tolerance` loosens what another implementation is held to for this snapshot).
26
+ */
27
+ pixels?: boolean | {
28
+ paper?: boolean;
29
+ tolerance?: {
30
+ maxDelta?: number;
31
+ meanDelta?: number;
32
+ };
33
+ };
34
+ /**
35
+ * Parts of the result left out of the comparison because they describe this implementation
36
+ * rather than the contract (the wet engine's backend): JSON pointers like `/backend`, where a
37
+ * `*` segment stands for every array element or object member.
38
+ */
39
+ ignore?: string[];
40
+ /** Recorded results, written by the recorder. */
41
+ expect?: ConformanceExpectation;
42
+ }
43
+ export interface ConformanceExpectation {
44
+ /** The normalized `structuredContent`, or `{ error: { code, message, details? } }` for a failed call. */
45
+ result: unknown;
46
+ isError?: true;
47
+ document?: unknown;
48
+ pixels?: ConformancePixels;
49
+ }
50
+ /** A pixel snapshot: the PNG next to the scenario plus how close another implementation must be. */
51
+ export interface ConformancePixels {
52
+ /** PNG file, relative to the scenario file. */
53
+ file: string;
54
+ width: number;
55
+ height: number;
56
+ /** SHA-256 of the RGBA bytes, hex. This core matches it exactly. */
57
+ sha256: string;
58
+ /** Largest difference allowed in any channel of any pixel (0–255). */
59
+ maxDelta: number;
60
+ /** Largest mean difference allowed over all channels. */
61
+ meanDelta: number;
62
+ }
63
+ export interface ConformanceScenario {
64
+ $schema?: string;
65
+ /** Unique kebab-case id within the domain; also the file name. */
66
+ id: string;
67
+ description: string;
68
+ /** Host options, such as the canvas size. */
69
+ setup?: Record<string, unknown>;
70
+ steps: ConformanceStep[];
71
+ /** Reasons an implementation skips the whole scenario, by implementation (`swift`). */
72
+ skip?: Record<string, string>;
73
+ }
74
+ /** What a scenario runs against: a tool registry plus ways to read the document and pixels. */
75
+ export interface ConformanceHost {
76
+ callTool(name: string, args: Record<string, unknown>): Promise<McpToolResult>;
77
+ document(): unknown;
78
+ pixels(options: {
79
+ paper?: boolean;
80
+ }): {
81
+ width: number;
82
+ height: number;
83
+ data: Uint8Array | Uint8ClampedArray;
84
+ };
85
+ /** Whether a string is an id this implementation generated in its own format (default: none). */
86
+ isGeneratedId?(value: string): boolean;
87
+ dispose?(): void;
88
+ }
89
+ /** What one step produced, normalized (pixels are raw RGBA for the caller to hash or encode). */
90
+ export interface ConformanceStepOutput {
91
+ result: unknown;
92
+ isError: boolean;
93
+ document?: unknown;
94
+ pixels?: {
95
+ width: number;
96
+ height: number;
97
+ data: Uint8Array;
98
+ };
99
+ }
100
+ /** Maps real ids to `@n` placeholders and back, across a whole scenario. */
101
+ export declare class ConformanceIds {
102
+ private readonly toPlaceholder;
103
+ private readonly fromPlaceholder;
104
+ /** The placeholder for `id`, assigning the next one when it is new. */
105
+ add(id: string): string;
106
+ placeholder(id: string): string | undefined;
107
+ /** `text` with every known id in it replaced by its placeholder (longest ids first). */
108
+ replaceWithin(text: string): string;
109
+ /** The real id a placeholder stands for. */
110
+ resolve(placeholder: string): string | undefined;
111
+ }
112
+ /** Strings at least this long made only of base64 characters are binary payloads. */
113
+ export declare const CONFORMANCE_BINARY_MIN_LENGTH = 200;
114
+ type IdTest = (value: string) => boolean;
115
+ /**
116
+ * A value as scenarios store it:
117
+ * 1. Every string value or object key the implementation generated as an id (`isGeneratedId`) is
118
+ * numbered `@1`, `@2`, … in the order first seen, walking depth first with object keys sorted
119
+ * (each key before its value); the numbering carries across the scenario's steps.
120
+ * 2. Every known id in a string or object key, whole or inside a longer string (an error message),
121
+ * becomes its placeholder; longer ids are replaced first.
122
+ * 3. A string starting with `data:`, or at least 200 characters of base64, becomes `"<binary>"`.
123
+ * 4. An ISO 8601 date-time string (`2026-11-04T01:06:52.993Z`) becomes `"<time>"`: clocks differ.
124
+ * 5. Object keys are sorted; `undefined` values are dropped (as JSON would).
125
+ */
126
+ export declare function normalizeConformanceValue(value: unknown, ids: ConformanceIds, isGeneratedId?: IdTest): unknown;
127
+ /** `value` without the members at `paths` (`/a/b`, `*` for every element or member). */
128
+ export declare function omitConformancePaths(value: unknown, paths: readonly string[]): unknown;
129
+ /** Arguments with each `@n` placeholder string replaced by the id it stands for. */
130
+ export declare function resolveConformanceArguments(value: unknown, ids: ConformanceIds): unknown;
131
+ /** The comparable part of a tool result: `structuredContent`, or the error's code, message and details. */
132
+ export declare function conformanceResult(result: McpToolResult): {
133
+ value: unknown;
134
+ isError: boolean;
135
+ };
136
+ /** Runs a scenario's calls in order against a fresh host and returns what each step produced. */
137
+ export declare function runConformanceScenario(scenario: ConformanceScenario, host: ConformanceHost): Promise<ConformanceStepOutput[]>;
138
+ export {};
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Conformance scenarios: a recorded sequence of tool calls with what each one returned, so
3
+ * another implementation of an editor (the Swift port, #533) can replay the same calls and check
4
+ * it gets the same results, documents and pixels. `@pixerate/conformance` holds the scenarios
5
+ * (`domains/<domain>/fixtures/*.json`); `scripts/record-conformance.mjs` records them from this
6
+ * core and a core test replays them.
7
+ *
8
+ * Results are compared after {@link normalizeConformanceValue}: ids an implementation generates
9
+ * (from a clock, say) become `@1`, `@2`, … in the order they are first seen, and binary payloads
10
+ * become `"<binary>"`, so neither the id format nor the PNG encoder is part of the contract. Ids
11
+ * that come from counters or names (`s1`, `pad1`, `ink-pen`) stay as they are. Every implementation applies the same rules (written
12
+ * out in the package README).
13
+ */
14
+ import type { McpToolResult } from './registry.js';
15
+ /** One tool call of a scenario and, once recorded, what it returned. */
16
+ export interface ConformanceStep {
17
+ /** Tool name, unprefixed (`paint_stroke`). */
18
+ tool: string;
19
+ /** Arguments. A string `@n` is replaced by the id that `@n` stands for. */
20
+ arguments?: Record<string, unknown>;
21
+ /** Also compare the saved document after this call. */
22
+ document?: boolean;
23
+ /**
24
+ * Also compare the rendered pixels after this call (`paper: false` leaves the paper out;
25
+ * `tolerance` loosens what another implementation is held to for this snapshot).
26
+ */
27
+ pixels?: boolean | {
28
+ paper?: boolean;
29
+ tolerance?: {
30
+ maxDelta?: number;
31
+ meanDelta?: number;
32
+ };
33
+ };
34
+ /**
35
+ * Parts of the result left out of the comparison because they describe this implementation
36
+ * rather than the contract (the wet engine's backend): JSON pointers like `/backend`, where a
37
+ * `*` segment stands for every array element or object member.
38
+ */
39
+ ignore?: string[];
40
+ /** Recorded results, written by the recorder. */
41
+ expect?: ConformanceExpectation;
42
+ }
43
+ export interface ConformanceExpectation {
44
+ /** The normalized `structuredContent`, or `{ error: { code, message, details? } }` for a failed call. */
45
+ result: unknown;
46
+ isError?: true;
47
+ document?: unknown;
48
+ pixels?: ConformancePixels;
49
+ }
50
+ /** A pixel snapshot: the PNG next to the scenario plus how close another implementation must be. */
51
+ export interface ConformancePixels {
52
+ /** PNG file, relative to the scenario file. */
53
+ file: string;
54
+ width: number;
55
+ height: number;
56
+ /** SHA-256 of the RGBA bytes, hex. This core matches it exactly. */
57
+ sha256: string;
58
+ /** Largest difference allowed in any channel of any pixel (0–255). */
59
+ maxDelta: number;
60
+ /** Largest mean difference allowed over all channels. */
61
+ meanDelta: number;
62
+ }
63
+ export interface ConformanceScenario {
64
+ $schema?: string;
65
+ /** Unique kebab-case id within the domain; also the file name. */
66
+ id: string;
67
+ description: string;
68
+ /** Host options, such as the canvas size. */
69
+ setup?: Record<string, unknown>;
70
+ steps: ConformanceStep[];
71
+ /** Reasons an implementation skips the whole scenario, by implementation (`swift`). */
72
+ skip?: Record<string, string>;
73
+ }
74
+ /** What a scenario runs against: a tool registry plus ways to read the document and pixels. */
75
+ export interface ConformanceHost {
76
+ callTool(name: string, args: Record<string, unknown>): Promise<McpToolResult>;
77
+ document(): unknown;
78
+ pixels(options: {
79
+ paper?: boolean;
80
+ }): {
81
+ width: number;
82
+ height: number;
83
+ data: Uint8Array | Uint8ClampedArray;
84
+ };
85
+ /** Whether a string is an id this implementation generated in its own format (default: none). */
86
+ isGeneratedId?(value: string): boolean;
87
+ dispose?(): void;
88
+ }
89
+ /** What one step produced, normalized (pixels are raw RGBA for the caller to hash or encode). */
90
+ export interface ConformanceStepOutput {
91
+ result: unknown;
92
+ isError: boolean;
93
+ document?: unknown;
94
+ pixels?: {
95
+ width: number;
96
+ height: number;
97
+ data: Uint8Array;
98
+ };
99
+ }
100
+ /** Maps real ids to `@n` placeholders and back, across a whole scenario. */
101
+ export declare class ConformanceIds {
102
+ private readonly toPlaceholder;
103
+ private readonly fromPlaceholder;
104
+ /** The placeholder for `id`, assigning the next one when it is new. */
105
+ add(id: string): string;
106
+ placeholder(id: string): string | undefined;
107
+ /** `text` with every known id in it replaced by its placeholder (longest ids first). */
108
+ replaceWithin(text: string): string;
109
+ /** The real id a placeholder stands for. */
110
+ resolve(placeholder: string): string | undefined;
111
+ }
112
+ /** Strings at least this long made only of base64 characters are binary payloads. */
113
+ export declare const CONFORMANCE_BINARY_MIN_LENGTH = 200;
114
+ type IdTest = (value: string) => boolean;
115
+ /**
116
+ * A value as scenarios store it:
117
+ * 1. Every string value or object key the implementation generated as an id (`isGeneratedId`) is
118
+ * numbered `@1`, `@2`, … in the order first seen, walking depth first with object keys sorted
119
+ * (each key before its value); the numbering carries across the scenario's steps.
120
+ * 2. Every known id in a string or object key, whole or inside a longer string (an error message),
121
+ * becomes its placeholder; longer ids are replaced first.
122
+ * 3. A string starting with `data:`, or at least 200 characters of base64, becomes `"<binary>"`.
123
+ * 4. An ISO 8601 date-time string (`2026-11-04T01:06:52.993Z`) becomes `"<time>"`: clocks differ.
124
+ * 5. Object keys are sorted; `undefined` values are dropped (as JSON would).
125
+ */
126
+ export declare function normalizeConformanceValue(value: unknown, ids: ConformanceIds, isGeneratedId?: IdTest): unknown;
127
+ /** `value` without the members at `paths` (`/a/b`, `*` for every element or member). */
128
+ export declare function omitConformancePaths(value: unknown, paths: readonly string[]): unknown;
129
+ /** Arguments with each `@n` placeholder string replaced by the id it stands for. */
130
+ export declare function resolveConformanceArguments(value: unknown, ids: ConformanceIds): unknown;
131
+ /** The comparable part of a tool result: `structuredContent`, or the error's code, message and details. */
132
+ export declare function conformanceResult(result: McpToolResult): {
133
+ value: unknown;
134
+ isError: boolean;
135
+ };
136
+ /** Runs a scenario's calls in order against a fresh host and returns what each step produced. */
137
+ export declare function runConformanceScenario(scenario: ConformanceScenario, host: ConformanceHost): Promise<ConformanceStepOutput[]>;
138
+ export {};