@pixerate/editor 0.221.0 → 0.221.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2807,6 +2807,21 @@ function inspectMaskEdge(mask, image, options = {}) {
2807
2807
  blk.x1 = Math.max(blk.x1, x);
2808
2808
  blk.y1 = Math.max(blk.y1, y);
2809
2809
  };
2810
+ const antiAliased = (x, y) => {
2811
+ let full = false;
2812
+ let none = false;
2813
+ for (let dy = -2; dy <= 2; dy++) {
2814
+ for (let dx = -2; dx <= 2; dx++) {
2815
+ const nx = x + dx;
2816
+ const ny = y + dy;
2817
+ if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
2818
+ const n = a[ny * W + nx];
2819
+ if (n === 255) full = true;
2820
+ else if (n === 0) none = true;
2821
+ }
2822
+ }
2823
+ return full && none;
2824
+ };
2810
2825
  const x0 = Math.max(0, b.x - 1);
2811
2826
  const y0 = Math.max(0, b.y - 1);
2812
2827
  const x1 = Math.min(W, b.x + b.width + 1);
@@ -2836,7 +2851,7 @@ function inspectMaskEdge(mask, image, options = {}) {
2836
2851
  const partial = v > 0 && v < 255;
2837
2852
  if (hi && lo || partial) {
2838
2853
  band++;
2839
- if (partial) soft++;
2854
+ if (partial && !antiAliased(x, y)) soft++;
2840
2855
  }
2841
2856
  if (v < 128) continue;
2842
2857
  const inCanvasLow = (nx, ny) => nx >= 0 && ny >= 0 && nx < W && ny < H && a[ny * W + nx] < 128;
@@ -13784,7 +13799,7 @@ function createImageSelectionTools(controller) {
13784
13799
  const inspectEdge = {
13785
13800
  name: "inspect_mask_edge",
13786
13801
  title: "Inspect a mask edge",
13787
- description: "Measures the edge of the selection (default), a layer's painted mask (layer-mask) or its vector mask (vector-mask), so you can check it without looking. Returns haloScore 0-1 (background colour left just inside the edge; worse above 0.35), edgeAlignment 0-1 (share of the edge on strong image edges; worse below 0.5), jaggedness (outline turning in radians per 100 px beyond what closing it needs; smooth outlines score under 3, worse above 15), softFraction 0-1 (share of edge pixels partly selected), holes and islands (count, total area, the 10 largest with bounds), worst (up to 5 regions with a reason, ready for capture_view rect) and issues (findings with a suggested fix; empty when clean). Measures a pixel layer's masks against its own pixels, otherwise the composite. Read-only: inspect, fix the issues, inspect again.",
13802
+ description: "Measures the edge of the selection (default), a layer's painted mask (layer-mask) or its vector mask (vector-mask), so you can check it without looking. Returns haloScore 0-1 (background colour left just inside the edge; worse above 0.35), edgeAlignment 0-1 (share of the edge on strong image edges; worse below 0.5), jaggedness (outline turning in radians per 100 px beyond what closing it needs; smooth outlines score under 3, worse above 15), softFraction 0-1 (share of edge pixels in a transition wider than anti-aliasing), holes and islands (count, total area, the 10 largest with bounds), worst (up to 5 regions with a reason, ready for capture_view rect) and issues (findings with a suggested fix; empty when clean). Measures a pixel layer's masks against its own pixels, otherwise the composite. Read-only: inspect, fix the issues, inspect again.",
13788
13803
  inputSchema: {
13789
13804
  type: "object",
13790
13805
  properties: {
@@ -2807,6 +2807,21 @@ function inspectMaskEdge(mask, image, options = {}) {
2807
2807
  blk.x1 = Math.max(blk.x1, x);
2808
2808
  blk.y1 = Math.max(blk.y1, y);
2809
2809
  };
2810
+ const antiAliased = (x, y) => {
2811
+ let full = false;
2812
+ let none = false;
2813
+ for (let dy = -2; dy <= 2; dy++) {
2814
+ for (let dx = -2; dx <= 2; dx++) {
2815
+ const nx = x + dx;
2816
+ const ny = y + dy;
2817
+ if (nx < 0 || ny < 0 || nx >= W || ny >= H) continue;
2818
+ const n = a[ny * W + nx];
2819
+ if (n === 255) full = true;
2820
+ else if (n === 0) none = true;
2821
+ }
2822
+ }
2823
+ return full && none;
2824
+ };
2810
2825
  const x0 = Math.max(0, b.x - 1);
2811
2826
  const y0 = Math.max(0, b.y - 1);
2812
2827
  const x1 = Math.min(W, b.x + b.width + 1);
@@ -2836,7 +2851,7 @@ function inspectMaskEdge(mask, image, options = {}) {
2836
2851
  const partial = v > 0 && v < 255;
2837
2852
  if (hi && lo || partial) {
2838
2853
  band++;
2839
- if (partial) soft++;
2854
+ if (partial && !antiAliased(x, y)) soft++;
2840
2855
  }
2841
2856
  if (v < 128) continue;
2842
2857
  const inCanvasLow = (nx, ny) => nx >= 0 && ny >= 0 && nx < W && ny < H && a[ny * W + nx] < 128;
@@ -13784,7 +13799,7 @@ function createImageSelectionTools(controller) {
13784
13799
  const inspectEdge = {
13785
13800
  name: "inspect_mask_edge",
13786
13801
  title: "Inspect a mask edge",
13787
- description: "Measures the edge of the selection (default), a layer's painted mask (layer-mask) or its vector mask (vector-mask), so you can check it without looking. Returns haloScore 0-1 (background colour left just inside the edge; worse above 0.35), edgeAlignment 0-1 (share of the edge on strong image edges; worse below 0.5), jaggedness (outline turning in radians per 100 px beyond what closing it needs; smooth outlines score under 3, worse above 15), softFraction 0-1 (share of edge pixels partly selected), holes and islands (count, total area, the 10 largest with bounds), worst (up to 5 regions with a reason, ready for capture_view rect) and issues (findings with a suggested fix; empty when clean). Measures a pixel layer's masks against its own pixels, otherwise the composite. Read-only: inspect, fix the issues, inspect again.",
13802
+ description: "Measures the edge of the selection (default), a layer's painted mask (layer-mask) or its vector mask (vector-mask), so you can check it without looking. Returns haloScore 0-1 (background colour left just inside the edge; worse above 0.35), edgeAlignment 0-1 (share of the edge on strong image edges; worse below 0.5), jaggedness (outline turning in radians per 100 px beyond what closing it needs; smooth outlines score under 3, worse above 15), softFraction 0-1 (share of edge pixels in a transition wider than anti-aliasing), holes and islands (count, total area, the 10 largest with bounds), worst (up to 5 regions with a reason, ready for capture_view rect) and issues (findings with a suggested fix; empty when clean). Measures a pixel layer's masks against its own pixels, otherwise the composite. Read-only: inspect, fix the issues, inspect again.",
13788
13803
  inputSchema: {
13789
13804
  type: "object",
13790
13805
  properties: {
@@ -31,7 +31,7 @@ export interface MaskEdgeReport {
31
31
  edgeAlignment: number;
32
32
  /** Turning of the outline beyond what closing it needs, in radians per 100 px of outline (0: smooth). */
33
33
  jaggedness: number;
34
- /** 0–1: the share of edge pixels that are partly covered (0 < α < 1). */
34
+ /** 0–1: the share of edge pixels in a transition wider than anti-aliasing (partly covered, no fully selected and unselected pixel within 2 px). */
35
35
  softFraction: number;
36
36
  /** Unselected areas enclosed by the selection, largest first (at most 10 listed). */
37
37
  holes: {
@@ -31,7 +31,7 @@ export interface MaskEdgeReport {
31
31
  edgeAlignment: number;
32
32
  /** Turning of the outline beyond what closing it needs, in radians per 100 px of outline (0: smooth). */
33
33
  jaggedness: number;
34
- /** 0–1: the share of edge pixels that are partly covered (0 < α < 1). */
34
+ /** 0–1: the share of edge pixels in a transition wider than anti-aliasing (partly covered, no fully selected and unselected pixel within 2 px). */
35
35
  softFraction: number;
36
36
  /** Unselected areas enclosed by the selection, largest first (at most 10 listed). */
37
37
  holes: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixerate/editor",
3
- "version": "0.221.0",
3
+ "version": "0.221.1",
4
4
  "description": "UI-agnostic rich text and media editing core library",
5
5
  "author": "Pixerate",
6
6
  "license": "MIT",
package/skills/README.md CHANGED
@@ -5,6 +5,11 @@ Each editor domain in `@pixerate/editor` ships an [Agent Skill](https://agentski
5
5
  expert in the domain would follow. They are published with the package, so an app or agent
6
6
  that uses the tools can load the matching skill.
7
7
 
8
+ ## Reference skills
9
+
10
+ One per editor domain. A reference skill owns its domain's tool and command tables, its mental
11
+ model and its general practice.
12
+
8
13
  | Skill | Covers |
9
14
  | --- | --- |
10
15
  | [`pixerate-agent-tools`](pixerate-agent-tools/SKILL.md) | What every editor has in common: the MCP bridge, toolsets and tool naming, finding tools with `list_toolsets` / `search_tools` / `enable_tools` / `get_skill`, `list_commands` / `run_command` / `run_commands`, errors, undo. Load it with any domain skill. |
@@ -15,12 +20,9 @@ that uses the tools can load the matching skill.
15
20
  | [`pixerate-chart`](pixerate-chart/SKILL.md) | Chart specs, data, series, presets, trendlines, Office and Mermaid charts. |
16
21
  | [`pixerate-diagram`](pixerate-diagram/SKILL.md) | Mermaid diagrams: nodes, edges, groups, layout. |
17
22
  | [`pixerate-image-editor`](pixerate-image-editor/SKILL.md) | Adjustments, adjustment layers, selections and masks. |
18
- | [`pixerate-image-selection-masking`](pixerate-image-selection-masking/SKILL.md) | Workflow: cutouts, background and sky replacement, local adjustments through masks; choosing a selection method, checking and refining edges, and outputting an editable mask. Built on `pixerate-image-editor`'s tools. |
19
23
  | [`pixerate-paint-editor`](pixerate-paint-editor/SKILL.md) | Natural-media painting: paper and grain, dry-media brushes, the brush library, strokes as data, pigment mixing. |
20
24
  | [`pixerate-animation-editor`](pixerate-animation-editor/SKILL.md) | Vector animation: shots, commands, timing, Flair and Lottie. |
21
25
  | [`pixerate-video-editor`](pixerate-video-editor/SKILL.md) | Non-linear video editing: tracks, clips, transitions, export. |
22
- | [`pixerate-talking-head-edit`](pixerate-talking-head-edit/SKILL.md) | Talking-head cleanup by transcript: fillers, long pauses and stutters found in one pass and cut as one plan reviewed before it applies. |
23
- | [`pixerate-bgm-alignment`](pixerate-bgm-alignment/SKILL.md) | Background music: beats and cuts on the beat, ducking under speech, loudness in LUFS. |
24
26
  | [`pixerate-generation`](pixerate-generation/SKILL.md) | Planning, estimating and running paid media generation. |
25
27
  | [`pixerate-parameters`](pixerate-parameters/SKILL.md) | Effect parameters, presets and keyframes. |
26
28
  | [`pixerate-tokens`](pixerate-tokens/SKILL.md) | Design tokens: collections, modes, variables and aliases, through the whole-set document tools. |
@@ -28,6 +30,20 @@ that uses the tools can load the matching skill.
28
30
  | [`pixerate-color`](pixerate-color/SKILL.md) | Colour inspection, WCAG accessibility and contrast optimization, CVD simulation, palette audits, harmonies, and OKLab/OKLCH blends. |
29
31
  | [`pixerate-typography`](pixerate-typography/SKILL.md) | Fonts and fallback stacks, font loading, modular type scales and hierarchies, fluid sizes, readability and type-aware contrast audits, text segmentation, the font picker. |
30
32
 
33
+ ## Workflow skills
34
+
35
+ A workflow skill covers one job from start to finish: what to confirm first, the steps in
36
+ order with a check after each, a decision table, recipes and the job's pitfalls. It is built on
37
+ its reference skill's tools (load both), and its description is written about the job, so a
38
+ request like "clean up this export" finds it.
39
+
40
+ | Skill | Editor | Covers |
41
+ | --- | --- | --- |
42
+ | [`pixerate-image-selection-masking`](pixerate-image-selection-masking/SKILL.md) | Image (`pixerate-image-editor`) | Cutouts, background and sky replacement, local adjustments through masks; choosing a selection method, checking and refining edges, and outputting an editable mask. |
43
+ | [`pixerate-spreadsheet-clean-import`](pixerate-spreadsheet-clean-import/SKILL.md) | Spreadsheet (`pixerate-spreadsheet`) | Imported or pasted data made tidy: structure, headers, types (currency, comma decimals, day-first dates, leading zeros, missing values), text, duplicates and validation. |
44
+ | [`pixerate-talking-head-edit`](pixerate-talking-head-edit/SKILL.md) | Video (`pixerate-video-editor`) | Talking-head cleanup by transcript: fillers, long pauses and stutters found in one pass and cut as one plan reviewed before it applies. Owns the dialogue tools. |
45
+ | [`pixerate-bgm-alignment`](pixerate-bgm-alignment/SKILL.md) | Video (`pixerate-video-editor`) | Background music: beats and cuts on the beat, ducking under speech, loudness in LUFS. Owns the music tools. |
46
+
31
47
  ## Using them
32
48
 
33
49
  Copy or symlink the directories into your agent's skills folder, for example for Claude Code:
@@ -75,12 +91,27 @@ same change.
75
91
  `create*ToolProvider` / `create*Commands` factory is not mapped to a skill. A new domain
76
92
  gets a new skill directory, an entry in `expectedBySkill()` and `FACTORIES`, and a row in
77
93
  the table above.
78
- - **Workflow skills.** A skill that covers one job end to end on another skill's tools (for
79
- example `pixerate-image-selection-masking` on `pixerate-image-editor`) has no tool table.
80
- Its frontmatter adds `metadata` with `kind: workflow` and `reference: <skill>`, it lists the
81
- tools it uses as bullets under `## Tools used`, and it is mapped in `WORKFLOWS` in the test
82
- (and to `[]` in `expectedBySkill()`). The test checks that every tool it uses exists in the
83
- reference skill.
94
+ - **Workflow skills.** A skill that covers one job end to end on another skill's tools has no
95
+ tool table (unless the job has its own provider, as the two video ones do). Start from
96
+ [`WORKFLOW_TEMPLATE.md`](WORKFLOW_TEMPLATE.md). Its frontmatter adds `metadata` with
97
+ `kind: workflow` and `reference: <skill>`; it lists the tools it uses as bullets under
98
+ `## Tools used`; and it is mapped in `WORKFLOWS` in the test (and to `[]` in
99
+ `expectedBySkill()`). The test checks that every tool it uses exists in the reference skill,
100
+ that every backticked tool or command name in its prose is real, that the reference skill
101
+ names it under `## Workflow skills`, that it stays under 250 body lines (aim for 200) and that
102
+ it ships evals. Add its row to the workflow table above.
103
+ - **Evals.** Each workflow skill ships at least three scenarios in `evals/*.json`, run by
104
+ `packages/core/tests/agent-skill-evals.test.ts`. An eval has a `prompt` (the task, in the
105
+ user's words), a `fixture` (the starting document: `{ "editor": "spreadsheet", "rows" }` or
106
+ `{ "editor": "image", "width", "height", "background", "shapes" }`), `checks` (a read-only
107
+ tool, its `args`, what its result must match in `expect`, and `why`) and a `solution` (the
108
+ tool calls an expert would make). The test proves each eval: some check fails on the fixture,
109
+ and all pass after the solution. A model harness runs the same file with the model in place
110
+ of the solution. In `expect`, objects match key by key and arrays element by element, with
111
+ operators for ranges and lists (`$min`, `$max`, `$match`, `$every`, `$some`, `$length`,
112
+ `$unique`, `$distinct`, `$sum`, `$column`, `$nonEmpty`, `$not`, `$in`, `$type`); a check's
113
+ argument can read a value the job created with `{ "$read": "list_layers", "path":
114
+ "layers.-1.id" }`. Checks test the outcome the user asked for, not one way of getting it.
84
115
  - **Workflows and practice.** The test cannot check advice. When a feature changes the best
85
116
  way to do something (a new batch tool, a new check, a better default), update the working
86
117
  method and best practices too.
@@ -0,0 +1,50 @@
1
+ ---
2
+ name: pixerate-<editor>-<job>
3
+ description: The <job> workflow in the Pixerate <editor> - <what it produces, in the user's words, and the main steps>. Use when asked to <trigger phrases: the verbs and nouns people use for this job>, or when <a tool or two that signal the job> are available. Load with pixerate-<editor>, which lists every tool.
4
+ metadata:
5
+ kind: workflow
6
+ reference: pixerate-<editor>
7
+ ---
8
+
9
+ # <The job, as a short title>
10
+
11
+ <Two to four sentences: the model of the job a person needs before starting. What the
12
+ document is made of here, and the one or two facts that make this job go right or wrong.>
13
+
14
+ This skill is the method. `pixerate-<editor>` has the full tool reference.
15
+
16
+ ## Goal and quality bar
17
+
18
+ - <What the finished result is, stated so it can be checked: numbers, ranges, absences.>
19
+ - <What must stay editable, reversible or untouched.>
20
+
21
+ ## Inputs to confirm
22
+
23
+ Ask when you cannot tell from the document; say what you assumed when you can.
24
+
25
+ - **<Decision>:** <the options, and the default when the user does not say>.
26
+
27
+ ## Working method
28
+
29
+ 1. **<Step>.** <What to do, with which tool and which arguments matter (units, defaults).>
30
+ **Check:** <a read tool and what its result must show before going on>.
31
+ 2. **<Step>.** ...
32
+ **Check:** ...
33
+
34
+ ## Decision table
35
+
36
+ | Situation | Approach |
37
+ | --- | --- |
38
+ | <case> | <method, tool, setting> |
39
+
40
+ ## Recipes
41
+
42
+ **<Common request>.** <The tool calls in order, with arguments, as short prose.>
43
+
44
+ ## Pitfalls
45
+
46
+ - **<Failure>.** <Why it happens and how to avoid or detect it.>
47
+
48
+ ## Tools used
49
+
50
+ - `<tool_name>`, `<tool_name>`: <one-line purpose in this job>
@@ -33,6 +33,13 @@ Also an optional selection and crop/rotate/flip. See `pixerate-agent-tools` for
33
33
  - **Coordinates** for selections are natural pixels of the source image (before crop, rotate
34
34
  or flip). Read `get_selection` bounds or the image size, never screen pixels.
35
35
 
36
+ ## Workflow skills
37
+
38
+ For these jobs, load the workflow skill too: it has the method, the checks between steps and
39
+ the pitfalls, and uses the tools below.
40
+
41
+ - `pixerate-image-selection-masking`: cutouts, background and sky replacement, local adjustments through masks.
42
+
36
43
  ## Working method
37
44
 
38
45
  1. **Read.** `get_adjustments` for the current values, process version and layers;
@@ -0,0 +1,22 @@
1
+ {
2
+ "prompt": "Make just the red ball black and white, and keep it adjustable so I can tweak it later. The sign in the corner should stay red.",
3
+ "fixture": {
4
+ "editor": "image",
5
+ "width": 120,
6
+ "height": 80,
7
+ "background": [40, 90, 200],
8
+ "shapes": [
9
+ { "shape": "ellipse", "x": 40, "y": 20, "width": 40, "height": 40, "color": [210, 40, 40] },
10
+ { "shape": "rect", "x": 100, "y": 6, "width": 12, "height": 8, "color": [210, 40, 40] }
11
+ ]
12
+ },
13
+ "checks": [
14
+ { "why": "a Black & White adjustment layer, masked to the ball and not the sign", "tool": "list_layers", "expect": { "layers": { "$some": { "type": "adjustment", "kind": "blackWhite", "mask": { "kind": "raster", "defaultValue": 0, "paintedBounds": { "x": { "$min": 38, "$max": 41 }, "y": { "$min": 18, "$max": 21 }, "width": { "$min": 39, "$max": 44 }, "height": { "$min": 39, "$max": 44 } } } } } } },
15
+ { "why": "the photo's own pixels are not changed", "tool": "list_layers", "expect": { "layers": { "$some": { "id": "background", "type": "pixel", "mask": null } } } }
16
+ ],
17
+ "solution": [
18
+ { "tool": "magic_wand", "args": { "x": 60, "y": 40, "tolerance": 20, "contiguous": true } },
19
+ { "tool": "get_selection" },
20
+ { "tool": "add_adjustment_layer", "args": { "kind": "blackWhite" } }
21
+ ]
22
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "prompt": "Hide the blue background so only the red ball shows. Don't delete any pixels; I may want the background back later.",
3
+ "fixture": {
4
+ "editor": "image",
5
+ "width": 120,
6
+ "height": 80,
7
+ "background": [40, 90, 200],
8
+ "shapes": [
9
+ { "shape": "ellipse", "x": 40, "y": 20, "width": 40, "height": 40, "color": [210, 40, 40] },
10
+ { "shape": "rect", "x": 100, "y": 6, "width": 12, "height": 8, "color": [210, 40, 40] }
11
+ ]
12
+ },
13
+ "checks": [
14
+ { "why": "the top layer has a layer mask that reveals only the ball", "tool": "list_layers", "expect": { "layers": { "$some": { "type": "pixel", "mask": { "kind": "raster", "enabled": true, "defaultValue": 0, "paintedBounds": { "x": { "$min": 38, "$max": 41 }, "y": { "$min": 18, "$max": 21 }, "width": { "$min": 39, "$max": 44 }, "height": { "$min": 39, "$max": 44 } } } } } } },
15
+ { "why": "the mask edge is clean", "tool": "inspect_mask_edge", "args": { "target": "layer-mask", "layerId": { "$read": "list_layers", "path": "layers.-1.id" } }, "expect": { "issues": [], "haloScore": { "$max": 0.35 } } }
16
+ ],
17
+ "solution": [
18
+ { "tool": "list_layers" },
19
+ { "tool": "magic_wand", "args": { "x": 60, "y": 40, "tolerance": 20, "contiguous": true } },
20
+ { "tool": "inspect_mask_edge" },
21
+ { "tool": "add_layer_mask", "args": { "layerId": "background", "from": "selection" } },
22
+ { "tool": "inspect_mask_edge", "args": { "target": "layer-mask", "layerId": "background" } }
23
+ ]
24
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "prompt": "Select the red ball in the middle of the photo, not the red sign in the top-right corner. Just the selection for now; I'll decide what to do with it.",
3
+ "fixture": {
4
+ "editor": "image",
5
+ "width": 120,
6
+ "height": 80,
7
+ "background": [40, 90, 200],
8
+ "shapes": [
9
+ { "shape": "ellipse", "x": 40, "y": 20, "width": 40, "height": 40, "color": [210, 40, 40] },
10
+ { "shape": "rect", "x": 100, "y": 6, "width": 12, "height": 8, "color": [210, 40, 40] }
11
+ ]
12
+ },
13
+ "checks": [
14
+ { "why": "the selection hugs the ball and leaves the sign out", "tool": "get_selection", "expect": { "empty": false, "bounds": { "x": { "$min": 38, "$max": 41 }, "y": { "$min": 18, "$max": 21 }, "width": { "$min": 39, "$max": 44 }, "height": { "$min": 39, "$max": 44 } }, "percent": { "$min": 12, "$max": 14.5 } } },
15
+ { "why": "a clean edge: no halo, holes or islands", "tool": "inspect_mask_edge", "expect": { "issues": [], "holes": { "count": 0 }, "islands": { "count": 0 } } }
16
+ ],
17
+ "solution": [
18
+ { "tool": "magic_wand", "args": { "x": 60, "y": 40, "tolerance": 20, "contiguous": true } },
19
+ { "tool": "get_selection" },
20
+ { "tool": "inspect_mask_edge" }
21
+ ]
22
+ }
@@ -15,6 +15,13 @@ leading `'` forces text.
15
15
 
16
16
  Errors, namespacing and `run_commands` batching are in the `pixerate-agent-tools` skill.
17
17
 
18
+ ## Workflow skills
19
+
20
+ For these jobs, load the workflow skill too: it has the method, the checks between steps and
21
+ the pitfalls, and uses the tools below.
22
+
23
+ - `pixerate-spreadsheet-clean-import`: tidying imported or pasted data: structure, headers, types, dates, duplicates.
24
+
18
25
  ## Working method
19
26
 
20
27
  1. **Read.** `get_range` on the area you will touch (and its header row). The result gives
@@ -0,0 +1,154 @@
1
+ ---
2
+ name: pixerate-spreadsheet-clean-import
3
+ description: The clean-up workflow for imported data in a Pixerate spreadsheet - a CSV export, a pasted table or a report dump turned into tidy data. Covers finding the data region, profiling each column, removing title, blank, total and repeated header rows, normalising headers, fixing types (numbers stored as text, currency and European decimals, day-first dates read month-first, lost leading zeros, missing-value markers), trimming and unifying text, removing duplicates by a key, and validating the result without damaging the source. Use when asked to clean, tidy, fix, normalise, deduplicate or prepare imported or pasted data for analysis, or when a column will not sum or sort. Load with pixerate-spreadsheet, which lists every tool.
4
+ metadata:
5
+ kind: workflow
6
+ reference: pixerate-spreadsheet
7
+ ---
8
+
9
+ # Cleaning imported data
10
+
11
+ Imported data arrives as typed text: each cell is parsed the way a person's typing is (en-US),
12
+ so `$1,200.00` and `(45)` become numbers, `3/4/2026` becomes the ISO date `2026-03-04`, and
13
+ anything the parser does not recognise (`USD 300`, `1.250,50`, `31/03/2026`, `n/a`) stays text.
14
+ `get_range` shows both sides: `raw` is what was typed, `values` what it became. Cleaning is
15
+ reading both, deciding what each column should be, and writing it back in blocks.
16
+
17
+ This skill is the method. `pixerate-spreadsheet` has the full tool reference and the formula list.
18
+
19
+ ## Goal and quality bar
20
+
21
+ - One header row of short, unique, trimmed names, with units in the header (`Amount (USD)`).
22
+ - One record per row: no title, notes, blank, subtotal, total or repeated header rows inside the data.
23
+ - One type per column: numbers are numbers (they sum), dates are ISO dates, codes that must keep
24
+ leading zeros are text, missing values are empty cells (not 0, not `n/a`).
25
+ - Text is trimmed and each category is spelled one way (`North`, not `north` and `NORTH `).
26
+ - No duplicates by the key the user chose; the right row of each set is kept.
27
+ - Nothing is lost by accident: the user can see what changed, and undo it.
28
+
29
+ ## Inputs to confirm
30
+
31
+ Ask when you cannot tell from the data; say what you assumed when you can.
32
+
33
+ - **In place or beside?** Clean the cells in place (the default for a sheet that is only an
34
+ import), or keep the raw columns and add clean ones next to them (when the raw data is the
35
+ only copy, or must be audited).
36
+ - **Date order** of the source: month-first (US) or day-first (most other places). A raw date
37
+ whose first part is above 12 (`31/03/2026`) settles it.
38
+ - **The duplicate key** (which columns make two rows the same record, and whether case and
39
+ spaces count) and **which row wins** (first, last, most recently updated, most complete).
40
+ - **Missing values:** empty (the default), or a value the user names. Never 0 unless told.
41
+ - **Category spelling:** the canonical form when variants disagree (`North` or `north`).
42
+
43
+ ## Working method
44
+
45
+ 1. **Find the data and keep a way back.** `get_range` a generous area (`A1:Z60`) and find the
46
+ header row, the first and last data row and any title, notes or total rows. If rows are
47
+ hidden or filtered, run `spreadsheet.unhideAll` or `spreadsheet.clearFilters` first so the
48
+ user sees what you clean. Before a large rewrite, `get_spreadsheet_document` keeps a copy
49
+ that `load_spreadsheet_document` can restore; small steps are each one `undo_spreadsheet`.
50
+ **Check:** you can name the data range and each column's intended type.
51
+ 2. **Profile each column** from `raw` and `values` together: numbers stored as text (a string in
52
+ `values` where the rest are numbers), currency codes, comma decimals, slash dates and their
53
+ order, codes that lost leading zeros (a number where text is expected: ZIP, ID, phone),
54
+ missing-value markers (`n/a`, `-`, `NULL`, `?`), stray spaces and case variants.
55
+ **Check:** a list of problems per column, with counts. Report anything you will not fix.
56
+ 3. **Fix the structure.** `unmerge_cells` over the region first. Delete title, notes, blank,
57
+ total and repeated header rows with `delete_rows`, from the bottom up so earlier row numbers
58
+ stay valid, or rewrite the whole block and delete the leftover tail once.
59
+ **Check:** `get_range` shows the header in row 1 of the region, data directly below, and
60
+ `sheet.rows` is the count you expect.
61
+ 4. **Fix the headers** in one `set_range` over the header row: trimmed, unique, plain names,
62
+ units in the header and not in the cells.
63
+ **Check:** no duplicates, no leading or trailing spaces.
64
+ 5. **Fix the values, a column (or a block) at a time** with `set_range`: numbers as JSON
65
+ numbers, dates as ISO text, text codes with a leading `'` (`'02134`), missing values as
66
+ `null`. For a large table use formula columns (see the recipes) rather than copying values
67
+ through the conversation.
68
+ **Check:** the returned `errors` is empty, and a `get_range` of the column shows one type.
69
+ 6. **Remove duplicates** by the confirmed key, keeping the winning row of each set. Rewrite the
70
+ kept rows as one block and delete the rest, or delete the losing rows from the bottom up.
71
+ **Check:** the row count fell by the number of duplicates you found in step 2.
72
+ 7. **Validate the whole region.** `get_range` it once more: `errors` is empty, every column has
73
+ one type, totals you can compute (a sum of amounts, a row count) match what you expect from
74
+ the source. For a big sheet, count in scratch cells instead of reading every value (see the
75
+ recipes), then clear them.
76
+ **Check:** every problem from step 2 is fixed or reported.
77
+ 8. **Report** what changed: rows removed (blank, total, duplicate) and why, columns converted,
78
+ assumptions (date order, the key, the canonical spelling) and anything left for the user.
79
+
80
+ ## Decision table
81
+
82
+ | Situation | Approach |
83
+ | --- | --- |
84
+ | Small table (a few hundred cells to change), sheet is only an import | Read, then rewrite the cleaned block in place with `set_range`; delete leftover rows |
85
+ | Large table, or the raw data must stay | Formula columns beside the raw ones (`=TRIM(C2:C5000)`, `=MAP(...)`); the raw columns stay as they are |
86
+ | One or two broken columns, the rest fine | `set_range` on those columns only; leave every other cell untouched |
87
+ | Numbers with a currency code or unit (`USD 300`, `12 kg`) | Strip it, put the unit in the header; ask when one column mixes currencies |
88
+ | Comma decimals (`1.250,50`) | Convert to `1250.5`; then check that cells the parser already turned into numbers (`1,250` read as 1250) were not meant as `1.25` |
89
+ | Slash dates, source is day-first | Rewrite every slash date in the column from `raw` as ISO, including the ones that already parsed (they parsed month-first and are wrong) |
90
+ | Codes that must keep zeros (ZIP, ID, phone) | Text with a leading `'`, padded to the code's length |
91
+ | Missing markers (`n/a`, `-`, `NULL`) | Empty cell (`null`), unless the user names a value |
92
+ | Exact duplicate rows | Keep the first |
93
+ | Same key, different details | Keep the row the user chose (latest, most complete); say which you dropped |
94
+ | Merged title cells above the header | Unmerge, then delete the title rows or leave them above the data with a blank row between |
95
+
96
+ ## Recipes
97
+
98
+ **Rewrite in place (small table).** `get_range` the region; build the clean block (header plus
99
+ kept rows) in one `set_range` from the top-left cell; `delete_rows` the rows left below it.
100
+ That is two undo steps, and every cell the user had is accounted for in your report.
101
+
102
+ **Formula columns (large table).** Insert clean columns with `insert_columns` to the right of
103
+ the raw ones and write one spilling formula at the top of each, leaving the cells below empty:
104
+
105
+ - Trim text: `=TRIM(C2:C5000)`; one spelling: `=MAP(E2:E5000,LAMBDA(v,PROPER(TRIM(v))))`.
106
+ - Currency text to numbers: `=MAP(D2:D5000,LAMBDA(v,IF(ISNUMBER(v),v,VALUE(TRIM(SUBSTITUTE(v,"USD",""))))))`.
107
+ - Comma decimals: `VALUE(SUBSTITUTE(SUBSTITUTE(v,".",""),",","."))` inside the same `MAP`.
108
+ - Day-first text dates: `DATE(RIGHT(v,4),MID(v,4,2),LEFT(v,2))`, which gives an ISO date; take
109
+ `v` from the raw text, since a date the parser already read month-first is wrong.
110
+
111
+ Tell the user the clean columns are formulas over the raw ones (sorting or deleting the raw
112
+ columns breaks them), and name the headers you gave them.
113
+
114
+ **Count instead of reading (big sheets).** Write checks into a spare cell outside the data, read
115
+ them, then clear the cell with `set_range` and `null`:
116
+
117
+ - Duplicates by key: `=COUNTA(A2:A5000)-COUNTA(UNIQUE(A2:A5000))`.
118
+ - Text in a number column: `=COUNTA(D2:D5000)-COUNT(D2:D5000)`.
119
+ - Stray spaces: `=COUNTIF(C2:C5000," *")+COUNTIF(C2:C5000,"* ")`.
120
+
121
+ **Keep the latest of each duplicate.** Read the key and date columns, pick the winner of each
122
+ set by date, then rewrite the kept rows as one block (or delete the losers bottom up). Normalise
123
+ the key (trim, lower case) only for comparing; write the kept row's own key back cleaned.
124
+
125
+ ## Pitfalls
126
+
127
+ - **Month-first parsing.** `02/04/2026` from a day-first export is stored as 4 February, with no
128
+ error. Only `raw` shows the original; check it whenever a column has slash dates.
129
+ - **Silent number parsing.** `1,250` becomes 1250 and `007` becomes 7 on input. Write codes with
130
+ a leading `'`, and check comma-decimal sources for values that parsed as thousands.
131
+ - **Totals inside the data** double every sum. A total row is usually last, has an empty key and
132
+ a `SUM` in `raw`; delete it, and compute totals elsewhere if the user wants them.
133
+ - **Deleting top-down** shifts the rows below, so the next row number you delete is wrong.
134
+ Delete from the bottom, or rewrite the block and delete the tail.
135
+ - **Formulas that point into the data** become `#REF!` when rows they use are deleted. Read
136
+ `raw` for formulas before deleting and check `errors` after.
137
+ - **Spills need room.** A spilling clean column turns into `#SPILL!` if anything sits below it.
138
+ - **0 is not missing.** Writing 0 for `n/a` changes averages and counts; write `null`.
139
+ - **Hidden rows are still data.** `get_range` returns them; unhide before you show the user a
140
+ result, so what they see matches what you counted.
141
+ - **Copying values through the conversation** is slow and error-prone for thousands of cells;
142
+ use formula columns, and keep the values you write to the cells that change.
143
+
144
+ ## Tools used
145
+
146
+ - `get_range`: read `raw` and `values`, the sheet size, errors and merges, before and after every step
147
+ - `set_range`: write headers, cleaned blocks, formula columns and scratch checks, one undo step each
148
+ - `set_cell`: fix one cell
149
+ - `delete_rows`, `insert_rows`: remove title, blank, total and duplicate rows; make room
150
+ - `insert_columns`, `delete_columns`: add clean formula columns; drop empty or unwanted ones
151
+ - `unmerge_cells`: split merged cells before restructuring
152
+ - `get_spreadsheet_document`, `load_spreadsheet_document`: keep a copy before a large rewrite, and restore it
153
+ - `undo_spreadsheet`: take back a step
154
+ - `spreadsheet.unhideAll`, `spreadsheet.clearFilters`: see every row before cleaning
@@ -0,0 +1,43 @@
1
+ {
2
+ "prompt": "This sheet is a raw CSV export of orders from our European shop. Clean it up in place so it can be analysed: tidy headers, one type per column, and no blank, duplicate or total rows.",
3
+ "fixture": {
4
+ "editor": "spreadsheet",
5
+ "rows": [
6
+ [" Order ID", "date", "Customer name ", "Amount", "region"],
7
+ ["1001", "2026-03-04", "acme corp", "$1,200.00", "north"],
8
+ ["1002", "31/03/2026", "Globex ", "950", "South"],
9
+ ["", "", "", "", ""],
10
+ ["1003", "4-Mar-2026", "Initech", "USD 300", "NORTH"],
11
+ ["1002", "31/03/2026", "Globex ", "950", "South"],
12
+ ["1004", "02/04/2026", "Umbrella", "1.250,50", " south"],
13
+ ["", "", "Total", "=SUM(D2:D7)", ""]
14
+ ]
15
+ },
16
+ "checks": [
17
+ { "why": "headers are trimmed and unique", "tool": "get_range", "args": { "range": "A1:E1" }, "expect": { "values": [{ "$every": { "$match": "^\\S(.*\\S)?$" }, "$unique": true }] } },
18
+ { "why": "one header row and the four distinct orders, with no blank, duplicate or total rows", "tool": "get_range", "args": { "range": "A1:H40" }, "expect": { "values": { "$nonEmpty": { "$length": 5 } } } },
19
+ { "why": "order ids are unique", "tool": "get_range", "args": { "range": "A2:A40" }, "expect": { "values": { "$nonEmpty": { "$column": [0, { "$unique": true }] } } } },
20
+ { "why": "amounts are numbers, European decimals and currency text included", "tool": "get_range", "args": { "range": "D2:D40" }, "expect": { "values": { "$nonEmpty": { "$column": [0, { "$sum": 3700.5 }] } } } },
21
+ { "why": "dates are real dates read day-first, as the export writes them (02/04 is 2 April)", "tool": "get_range", "args": { "range": "B2:B40" }, "expect": { "values": { "$nonEmpty": { "$column": [0, { "$every": { "$match": "^\\d{4}-\\d{2}-\\d{2}$" }, "$some": "2026-04-02", "$not": { "$some": "2026-02-04" } }] } } } },
22
+ { "why": "customer names are trimmed", "tool": "get_range", "args": { "range": "C2:C40" }, "expect": { "values": { "$nonEmpty": { "$column": [0, { "$every": { "$match": "^\\S(.*\\S)?$" } }] } } } },
23
+ { "why": "regions are spelled one way", "tool": "get_range", "args": { "range": "E2:E40" }, "expect": { "values": { "$nonEmpty": { "$column": [0, { "$distinct": { "$length": 2 } }] } } } },
24
+ { "why": "no formula errors", "tool": "get_range", "args": { "range": "A1:H40" }, "expect": { "errors": [] } }
25
+ ],
26
+ "solution": [
27
+ { "tool": "get_range", "args": { "range": "A1:Z50" } },
28
+ {
29
+ "tool": "set_range",
30
+ "args": {
31
+ "start": "A1",
32
+ "values": [
33
+ ["Order ID", "Date", "Customer", "Amount", "Region"],
34
+ [1001, "2026-03-04", "Acme Corp", 1200, "North"],
35
+ [1002, "2026-03-31", "Globex", 950, "South"],
36
+ [1003, "2026-03-04", "Initech", 300, "North"],
37
+ [1004, "2026-04-02", "Umbrella", 1250.5, "South"]
38
+ ]
39
+ }
40
+ },
41
+ { "tool": "delete_rows", "args": { "start": 6, "count": 3 } }
42
+ ]
43
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "prompt": "Remove duplicate customers from this list. Two rows are the same customer when the email matches, ignoring case and spaces; keep each customer's most recently updated row.",
3
+ "fixture": {
4
+ "editor": "spreadsheet",
5
+ "rows": [
6
+ ["Email", "Name", "Plan", "Updated"],
7
+ ["ann@x.com", "Ann Lee", "Free", "2026-01-10"],
8
+ ["bob@y.com", "Bob Ray", "Pro", "2026-02-01"],
9
+ ["Ann@X.com ", "Ann Lee", "Pro", "2026-05-01"],
10
+ ["cat@z.com", "Cat Sun", "Free", "2026-03-15"],
11
+ ["bob@y.com", "Bob Ray", "Pro", "2026-02-01"]
12
+ ]
13
+ },
14
+ "checks": [
15
+ { "why": "one row per customer under the header", "tool": "get_range", "args": { "range": "A1:F40" }, "expect": { "values": { "$nonEmpty": { "$length": 4 } } } },
16
+ { "why": "Ann's latest row (Pro, updated 1 May) is the one kept", "tool": "get_range", "args": { "range": "A2:D40" }, "expect": { "values": { "$some": [{ "$match": ["^\\s*ann@x\\.com\\s*$", "i"] }, "Ann Lee", "Pro", "2026-05-01"] } } },
17
+ { "why": "Bob and Cat are still there", "tool": "get_range", "args": { "range": "A2:A40" }, "expect": { "values": { "$some": ["bob@y.com"], "$nonEmpty": { "$some": ["cat@z.com"] } } } },
18
+ { "why": "the header row is unchanged", "tool": "get_range", "args": { "range": "A1:D1" }, "expect": { "values": [["Email", "Name", "Plan", "Updated"]] } }
19
+ ],
20
+ "solution": [
21
+ { "tool": "get_range", "args": { "range": "A1:D20" } },
22
+ { "tool": "set_range", "args": { "start": "A2", "values": [["ann@x.com", "Ann Lee", "Pro", "2026-05-01"], ["bob@y.com", "Bob Ray", "Pro", "2026-02-01"], ["cat@z.com", "Cat Sun", "Free", "2026-03-15"]] } },
23
+ { "tool": "delete_rows", "args": { "start": 5, "count": 2 } }
24
+ ]
25
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "prompt": "The ZIP column lost its leading zeros on import and Amount has some text in it. Fix those two columns so ZIPs are five-digit codes and amounts are numbers (leave missing amounts empty). Don't change anything else.",
3
+ "fixture": {
4
+ "editor": "spreadsheet",
5
+ "rows": [
6
+ ["ZIP", "Amount", "Date", "Note"],
7
+ ["2134", "300 USD", "2026-03-04", "rush"],
8
+ ["90210", "1 200", "2026-03-05", ""],
9
+ ["601", "(45)", "2026-03-05", "refund"],
10
+ ["10001", "n/a", "2026-03-06", "check with sales"]
11
+ ]
12
+ },
13
+ "checks": [
14
+ { "why": "ZIPs are five-digit text, leading zeros restored", "tool": "get_range", "args": { "range": "A2:A5" }, "expect": { "values": [["02134"], ["90210"], ["00601"], ["10001"]] } },
15
+ { "why": "amounts are numbers; a missing amount is empty, not 0", "tool": "get_range", "args": { "range": "B2:B5" }, "expect": { "values": [[300], [1200], [-45], [""]] } },
16
+ { "why": "the other columns and the headers are untouched", "tool": "get_range", "args": { "range": "A1:D5" }, "expect": { "raw": [["ZIP", "Amount", "Date", "Note"], { "$length": 4 }, { "$length": 4 }, { "$length": 4 }, { "$length": 4 }] } },
17
+ { "why": "dates and notes keep what was typed", "tool": "get_range", "args": { "range": "C2:D5" }, "expect": { "raw": [["2026-03-04", "rush"], ["2026-03-05", ""], ["2026-03-05", "refund"], ["2026-03-06", "check with sales"]] } },
18
+ { "why": "the sheet keeps its size", "tool": "get_range", "args": { "range": "A1" }, "expect": { "sheet": { "rows": 5, "columns": 4 } } }
19
+ ],
20
+ "solution": [
21
+ { "tool": "get_range", "args": { "range": "A1:D5" } },
22
+ { "tool": "set_range", "args": { "start": "A2", "values": [["'02134", 300], ["'90210", 1200], ["'00601", -45], ["'10001", null]] } }
23
+ ]
24
+ }
@@ -26,6 +26,14 @@ them from `get_video_project`; never invent them.
26
26
 
27
27
  For errors, namespaced tool names and undo conventions, see `pixerate-agent-tools`.
28
28
 
29
+ ## Workflow skills
30
+
31
+ For these jobs, load the workflow skill too: it has the method, the checks between steps and
32
+ the pitfalls, and uses the tools below.
33
+
34
+ - `pixerate-talking-head-edit`: dialogue cleanup by transcript: fillers, pauses, stutters.
35
+ - `pixerate-bgm-alignment`: cutting to the beat, ducking music under speech, loudness.
36
+
29
37
  ## Working method
30
38
 
31
39
  1. **Read.** `get_video_project` for settings, assets, tracks, clips (with end and source