@pixerate/editor 0.277.0 → 0.279.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.
@@ -368,7 +368,10 @@ import {
368
368
  wheelToHue
369
369
  } from "../chunk-2EUW2XZX.js";
370
370
  import "../chunk-E2V5DA3W.js";
371
- import "../chunk-VR6OGHU5.js";
371
+ import {
372
+ isDualColor,
373
+ normalizeDualColor
374
+ } from "../chunk-VR6OGHU5.js";
372
375
  import "../chunk-JC2SHA4A.js";
373
376
  import {
374
377
  linearToSrgb,
@@ -4172,6 +4175,8 @@ function normalizeBrushStroke(raw) {
4172
4175
  points,
4173
4176
  seed: finite2(r.seed) ? r.seed | 0 : 0
4174
4177
  };
4178
+ const dual = r.dualColor === void 0 ? null : normalizeDualColor(r.dualColor);
4179
+ if (dual && /^#[0-9a-f]{6}$/.test(dual.light) && /^#[0-9a-f]{6}$/.test(dual.dark)) out.dualColor = dual;
4175
4180
  const copies = Array.isArray(r.copies) ? r.copies.map(readAffine).filter((m) => m !== null) : [];
4176
4181
  if (copies.length) out.copies = copies;
4177
4182
  const load = readLoad(r.load);
@@ -4380,9 +4385,77 @@ function serializePaintDocument(state, options = {}) {
4380
4385
  const { layers, assets } = serializeLayers(state.layers);
4381
4386
  return { ...header(state, options), layers, assets };
4382
4387
  }
4388
+ var NO_EXTENSION_PNGS = /* @__PURE__ */ new Set(["format", "version", "width", "height", "dpi", "paper", "layers", "assets", "activeLayerId", "strokes", "wetEvents", "paintEvents", "origin"]);
4389
+ var EXTENSION_ASSET_REF = "asset:";
4390
+ var PNG_DATA_URL = "data:image/png;base64,";
4391
+ var storedPngs = /* @__PURE__ */ new WeakMap();
4392
+ function fnv1a(text) {
4393
+ let h = 2166136261;
4394
+ for (let i = 0; i < text.length; i++) {
4395
+ h ^= text.charCodeAt(i);
4396
+ h = Math.imul(h, 16777619);
4397
+ }
4398
+ return (h >>> 0).toString(16).padStart(8, "0");
4399
+ }
4400
+ async function externalizePngs(value, assets, store) {
4401
+ if (typeof value === "string") {
4402
+ if (!value.startsWith(PNG_DATA_URL)) return value;
4403
+ const id = `ext-${fnv1a(value)}-${value.length.toString(36)}`;
4404
+ if (!assets[id]) {
4405
+ let refs = storedPngs.get(store);
4406
+ if (!refs) storedPngs.set(store, refs = /* @__PURE__ */ new Map());
4407
+ let ref2 = refs.get(value);
4408
+ if (!ref2) {
4409
+ const parsed = parseDataUrl(value);
4410
+ if (!parsed) return value;
4411
+ const b = parsed.bytes;
4412
+ const u32 = (o) => (b[o] << 24 | b[o + 1] << 16 | b[o + 2] << 8 | b[o + 3]) >>> 0;
4413
+ ref2 = await store.put(new Blob([b], { type: "image/png" }), { id, width: b.length >= 24 ? u32(16) : 0, height: b.length >= 24 ? u32(20) : 0 });
4414
+ if (typeof ref2 !== "string" || !ref2) throw new Error(`The asset store returned no reference for "${id}".`);
4415
+ refs.set(value, ref2);
4416
+ }
4417
+ assets[id] = ref2;
4418
+ }
4419
+ return `${EXTENSION_ASSET_REF}${id}`;
4420
+ }
4421
+ if (Array.isArray(value)) {
4422
+ const out = [];
4423
+ for (const v of value) out.push(await externalizePngs(v, assets, store));
4424
+ return out;
4425
+ }
4426
+ if (value && typeof value === "object") {
4427
+ const out = {};
4428
+ for (const [k, v] of Object.entries(value)) out[k] = await externalizePngs(v, assets, store);
4429
+ return out;
4430
+ }
4431
+ return value;
4432
+ }
4433
+ function inlinePngs(value, assets, problems, field, used) {
4434
+ if (typeof value === "string") {
4435
+ if (!value.startsWith(EXTENSION_ASSET_REF)) return value;
4436
+ const id = value.slice(EXTENSION_ASSET_REF.length);
4437
+ const url = assets[id];
4438
+ if (typeof url === "string" && url.startsWith("data:")) {
4439
+ used.add(id);
4440
+ return url;
4441
+ }
4442
+ const msg = `"${field}" refers to asset "${id}", which could not be read; that part loads empty.`;
4443
+ if (!problems.includes(msg)) problems.push(msg);
4444
+ return value;
4445
+ }
4446
+ if (Array.isArray(value)) return value.map((v) => inlinePngs(v, assets, problems, field, used));
4447
+ if (value && typeof value === "object") {
4448
+ const out = {};
4449
+ for (const [k, v] of Object.entries(value)) out[k] = inlinePngs(v, assets, problems, field, used);
4450
+ return out;
4451
+ }
4452
+ return value;
4453
+ }
4383
4454
  async function serializePaintDocumentExternal(state, store, options = {}) {
4384
4455
  const { layers, assets } = await serializeLayersExternal(state.layers, store);
4385
- return { ...header(state, options), layers, assets };
4456
+ const out = { ...header(state, options), layers, assets };
4457
+ for (const key of Object.keys(out)) if (!NO_EXTENSION_PNGS.has(key)) out[key] = await externalizePngs(out[key], assets, store);
4458
+ return out;
4386
4459
  }
4387
4460
  function paintLayersOnly(layers, problems) {
4388
4461
  const out = [];
@@ -4415,9 +4488,15 @@ function deserializePaintDocument(raw) {
4415
4488
  throw new Error(`Paint document version ${JSON.stringify(r.version)} is newer than this editor reads (${PAINT_SCHEMA_VERSION}); update @pixerate/editor.`);
4416
4489
  }
4417
4490
  const problems = [];
4491
+ const rawAssets = r.assets && typeof r.assets === "object" ? r.assets : {};
4492
+ const used = /* @__PURE__ */ new Set();
4493
+ const inlined = { ...r };
4494
+ for (const key of Object.keys(r)) if (!NO_EXTENSION_PNGS.has(key)) inlined[key] = inlinePngs(r[key], rawAssets, problems, key, used);
4495
+ return readDocument(inlined, Object.fromEntries(Object.entries(rawAssets).filter(([id]) => !used.has(id))), problems);
4496
+ }
4497
+ function readDocument(r, assets, problems) {
4418
4498
  const canvas = normalizeCanvas({ width: r.width, height: r.height, dpi: r.dpi ?? void 0 });
4419
4499
  const paper = normalizePaper(r.paper ?? {});
4420
- const assets = r.assets && typeof r.assets === "object" ? r.assets : {};
4421
4500
  const layers = paintLayersOnly(deserializeLayers(r.layers, { assets, problems }), problems);
4422
4501
  const wanted = typeof r.activeLayerId === "string" ? r.activeLayerId : null;
4423
4502
  const activeLayerId = wanted && findLayer(layers, wanted) ? wanted : flattenLayers(layers).filter((l) => l.type === "pixel").pop()?.id ?? null;
@@ -9025,7 +9104,8 @@ var PaintEditorController = class _PaintEditorController {
9025
9104
  brushColour = null;
9026
9105
  /**
9027
9106
  * The host's colour scheme: a paint colour that came from a brush's light / dark default
9028
- * resolves to the new side. Editor state, not an undo step.
9107
+ * resolves to the new side, and stroke layers show their dual-colour strokes in it. Only the
9108
+ * view changes: the document, history and change listeners don't hear of it.
9029
9109
  */
9030
9110
  setColorScheme(scheme) {
9031
9111
  const next = scheme === "dark" ? "dark" : "light";
@@ -9033,9 +9113,77 @@ var PaintEditorController = class _PaintEditorController {
9033
9113
  this.colorScheme = next;
9034
9114
  const hex2 = this.brushColour ? brushColourHex(this.brushColour, next) : null;
9035
9115
  if (hex2) this.color = hex2;
9116
+ this.liveLayers = null;
9036
9117
  const refs = brushStickerRefs(this.brush);
9037
9118
  if (refs.some((r) => !editorStampOf(r))) void this.prepareStickers(refs).catch(() => this.emit([]));
9038
- this.emit([]);
9119
+ this.emit();
9120
+ }
9121
+ /**
9122
+ * Stroke layers in the colour scheme: the document keeps every stroke layer drawn with each
9123
+ * stroke's `color` (a dual-colour stroke's light side), so saved and shared documents never
9124
+ * depend on who is looking. The view (display, image exports, the base a pointer stroke paints
9125
+ * on) draws dual-colour strokes in the scheme's side instead. One cached surface per layer,
9126
+ * brought up to date from the strokes that changed since it was drawn.
9127
+ */
9128
+ schemeViews = /* @__PURE__ */ new Map();
9129
+ schemeCopies = /* @__PURE__ */ new WeakMap();
9130
+ /** Set while a stroke is painted for the document rather than the view (`runStroke` on a stroke layer): dual colours take this side. */
9131
+ paintScheme = null;
9132
+ /** A stroke as the scheme shows it: itself, or (a dual-colour stroke) a copy in the scheme's side. */
9133
+ schemeStroke(st, scheme) {
9134
+ const want = st.dualColor?.[scheme];
9135
+ if (!want || want === st.color) return st;
9136
+ const had = this.schemeCopies.get(st);
9137
+ if (had?.scheme === scheme) return had.stroke;
9138
+ const out = { ...st, color: want };
9139
+ if (st.load) {
9140
+ const load = brushLoadForStroke(st.brush, EMPTY_MIXER_LOAD, hexToRgb8(want).map((v) => v / 255));
9141
+ if (load.reservoir || load.pickup) out.load = load;
9142
+ else delete out.load;
9143
+ }
9144
+ this.schemeCopies.set(st, { scheme, stroke: out });
9145
+ return out;
9146
+ }
9147
+ /** A stroke layer's surface in the scheme, or `null` when the document's pixels already show it. */
9148
+ schemeSurface(layer, list) {
9149
+ const scheme = this.colorScheme;
9150
+ if (!list.some((st) => st.dualColor && st.dualColor[scheme] !== st.color)) {
9151
+ this.schemeViews.delete(layer.id);
9152
+ return null;
9153
+ }
9154
+ const had = this.schemeViews.get(layer.id);
9155
+ if (had && had.scheme === scheme && had.list === list && had.base === layer.surface) return had.surface;
9156
+ const view = list.map((st) => this.schemeStroke(st, scheme));
9157
+ let surface;
9158
+ if (had && had.scheme === scheme && had.list !== list && !layer.offset.x && !layer.offset.y) {
9159
+ const now = new Set(list);
9160
+ const before = new Set(had.list);
9161
+ const gone = had.list.filter((st) => !now.has(st));
9162
+ const kept = list.filter((st) => before.has(st));
9163
+ const sameOrder = kept.every((st, i) => st === had.list.filter((x) => now.has(x))[i]);
9164
+ surface = sameOrder ? this.drawLiveStrokes({ ...layer, surface: had.surface }, view, this.strokeAreas(gone.map((st) => this.schemeStroke(st, scheme)))) : this.drawLiveStrokes(layer, view);
9165
+ } else surface = this.drawLiveStrokes(layer, view);
9166
+ this.schemeViews.set(layer.id, { scheme, list, base: layer.surface, surface });
9167
+ return surface;
9168
+ }
9169
+ /** `layers` with each stroke layer that holds dual-colour strokes shown in the scheme. */
9170
+ schemeLayers(layers) {
9171
+ const live = this.doc.liveStrokes;
9172
+ if (!live) return layers;
9173
+ let out = layers;
9174
+ for (const [id, list] of Object.entries(live)) {
9175
+ const l = findLayer(out, id)?.layer;
9176
+ if (!isPixelLayer(l)) continue;
9177
+ const surface = this.schemeSurface(l, list);
9178
+ if (surface) out = mapLayer(out, id, () => ({ ...l, surface }));
9179
+ }
9180
+ return out;
9181
+ }
9182
+ /** The paint colour's light / dark pair for a stroke with `brushId`, when the colour came from one (a brush's own or default colour, or `setColor`). */
9183
+ dualPaint(brushId) {
9184
+ const spec = brushId !== void 0 ? this.findEntry(brushId)?.spec : this.brushSpec;
9185
+ if (spec?.colour?.own && spec.colour.default !== void 0) return dualHex(spec.colour.default);
9186
+ return this.brushColour ? dualHex(this.brushColour) : null;
9039
9187
  }
9040
9188
  /** Locks the paint colour: choosing a brush with a default colour keeps it. Editor state, not an undo step. */
9041
9189
  setColorLocked(locked) {
@@ -9065,10 +9213,16 @@ var PaintEditorController = class _PaintEditorController {
9065
9213
  const d = spec?.colour?.own ? spec.colour.default : void 0;
9066
9214
  return d !== void 0 ? brushColourHex(d, this.colorScheme) : null;
9067
9215
  }
9068
- /** Sets the paint colour (and goes back to one colour from gradient paint). */
9216
+ /**
9217
+ * Sets the paint colour (and goes back to one colour from gradient paint). A light / dark pair
9218
+ * paints the colour scheme's side, follows `setColorScheme`, and is recorded with each stroke
9219
+ * painted in it. Returns the colour as #rrggbb.
9220
+ */
9069
9221
  setColor(color) {
9070
- this.color = parseColor(color);
9071
- this.brushColour = null;
9222
+ const dual = typeof color === "string" ? null : dualHex(color);
9223
+ if (typeof color !== "string" && !dual) throw new PaintEditorError(`color ${JSON.stringify(color)} is not a colour; use #rrggbb, or { light, dark } with a colour each.`);
9224
+ this.color = dual ? dual[this.colorScheme] : parseColor(color);
9225
+ this.brushColour = dual;
9072
9226
  this.gradient = null;
9073
9227
  this.effectPaint = this.effectPaintPending = null;
9074
9228
  this.paintEpoch++;
@@ -9197,11 +9351,20 @@ var PaintEditorController = class _PaintEditorController {
9197
9351
  strokeCopies() {
9198
9352
  return symmetryCopies(this.guides.symmetry);
9199
9353
  }
9200
- sessionFor(doc, init, host, points) {
9354
+ sessionFor(doc, given, host, points) {
9355
+ let dualColor = null;
9356
+ if (given.color !== void 0 && typeof given.color !== "string") {
9357
+ dualColor = dualHex(given.color);
9358
+ if (!dualColor) throw new PaintEditorError(`color ${JSON.stringify(given.color)} is not a colour; use #rrggbb, or { light, dark } with a colour each.`);
9359
+ }
9360
+ const scheme = this.paintScheme ?? this.colorScheme;
9361
+ let init = dualColor ? { ...given, color: dualColor[scheme] } : given;
9201
9362
  if (init.paint !== void 0 && !isPaintEffectPaint(init.paint)) {
9202
9363
  const p = this.readStrokePaint(init.paint);
9203
- if (p.type === "color") init = { ...init, color: p.color, paint: void 0 };
9204
- else init = { ...init, paint: p };
9364
+ if (p.type === "color") {
9365
+ init = { ...init, color: p.color, paint: void 0 };
9366
+ dualColor = null;
9367
+ } else init = { ...init, paint: p };
9205
9368
  }
9206
9369
  let layer = (() => {
9207
9370
  const id = init.layerId ?? doc.activeLayerId;
@@ -9341,7 +9504,12 @@ var PaintEditorController = class _PaintEditorController {
9341
9504
  }
9342
9505
  let color = init.color === void 0 ? this.ownColour(init.brushId) ?? this.color : parseColor(init.color);
9343
9506
  const tracing = this.guides.tracing;
9344
- if (init.color === void 0 && init.start && tracing?.visible && tracing.sampleColors) color = this.sampleTracingColor(init.start.x, init.start.y) ?? color;
9507
+ const traced = init.color === void 0 && init.start && tracing?.visible && tracing.sampleColors ? this.sampleTracingColor(init.start.x, init.start.y) : null;
9508
+ if (traced) color = traced;
9509
+ else if (init.color === void 0) {
9510
+ dualColor = this.dualPaint(init.brushId);
9511
+ if (dualColor) color = dualColor[scheme];
9512
+ }
9345
9513
  const perspective = this.guides.perspective;
9346
9514
  const snapper = init.snapToPerspective && perspective.enabled && perspective.snap ? new PerspectiveSnapper(perspective) : null;
9347
9515
  const useStencils = init.stencils !== false;
@@ -9357,6 +9525,7 @@ var PaintEditorController = class _PaintEditorController {
9357
9525
  const copies = (init.copies ?? this.strokeCopies()).filter((m) => m.some((v, i) => v !== IDENTITY_AFFINE[i]));
9358
9526
  const hasThick = Boolean(doc.thick?.[layer.id]);
9359
9527
  const needThick = brush.engine === "impasto" || Boolean(brush.iridescence) || brush.engine === "eraser" && hasThick || Boolean(wet?.driver.init.film);
9528
+ const gradient = effect ? null : this.strokeGradient(init, brush, color);
9360
9529
  const session = new PaintStrokeSession(host, {
9361
9530
  layerId: layer.id,
9362
9531
  surface: layer.surface,
@@ -9369,6 +9538,8 @@ var PaintEditorController = class _PaintEditorController {
9369
9538
  paperDepth: doc.paper.depth,
9370
9539
  seed,
9371
9540
  colorHex: color,
9541
+ // Only a stroke that paints its one colour keeps the pair (gradient and effect paint have their own colours).
9542
+ ...dualColor && !gradient && !effect && dualColor[scheme] === color ? { dualColor } : {},
9372
9543
  affineCopies: copies,
9373
9544
  copies: [IDENTITY_AFFINE, ...copies].map(affineCopy),
9374
9545
  inputFilter,
@@ -9385,7 +9556,7 @@ var PaintEditorController = class _PaintEditorController {
9385
9556
  thermo: brush.engine !== "sticker" && (brush.thermochromic.hotColor !== null || doc.thermo?.[layer.id]) ? { surface: doc.thermo?.[layer.id] ?? emptyThermo() } : null,
9386
9557
  sticker: brush.engine === "sticker" ? { nextId: this.nextStickerNumber(doc), art: this.stickerArt.get, variant: (ref2) => this.stickerArt.variantFor(ref2, this.colorScheme === "dark") } : void 0,
9387
9558
  stampArt: this.stampArt,
9388
- paint: effect ? null : this.strokeGradient(init, brush, color),
9559
+ paint: gradient,
9389
9560
  effect
9390
9561
  });
9391
9562
  if (wet) this.wet.driver = wet.driver;
@@ -9402,6 +9573,10 @@ var PaintEditorController = class _PaintEditorController {
9402
9573
  points: session.points.map((p) => ({ ...p })),
9403
9574
  seed: init.seed
9404
9575
  };
9576
+ if (init.dualColor) {
9577
+ stroke.dualColor = { ...init.dualColor };
9578
+ if (this.doc.liveStrokes?.[session.layerId]) stroke.color = init.dualColor.light;
9579
+ }
9405
9580
  if (init.affineCopies.length) stroke.copies = init.affineCopies.map((m) => [...m]);
9406
9581
  if (init.effect) stroke.paint = JSON.parse(JSON.stringify(init.effect.source));
9407
9582
  else if (init.paint) stroke.paint = JSON.parse(JSON.stringify(init.paint));
@@ -9510,34 +9685,43 @@ var PaintEditorController = class _PaintEditorController {
9510
9685
  const ferroSettings = fc ? withLayerFerroSettings(this.doc.ferroSettings, ferro, l.id, brush.ferro.amount > 0 ? brush.ferro : this.doc.ferroSettings?.[l.id] ?? DEFAULT_FERRO) : this.doc.ferroSettings;
9511
9686
  const fluor = withLayerLight(this.doc.fluor, l.id, s.fluorChannel());
9512
9687
  const thermo = withLayerLight(this.doc.thermo, l.id, s.thermoChannel());
9513
- this.commit(
9514
- `${brush.name} stroke`,
9515
- this.withWetEvents(
9516
- withLiveStroke(
9517
- {
9518
- ...this.doc,
9519
- layers: mapLayer(this.doc.layers, l.id, () => ({ ...l, surface })),
9520
- strokes: [...this.doc.strokes, stroke],
9521
- ...thick ? { thick } : {},
9522
- ...stickers ? { stickers } : {},
9523
- ...light ? { light } : {},
9524
- ...fluor ? { fluor } : {},
9525
- ...thermo ? { thermo } : {},
9526
- ...ferro ? { ferro } : { ferro: void 0 },
9527
- ...ferroSettings ? { ferroSettings } : { ferroSettings: void 0 }
9528
- },
9529
- stroke
9530
- )
9531
- ),
9532
- before
9688
+ let next = this.withWetEvents(
9689
+ withLiveStroke(
9690
+ {
9691
+ ...this.doc,
9692
+ layers: mapLayer(this.doc.layers, l.id, () => ({ ...l, surface })),
9693
+ strokes: [...this.doc.strokes, stroke],
9694
+ ...thick ? { thick } : {},
9695
+ ...stickers ? { stickers } : {},
9696
+ ...light ? { light } : {},
9697
+ ...fluor ? { fluor } : {},
9698
+ ...thermo ? { thermo } : {},
9699
+ ...ferro ? { ferro } : { ferro: void 0 },
9700
+ ...ferroSettings ? { ferroSettings } : { ferroSettings: void 0 }
9701
+ },
9702
+ stroke
9703
+ )
9533
9704
  );
9705
+ const list = next.liveStrokes?.[l.id];
9706
+ if (list && (viewBase || stroke.color !== s.init.colorHex)) {
9707
+ const canonical = this.withLiveStrokes({ ...next, layers: mapLayer(next.layers, l.id, () => l) }, l, list, s.result?.bounds ? [s.result.bounds] : null, { record: false });
9708
+ const drawn = findLayer(canonical.layers, l.id)?.layer;
9709
+ this.schemeViews.set(l.id, { scheme: viewScheme, list, base: drawn.surface, surface });
9710
+ next = canonical;
9711
+ }
9712
+ this.commit(`${brush.name} stroke`, next, before);
9534
9713
  this.recordMutationStroke(stroke, s.result?.bounds ?? null);
9535
9714
  this.notifySample("end", s, [], stroke.id);
9536
9715
  }
9537
9716
  };
9717
+ const targetId = init.layerId ?? this.doc.activeLayerId;
9718
+ const target = targetId ? findLayer(this.doc.layers, targetId)?.layer : void 0;
9719
+ const targetStrokes = isPixelLayer(target) ? this.doc.liveStrokes?.[target.id] : void 0;
9720
+ const viewBase = targetStrokes && isPixelLayer(target) ? this.schemeSurface(target, targetStrokes) : null;
9721
+ const viewScheme = this.colorScheme;
9538
9722
  let session;
9539
9723
  try {
9540
- session = this.sessionFor(this.doc, { snapToPerspective: true, ...init }, host);
9724
+ session = this.sessionFor(viewBase && target ? { ...this.doc, layers: mapLayer(this.doc.layers, target.id, (x) => ({ ...x, surface: viewBase })) } : this.doc, { snapToPerspective: true, ...init }, host);
9541
9725
  } catch (e) {
9542
9726
  this.wetOp = null;
9543
9727
  throw e;
@@ -9614,7 +9798,7 @@ var PaintEditorController = class _PaintEditorController {
9614
9798
  this.liveThermo = null;
9615
9799
  const masks = s.init.selection ? (s.maskStencils ?? this.guides.stencils).filter((x) => x.enabled && x.mode === "mask") : [];
9616
9800
  const load = s.init.mixerLoad;
9617
- const result = this.paintStroke({ layerId: s.layerId, points: s.points, color: s.init.colorHex, brush: brushToJSON(s.init.brush), seed: s.init.seed, copies: s.init.affineCopies, ...load.reservoir || load.pickup ? { load } : {}, paint, snapToPerspective: false, stencils: false, maskStencils: masks });
9801
+ const result = this.paintStroke({ layerId: s.layerId, points: s.points, color: s.init.dualColor ?? s.init.colorHex, brush: brushToJSON(s.init.brush), seed: s.init.seed, copies: s.init.affineCopies, ...load.reservoir || load.pickup ? { load } : {}, paint, snapToPerspective: false, stencils: false, maskStencils: masks });
9618
9802
  Object.assign(s.result, result);
9619
9803
  this.notifySample("end", s, [], result.strokeId);
9620
9804
  };
@@ -9698,7 +9882,14 @@ var PaintEditorController = class _PaintEditorController {
9698
9882
  }, stroke);
9699
9883
  }
9700
9884
  };
9701
- const session = this.sessionFor(doc, init, host, points);
9885
+ const target = init.layerId ?? doc.activeLayerId;
9886
+ this.paintScheme = target && doc.liveStrokes?.[target] ? "light" : null;
9887
+ let session;
9888
+ try {
9889
+ session = this.sessionFor(doc, init, host, points);
9890
+ } finally {
9891
+ this.paintScheme = null;
9892
+ }
9702
9893
  session.push(points);
9703
9894
  const result = session.end();
9704
9895
  if (session.init.wet) {
@@ -10669,9 +10860,9 @@ var PaintEditorController = class _PaintEditorController {
10669
10860
  const override = this.frameOverride;
10670
10861
  if (override) return Array.from(override).reduce((ls, [id, surface]) => mapLayer(ls, id, (l) => ({ ...l, surface })), this.doc.layers);
10671
10862
  const live = this.live;
10672
- if (!live) return this.animatedView();
10863
+ if (!live) return this.schemeLayers(this.animatedView());
10673
10864
  if (this.liveLayers?.key === live.surface) return this.liveLayers.layers;
10674
- const layers = mapLayer(this.doc.layers, live.layerId, (l) => ({ ...l, surface: live.surface }));
10865
+ const layers = mapLayer(this.schemeLayers(this.doc.layers), live.layerId, (l) => ({ ...l, surface: live.surface }));
10675
10866
  this.liveLayers = { key: live.surface, layers };
10676
10867
  return layers;
10677
10868
  }
@@ -11226,6 +11417,7 @@ var PaintEditorController = class _PaintEditorController {
11226
11417
  delete this.extensions.heat;
11227
11418
  delete this.extensions.uvLight;
11228
11419
  this.viewCompositor.clear();
11420
+ this.schemeViews.clear();
11229
11421
  this.emit();
11230
11422
  const refs = Object.values(this.doc.stickers ?? {}).flatMap((list) => list.map((x) => x.ref));
11231
11423
  if (refs.some((r) => !editorStampOf(r))) void this.prepareStickers(refs).catch(() => this.emit([]));
@@ -11284,7 +11476,7 @@ var PaintEditorController = class _PaintEditorController {
11284
11476
  * pixels), and the composite as the merged image.
11285
11477
  */
11286
11478
  exportPsd() {
11287
- const driedBase = this.driedLayers(this.doc.layers);
11479
+ const driedBase = this.driedLayers(this.schemeLayers(this.doc.layers));
11288
11480
  const backlight = this.backlightLayer(driedBase);
11289
11481
  const dried = [...this.lightLayers(this.uvLayers(this.litLayers(this.thermoLayers(driedBase)))), ...backlight ? [backlight] : []];
11290
11482
  const { width, height, dpi } = this.doc.canvas;
@@ -11838,7 +12030,11 @@ var PaintEditorController = class _PaintEditorController {
11838
12030
  next.seed = update.seed;
11839
12031
  }
11840
12032
  if (update.color !== void 0) {
11841
- next.color = parseColor(update.color);
12033
+ const dual = typeof update.color === "string" ? null : dualHex(update.color);
12034
+ if (typeof update.color !== "string" && !dual) throw new PaintEditorError(`color ${JSON.stringify(update.color)} is not a colour; use #rrggbb, or { light, dark } with a colour each.`);
12035
+ next.color = dual ? dual.light : parseColor(update.color);
12036
+ if (dual) next.dualColor = dual;
12037
+ else delete next.dualColor;
11842
12038
  delete next.paint;
11843
12039
  }
11844
12040
  if (update.paint !== void 0) {
@@ -11851,6 +12047,7 @@ var PaintEditorController = class _PaintEditorController {
11851
12047
  const p = this.readStrokePaint(update.paint);
11852
12048
  if (p.type === "color") {
11853
12049
  next.color = p.color;
12050
+ delete next.dualColor;
11854
12051
  delete next.paint;
11855
12052
  } else next.paint = JSON.parse(JSON.stringify(p));
11856
12053
  }
@@ -11966,8 +12163,13 @@ var PaintEditorController = class _PaintEditorController {
11966
12163
  commitLiveStrokes(label, layer, list, dirty) {
11967
12164
  this.commit(label, this.withLiveStrokes(this.doc, layer, list, dirty));
11968
12165
  }
11969
- withLiveStrokes(doc, layer, list, dirty) {
11970
- if (isLayerLocked(layer, "pixels")) throw new PaintEditorError(`Layer "${layer.name}" has its pixels locked; unlock it (update_layer locks) first.`);
12166
+ /**
12167
+ * The document with a layer's strokes set and its pixels redrawn. With `record` false (the same
12168
+ * strokes drawn for the colour scheme) the pixel lock doesn't apply and no event is recorded for replays.
12169
+ */
12170
+ withLiveStrokes(doc, layer, list, dirty, options = {}) {
12171
+ const record = options.record !== false;
12172
+ if (record && isLayerLocked(layer, "pixels")) throw new PaintEditorError(`Layer "${layer.name}" has its pixels locked; unlock it (update_layer locks) first.`);
11971
12173
  const current = findLayer(doc.layers, layer.id)?.layer;
11972
12174
  const hasFilm = list.some((s) => Boolean(s.brush.iridescence)) || Boolean(doc.thick?.[layer.id]);
11973
12175
  const surface = this.drawLiveStrokes(current ?? layer, list, hasFilm ? null : dirty);
@@ -11987,6 +12189,7 @@ var PaintEditorController = class _PaintEditorController {
11987
12189
  thick = withLayerThick(doc.thick, layer.id, ch);
11988
12190
  }
11989
12191
  const next = { ...doc, layers: mapLayer(doc.layers, layer.id, (l) => ({ ...l, surface, offset: { x: 0, y: 0 } })), liveStrokes: { ...doc.liveStrokes ?? {}, [layer.id]: [...list] }, ...thick ? { thick } : {} };
12192
+ if (!record) return next;
11990
12193
  return this.withPaintEvent(next, { kind: "live-strokes", layerId: layer.id, data: { strokes: list.map((x) => JSON.parse(JSON.stringify(x))) } });
11991
12194
  }
11992
12195
  /** Where a stroke painted (document pixels): as learnt when it was painted or drawn, else an estimate from its points and brush. */
@@ -13276,6 +13479,12 @@ function readWetGhost(raw) {
13276
13479
  const linger = typeof g.linger === "number" && Number.isFinite(g.linger) ? Math.max(0.5, Math.min(60, g.linger)) : 4;
13277
13480
  return { pigment: [g.pigment[0], g.pigment[1], g.pigment[2], g.pigment[3]], remaining, total, linger };
13278
13481
  }
13482
+ function dualHex(value) {
13483
+ if (!isDualColor(value)) return null;
13484
+ const light = brushColourHex(value.light, "light");
13485
+ const dark = brushColourHex(value.dark, "dark");
13486
+ return light && dark ? { light, dark } : null;
13487
+ }
13279
13488
 
13280
13489
  // src/paint-editor/presence.ts
13281
13490
  var PaintPresence = class extends PresenceStore {
@@ -15687,24 +15896,33 @@ function createPaintEditorToolProvider(controller, options = {}) {
15687
15896
  add({
15688
15897
  name: "set_color",
15689
15898
  title: "Set the paint colour",
15690
- description: "Sets the paint colour (and optionally the second colour) that strokes and fills use by default, and whether it is locked. Choosing a brush with a default colour (set_brush id, e.g. the highlighter's yellow) makes that the paint colour unless the colour is locked; a brush with colour.own always paints its own. Setting color goes back to one colour from gradient paint (set_paint) or effect paint (set_effect_paint). Editor state, not an undo step. Returns both colours and locked.",
15899
+ description: "Sets the paint colour (and optionally the second colour) that strokes and fills use by default, and whether it is locked. Choosing a brush with a default colour (set_brush id, e.g. the highlighter's yellow) makes that the paint colour unless the colour is locked; a brush with colour.own always paints its own. Setting color goes back to one colour from gradient paint (set_paint) or effect paint (set_effect_paint). dualColor sets a light / dark pair instead: strokes record the pair, and on a stroke layer each viewer sees them in the side of their own colour scheme (a pixel layer keeps the side painted). Editor state, not an undo step. Returns both colours and locked (and dualColor when given).",
15691
15900
  inputSchema: {
15692
15901
  type: "object",
15693
15902
  properties: {
15694
15903
  color: { ...COLOR3, description: "Paint colour #rrggbb." },
15904
+ dualColor: {
15905
+ type: "object",
15906
+ description: "A light / dark pair as the paint colour, instead of color: each side a colour #rrggbb.",
15907
+ properties: { light: { ...COLOR3, description: "The colour in the light scheme, #rrggbb." }, dark: { ...COLOR3, description: "The colour in the dark scheme, #rrggbb." } },
15908
+ required: ["light", "dark"],
15909
+ additionalProperties: false
15910
+ },
15695
15911
  secondaryColor: { ...COLOR3, description: "Second colour #rrggbb." },
15696
15912
  locked: { type: "boolean", description: "Lock the paint colour: brushes chosen later keep it instead of taking their default colour (default: unchanged; the editor starts unlocked)." }
15697
15913
  },
15698
15914
  additionalProperties: false
15699
15915
  },
15700
15916
  annotations: { title: "Set the paint colour", ...SET6 },
15701
- handler: ({ color, secondaryColor, locked }) => {
15702
- if (color === void 0 && secondaryColor === void 0 && locked === void 0) throw new Error("Pass color, secondaryColor or locked.");
15917
+ handler: ({ color, dualColor, secondaryColor, locked }) => {
15918
+ if (color === void 0 && dualColor === void 0 && secondaryColor === void 0 && locked === void 0) throw new Error("Pass color, dualColor, secondaryColor or locked.");
15919
+ if (color !== void 0 && dualColor !== void 0) throw new Error("Pass color or dualColor, not both.");
15703
15920
  if (color !== void 0) c.setColor(color);
15921
+ if (dualColor !== void 0) c.setColor(dualColor);
15704
15922
  if (secondaryColor !== void 0) c.setSecondaryColor(secondaryColor);
15705
15923
  if (locked !== void 0) c.setColorLocked(locked);
15706
15924
  const s = c.getState();
15707
- return { color: s.color, secondaryColor: s.secondaryColor, locked: s.colorLocked };
15925
+ return { color: s.color, secondaryColor: s.secondaryColor, locked: s.colorLocked, ...dualColor !== void 0 ? { dualColor: { light: dualColor.light.toLowerCase(), dark: dualColor.dark.toLowerCase() } } : {} };
15708
15926
  }
15709
15927
  });
15710
15928
  add({
@@ -17575,6 +17793,7 @@ export {
17575
17793
  EVOLUTION_BRUSH_FACETS,
17576
17794
  EVOLUTION_MUTATION_SCHEMA,
17577
17795
  EVOLUTION_SLIDERS,
17796
+ EXTENSION_ASSET_REF,
17578
17797
  FERRO_CARRIER_RGB,
17579
17798
  FERRO_DEFAULT_GLOSS,
17580
17799
  FERRO_DEFAULT_METALLIC,
@@ -59,7 +59,9 @@ export interface SerializeOptions {
59
59
  }
60
60
  /** The document as JSON with inline PNG data URLs. */
61
61
  export declare function serializePaintDocument(state: PaintDocumentState, options?: SerializeOptions): SerializedPaintDocument;
62
- /** The document as JSON with each PNG uploaded to `store` once (by content). */
62
+ /** How an extension field refers to a PNG in `assets`: `asset:<asset id>`. */
63
+ export declare const EXTENSION_ASSET_REF = "asset:";
64
+ /** The document as JSON with each PNG (layers and extension fields alike) uploaded to `store` once (by content). */
63
65
  export declare function serializePaintDocumentExternal(state: PaintDocumentState, store: PaintAssetStore, options?: SerializeOptions): Promise<SerializedPaintDocument>;
64
66
  export interface LoadedPaintDocument {
65
67
  state: PaintDocumentState;
@@ -59,7 +59,9 @@ export interface SerializeOptions {
59
59
  }
60
60
  /** The document as JSON with inline PNG data URLs. */
61
61
  export declare function serializePaintDocument(state: PaintDocumentState, options?: SerializeOptions): SerializedPaintDocument;
62
- /** The document as JSON with each PNG uploaded to `store` once (by content). */
62
+ /** How an extension field refers to a PNG in `assets`: `asset:<asset id>`. */
63
+ export declare const EXTENSION_ASSET_REF = "asset:";
64
+ /** The document as JSON with each PNG (layers and extension fields alike) uploaded to `store` once (by content). */
63
65
  export declare function serializePaintDocumentExternal(state: PaintDocumentState, store: PaintAssetStore, options?: SerializeOptions): Promise<SerializedPaintDocument>;
64
66
  export interface LoadedPaintDocument {
65
67
  state: PaintDocumentState;
@@ -22,6 +22,7 @@ import { type PaperGrain } from './paper.cjs';
22
22
  import { type Sticker } from './stickers.cjs';
23
23
  import type { StickerArtLookup } from './sticker-art.cjs';
24
24
  import type { StickerRef, StickerVariant } from '../stickers/types.cjs';
25
+ import type { DualColor } from '../color/dual.cjs';
25
26
  export interface StrokeSessionHost {
26
27
  /** Shows the layer's live surface (no history); `rects` are where it changed. */
27
28
  update(layerId: string, surface: TiledSurface, rects: RasterRect[]): void;
@@ -48,6 +49,8 @@ export interface StrokeSessionInit {
48
49
  seed: number;
49
50
  /** The paint colour as #rrggbb (recorded with the stroke). */
50
51
  colorHex: string;
52
+ /** The light / dark pair `colorHex` is one side of, when the colour came from one (recorded with the stroke). */
53
+ dualColor?: DualColor;
51
54
  /** The stroke's extra copies as affine maps (recorded with the stroke); `copies` holds all of them as engine copies. */
52
55
  affineCopies: readonly Affine[];
53
56
  copies: readonly StrokeCopy[];
@@ -22,6 +22,7 @@ import { type PaperGrain } from './paper.js';
22
22
  import { type Sticker } from './stickers.js';
23
23
  import type { StickerArtLookup } from './sticker-art.js';
24
24
  import type { StickerRef, StickerVariant } from '../stickers/types.js';
25
+ import type { DualColor } from '../color/dual.js';
25
26
  export interface StrokeSessionHost {
26
27
  /** Shows the layer's live surface (no history); `rects` are where it changed. */
27
28
  update(layerId: string, surface: TiledSurface, rects: RasterRect[]): void;
@@ -48,6 +49,8 @@ export interface StrokeSessionInit {
48
49
  seed: number;
49
50
  /** The paint colour as #rrggbb (recorded with the stroke). */
50
51
  colorHex: string;
52
+ /** The light / dark pair `colorHex` is one side of, when the colour came from one (recorded with the stroke). */
53
+ dualColor?: DualColor;
51
54
  /** The stroke's extra copies as affine maps (recorded with the stroke); `copies` holds all of them as engine copies. */
52
55
  affineCopies: readonly Affine[];
53
56
  copies: readonly StrokeCopy[];
@@ -14,6 +14,7 @@ import type { MixerLoad } from '../paint/exchange.cjs';
14
14
  import { type Stencil } from './stencils.cjs';
15
15
  import { type PaintGradientSource } from '../paint/gradient-paint.cjs';
16
16
  import { type PaintEffectPaint } from './effect-paint.cjs';
17
+ import { type DualColor } from '../color/dual.cjs';
17
18
  /** An affine map [a, b, c, d, e, f]: (x, y) → (a·x + b·y + e, c·x + d·y + f). */
18
19
  export type Affine = readonly [number, number, number, number, number, number];
19
20
  export declare const IDENTITY_AFFINE: Affine;
@@ -21,8 +22,17 @@ export interface RecordedStroke {
21
22
  id: string;
22
23
  layerId: string;
23
24
  brush: PaintBrush;
24
- /** #rrggbb */
25
+ /**
26
+ * #rrggbb: the colour the document draws the stroke with. A dual-colour stroke on a stroke layer
27
+ * keeps its light side here, whoever painted it; on a pixel layer, the side it was painted in.
28
+ */
25
29
  color: string;
30
+ /**
31
+ * The light / dark pair the colour came from (both #rrggbb), when it came from one. A stroke
32
+ * layer shows it in the viewer's side (`PaintEditorController.setColorScheme`) without changing
33
+ * the document; a pixel layer's pixels stay as painted.
34
+ */
35
+ dualColor?: DualColor;
26
36
  /** Raw pen samples in document pixels, before smoothing. */
27
37
  points: PenPoint[];
28
38
  seed: number;
@@ -14,6 +14,7 @@ import type { MixerLoad } from '../paint/exchange.js';
14
14
  import { type Stencil } from './stencils.js';
15
15
  import { type PaintGradientSource } from '../paint/gradient-paint.js';
16
16
  import { type PaintEffectPaint } from './effect-paint.js';
17
+ import { type DualColor } from '../color/dual.js';
17
18
  /** An affine map [a, b, c, d, e, f]: (x, y) → (a·x + b·y + e, c·x + d·y + f). */
18
19
  export type Affine = readonly [number, number, number, number, number, number];
19
20
  export declare const IDENTITY_AFFINE: Affine;
@@ -21,8 +22,17 @@ export interface RecordedStroke {
21
22
  id: string;
22
23
  layerId: string;
23
24
  brush: PaintBrush;
24
- /** #rrggbb */
25
+ /**
26
+ * #rrggbb: the colour the document draws the stroke with. A dual-colour stroke on a stroke layer
27
+ * keeps its light side here, whoever painted it; on a pixel layer, the side it was painted in.
28
+ */
25
29
  color: string;
30
+ /**
31
+ * The light / dark pair the colour came from (both #rrggbb), when it came from one. A stroke
32
+ * layer shows it in the viewer's side (`PaintEditorController.setColorScheme`) without changing
33
+ * the document; a pixel layer's pixels stay as painted.
34
+ */
35
+ dualColor?: DualColor;
26
36
  /** Raw pen samples in document pixels, before smoothing. */
27
37
  points: PenPoint[];
28
38
  seed: number;