diff --git a/README.md b/README.md index 58b97349..bbe01b1b 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ Core skills are grouped under [skills/workflows/](skills/workflows), [skills/too | DevEx | [semantic-commit](./skills/tools/devex/semantic-commit/) | Commit staged changes using Semantic Commit format | | DevEx | [open-changed-files-review](./skills/tools/devex/open-changed-files-review/) | Open files edited by Codex in VSCode after making changes (silent no-op when unavailable) | | DevEx | [desktop-notify](./skills/tools/devex/desktop-notify/) | Send desktop notifications via terminal-notifier (macOS) or notify-send (Linux) | -| Media | [image-processing](./skills/tools/media/image-processing/) | Process images (convert/resize/crop/optimize) via ImageMagick | +| Media | [image-processing](./skills/tools/media/image-processing/) | Convert `svg/png/jpg/jpeg/webp` inputs to `png/webp/jpg` and validate SVGs via `image-processing` | | Media | [screen-record](./skills/tools/media/screen-record/) | Record a single window or full display to a video file via the screen-record CLI (macOS 12+ and Linux) | | Media | [screenshot](./skills/tools/media/screenshot/) | Capture screenshots via screen-record on macOS and Linux, with optional macOS desktop capture via screencapture | | SQL | [sql-postgres](./skills/tools/sql/sql-postgres/) | Run PostgreSQL queries via psql using a prefix + env file convention | diff --git a/skills/tools/media/image-processing/SKILL.md b/skills/tools/media/image-processing/SKILL.md index 9ce1261a..b3152fa9 100644 --- a/skills/tools/media/image-processing/SKILL.md +++ b/skills/tools/media/image-processing/SKILL.md @@ -1,6 +1,6 @@ --- name: image-processing -description: Validate SVG inputs and convert trusted SVG to png/webp/svg +description: Validate SVG inputs and convert svg/png/jpg/jpeg/webp inputs to png/webp/jpg --- # Image Processing @@ -19,7 +19,7 @@ Inputs: - Natural-language user intent (assistant translates into a command). - Exactly one operation: - - `convert`: `--from-svg ` + `--to png|webp|svg` + `--out ` + - `convert`: `--in ` + `--to png|webp|jpg` + `--out `; accepts `svg|png|jpg|jpeg|webp` inputs - `svg-validate`: exactly one `--in ` + `--out ` - Optional sizing for raster convert output: `--width` / `--height`. - Optional output controls: `--overwrite`, `--dry-run`, `--json`, `--report`. @@ -31,7 +31,7 @@ Outputs: - `summary.json` (when `--json` or `--report` is used) - `report.md` (when `--report` is used) - Assistant response (outside the script) must include: - - Output file/folder paths as clickable links (inline code) + - Output file/folder paths as clickable markdown file links - A suggested “next time” prompt to repeat the same task Exit codes: @@ -46,10 +46,12 @@ Failure modes: - Invalid or ambiguous flags (missing required params, unsupported combinations). - Output already exists without `--overwrite`. - Invalid convert contract: - - missing `--from-svg` / `--to` / `--out` - - `--in` used with `convert` - - `--out` extension mismatch vs `--to` - - `--to svg` with `--width`/`--height` + - missing `--in` / `--to` / `--out` + - repeated `--in` + - `--out` extension mismatch vs `--to` (`.jpeg` is valid for `--to jpg`) + - unsupported target format + - unsupported input format + - invalid `--width` / `--height` values - Invalid `svg-validate` contract: - missing or repeated `--in` - missing `--out` @@ -60,9 +62,11 @@ Failure modes: ### Preferences (optional; honor when provided) - Operation: `convert` or `svg-validate`. -- Target format (for `convert`): `png` / `webp` / `svg`. -- Raster sizing (for `convert --to png|webp`): `--width`, `--height`. +- Target format (for `convert`): `png` / `webp` / `jpg`. +- Raster sizing (for `convert`): `--width`, `--height`. - Reproducibility/audit flags: `--dry-run`, `--json`, `--report`, `--overwrite`. +- Output extension detail (for `--to jpg`): `.jpg` or `.jpeg`. +- JPG background behavior: transparent pixels are flattened onto white. ### Policies (must-follow per request) @@ -76,13 +80,14 @@ Failure modes: - Do not call ImageMagick binaries directly unless debugging the `image-processing` CLI itself. 3. Contract gate (exactly one operation path) - - `convert`: require `--from-svg`, `--to`, `--out`; forbid `--in`. + - `convert`: require exactly one `--in`, plus `--to` and `--out`. + - `convert`: accept `svg|png|jpg|jpeg|webp` inputs; require `--out` extension to match `--to` (`.jpeg` allowed for `--to jpg`). - `svg-validate`: require exactly one `--in` and `--out`; forbid `--to`/`--width`/`--height`. 4. Completion response (fixed) - After a successful run, respond using: - `skills/tools/media/image-processing/references/ASSISTANT_RESPONSE_TEMPLATE.md` - - Include clickable output path(s) and a one-sentence “next prompt” that repeats the same task with concrete paths/options. + - Include clickable markdown file links and a one-sentence “next prompt” that repeats the same task with concrete paths/options. ## References diff --git a/skills/tools/media/image-processing/references/ASSISTANT_RESPONSE_TEMPLATE.md b/skills/tools/media/image-processing/references/ASSISTANT_RESPONSE_TEMPLATE.md index 9f534452..f8246311 100644 --- a/skills/tools/media/image-processing/references/ASSISTANT_RESPONSE_TEMPLATE.md +++ b/skills/tools/media/image-processing/references/ASSISTANT_RESPONSE_TEMPLATE.md @@ -2,16 +2,16 @@ Use this template after a successful image-processing run. -```text +```md Output: -- `` +- [](/absolute/path/to/output) Next prompt: - "" Notes: -- Report (if used): `/report.md>` -- Summary (if `--json`/`--report` used): `/summary.json>` +- Report (if used): [report.md](/absolute/path/to/out/image-processing/runs//report.md) +- Summary (if `--json`/`--report` used): [summary.json](/absolute/path/to/out/image-processing/runs//summary.json) - Warnings (if any): ``` @@ -20,8 +20,9 @@ Notes: A good “next prompt” should include: - The subcommand (`convert` or `svg-validate`) -- Exact input path(s) (`--from-svg` for convert, `--in` for svg-validate) +- Exact input path (`--in`) - Exact output path (`--out`) +- Exact convert target (`--to png|webp|jpg`) when using `convert` - Any non-default flags (e.g., `--width`, `--height`, `--overwrite`, `--report`, `--dry-run`) Example: diff --git a/skills/tools/media/image-processing/references/IMAGE_PROCESSING_GUIDE.md b/skills/tools/media/image-processing/references/IMAGE_PROCESSING_GUIDE.md index 47f32f4e..557313eb 100644 --- a/skills/tools/media/image-processing/references/IMAGE_PROCESSING_GUIDE.md +++ b/skills/tools/media/image-processing/references/IMAGE_PROCESSING_GUIDE.md @@ -10,45 +10,47 @@ image-processing --help - The CLI has exactly two subcommands: `convert` and `svg-validate`. - By default, the CLI refuses to overwrite outputs. Use `--overwrite` to replace. -- `convert` is SVG-first: it requires `--from-svg` and does not accept `--in`. +- `convert` requires exactly one `--in` and accepts `svg|png|jpg|jpeg|webp` inputs. +- `convert` requires `--to png|webp|jpg`, and `--out` must match that target (`.jpeg` is accepted for `--to jpg`). - `svg-validate` requires exactly one `--in` and an explicit `--out` ending in `.svg`. - When `--json` is used, stdout is JSON only (logs go to stderr). +- JSON/report output includes `source.input_path` and `source.input_format`. ## Common flags - Inputs: - - `convert`: `--from-svg ` + - `convert`: `--in ` (exactly one) - `svg-validate`: `--in ` (exactly one) - Output: - `--out ` (required) - `--overwrite` (optional) - Convert target: - - `--to png|webp|svg` (required for `convert`) + - `--to png|webp|jpg` (required for `convert`) - `--width`, `--height` (optional, raster targets only) - Reproducibility: - `--dry-run` (no image outputs written) - - `--json` (machine summary) - - `--report` (writes `report.md` under `out/image-processing/runs//`) + - `--json` (machine summary to stdout, plus `summary.json` under `out/image-processing/runs//`) + - `--report` (writes `report.md` and `summary.json` under `out/image-processing/runs//`) ## Subcommands ### `convert` -Render trusted SVG input to `png`, `webp`, or `svg`. +Convert `svg|png|jpg|jpeg|webp` input to `png`, `webp`, or `jpg`. ```bash image-processing \ convert \ - --from-svg path/to/icon.svg \ + --in path/to/icon.svg \ --to webp \ --out out/icon.webp \ --json image-processing \ convert \ - --from-svg path/to/icon.svg \ - --to png \ - --out out/icon@2x.png \ + --in path/to/photo.png \ + --to jpg \ + --out out/photo.jpg \ --width 512 \ --height 512 \ --json @@ -56,10 +58,9 @@ image-processing \ Rules: -- Must include `--from-svg`, `--to`, and `--out`. -- Must not include `--in`. -- `--out` extension must match `--to`. -- `--to svg` does not support `--width`/`--height`. +- Must include exactly one `--in`, plus `--to` and `--out`. +- `--out` extension must match `--to` (`.jpeg` is accepted for `--to jpg`). +- `--to jpg` flattens alpha onto a white background. ### `svg-validate` @@ -77,12 +78,13 @@ Rules: - Requires exactly one `--in`. - Requires `--out`, and output must be `.svg`. -- Does not support convert-only flags (`--from-svg`, `--to`, `--width`, `--height`). +- Does not support convert-only flags (`--to`, `--width`, `--height`). ## Known removed subcommands The following legacy subcommands are no longer supported and now return usage errors: +- `generate` - `info` - `auto-orient` - `resize` diff --git a/skills/tools/media/image-processing/tests/test_tools_media_image_processing.py b/skills/tools/media/image-processing/tests/test_tools_media_image_processing.py index 39f7ad05..f0d09a06 100644 --- a/skills/tools/media/image-processing/tests/test_tools_media_image_processing.py +++ b/skills/tools/media/image-processing/tests/test_tools_media_image_processing.py @@ -88,7 +88,7 @@ def test_image_processing_help() -> None: @pytest.mark.parametrize( "removed", - ["info", "auto-orient", "resize", "rotate", "crop", "pad", "flip", "flop", "optimize"], + ["generate", "info", "auto-orient", "resize", "rotate", "crop", "pad", "flip", "flop", "optimize"], ) def test_removed_subcommands_are_usage_errors(removed: str) -> None: proc = _run([removed]) @@ -97,16 +97,16 @@ def test_removed_subcommands_are_usage_errors(removed: str) -> None: assert "invalid value" in stderr or "unrecognized subcommand" in stderr or "unknown subcommand" in stderr -def test_convert_from_svg_to_supported_outputs() -> None: +def test_convert_from_svg_input_to_supported_outputs() -> None: out_dir = _unique_out_dir("convert-supported-outputs") input_svg = _write_svg(out_dir / "icon.svg") - for to, expected_format in [("png", "PNG"), ("webp", "WEBP"), ("svg", "SVG")]: + for to, expected_format in [("png", "PNG"), ("webp", "WEBP"), ("jpg", "JPEG")]: output = out_dir / f"icon-converted.{to}" j = _run_json( [ "convert", - "--from-svg", + "--in", str(input_svg), "--to", to, @@ -115,13 +115,128 @@ def test_convert_from_svg_to_supported_outputs() -> None: ] ) assert j["operation"] == "convert" - assert j["source"]["mode"] == "from_svg" + assert j["source"]["mode"] == "svg" + assert j["source"]["input_format"] == "svg" item = j["items"][0] assert item["status"] == "ok" assert output.is_file() assert item["output_info"]["format"] == expected_format +@pytest.mark.parametrize( + ("fixture_name", "expected_input_format", "to", "out_name", "expected_output_format"), + [ + ("fixture_80x60.png", "png", "webp", "from-png.webp", "WEBP"), + ("fixture_80x60.jpg", "jpg", "png", "from-jpg.png", "PNG"), + ("fixture_80x60.webp", "webp", "jpg", "from-webp.jpeg", "JPEG"), + ], +) +def test_convert_supports_raster_inputs( + fixture_name: str, + expected_input_format: str, + to: str, + out_name: str, + expected_output_format: str, +) -> None: + out_dir = _unique_out_dir("convert-raster-inputs") + input_path = _fixtures_dir() / fixture_name + output = out_dir / out_name + + j = _run_json( + [ + "convert", + "--in", + str(input_path), + "--to", + to, + "--out", + str(output), + ] + ) + + assert j["operation"] == "convert" + assert j["source"]["mode"] == "raster" + assert j["source"]["input_format"] == expected_input_format + item = j["items"][0] + assert item["status"] == "ok" + assert output.is_file() + assert item["output_info"]["format"] == expected_output_format + + +def test_convert_jpg_output_flattens_alpha_and_accepts_jpeg_extension() -> None: + out_dir = _unique_out_dir("convert-jpg-background") + input_png = _fixtures_dir() / "fixture_80x60_alpha.png" + output = out_dir / "flattened.jpeg" + + j = _run_json( + [ + "convert", + "--in", + str(input_png), + "--to", + "jpg", + "--out", + str(output), + ] + ) + + assert j["options"]["background"] == "white" + item = j["items"][0] + assert item["status"] == "ok" + assert item["output_info"]["format"] == "JPEG" + assert item["output_info"]["alpha"] is False + assert output.is_file() + + +def test_convert_json_writes_summary_artifact_without_report() -> None: + out_dir = _unique_out_dir("convert-json-summary") + input_png = _fixtures_dir() / "fixture_80x60.png" + output = out_dir / "summary.webp" + + j = _run_json( + [ + "convert", + "--in", + str(input_png), + "--to", + "webp", + "--out", + str(output), + ] + ) + + summary_path = _repo_root() / "out" / "image-processing" / "runs" / j["run_id"] / "summary.json" + assert j["report_path"] is None + assert summary_path.is_file() + assert output.is_file() + + +def test_convert_dry_run_does_not_write_output_file() -> None: + out_dir = _unique_out_dir("convert-dry-run") + input_png = _fixtures_dir() / "fixture_80x60.png" + output = out_dir / "dry-run.webp" + + j = _run_json( + [ + "convert", + "--in", + str(input_png), + "--to", + "webp", + "--out", + str(output), + "--dry-run", + ] + ) + + summary_path = _repo_root() / "out" / "image-processing" / "runs" / j["run_id"] / "summary.json" + assert j["dry_run"] is True + assert j["items"][0]["status"] == "ok" + assert j["items"][0]["output_info"] is None + assert not output.exists() + assert summary_path.is_file() + + def test_convert_supports_raster_dimension_override() -> None: out_dir = _unique_out_dir("convert-raster-dimensions") input_svg = _write_svg(out_dir / "icon.svg") @@ -129,7 +244,7 @@ def test_convert_supports_raster_dimension_override() -> None: width_only = _run_json( [ "convert", - "--from-svg", + "--in", str(input_svg), "--to", "png", @@ -145,7 +260,7 @@ def test_convert_supports_raster_dimension_override() -> None: exact_box = _run_json( [ "convert", - "--from-svg", + "--in", str(input_svg), "--to", "png", @@ -161,21 +276,21 @@ def test_convert_supports_raster_dimension_override() -> None: assert exact_box["items"][0]["output_info"]["height"] == 512 -def test_convert_requires_from_svg() -> None: +def test_convert_requires_in() -> None: fixture = _fixtures_dir() / "fixture_80x60.png" - proc = _run(["convert", "--in", str(fixture), "--to", "png", "--out", str(fixture), "--json"]) + proc = _run(["convert", "--to", "png", "--out", str(fixture), "--json"]) assert proc.returncode == 2 - assert "convert requires --from-svg" in proc.stderr + assert "convert requires exactly one --in" in proc.stderr -def test_convert_rejects_in_flag_and_invalid_target() -> None: +def test_convert_rejects_multiple_inputs_and_invalid_target() -> None: out_dir = _unique_out_dir("convert-invalid-flags") input_svg = _write_svg(out_dir / "icon.svg") - with_in = _run( + with_multiple_inputs = _run( [ "convert", - "--from-svg", + "--in", str(input_svg), "--in", str(input_svg), @@ -186,37 +301,37 @@ def test_convert_rejects_in_flag_and_invalid_target() -> None: "--json", ] ) - assert with_in.returncode == 2 - assert "does not support --in" in with_in.stderr + assert with_multiple_inputs.returncode == 2 + assert "convert requires exactly one --in" in with_multiple_inputs.stderr invalid_target = _run( [ "convert", - "--from-svg", + "--in", str(input_svg), "--to", - "jpg", + "gif", "--out", - str(out_dir / "icon.jpg"), + str(out_dir / "icon.gif"), "--json", ] ) assert invalid_target.returncode == 2 - assert "png|webp|svg" in invalid_target.stderr + assert "png|webp|jpg" in invalid_target.stderr def test_convert_rejects_missing_out_and_extension_mismatch() -> None: out_dir = _unique_out_dir("convert-out-contract") input_svg = _write_svg(out_dir / "icon.svg") - missing_out = _run(["convert", "--from-svg", str(input_svg), "--to", "png", "--json"]) + missing_out = _run(["convert", "--in", str(input_svg), "--to", "png", "--json"]) assert missing_out.returncode == 2 assert "requires --out" in missing_out.stderr mismatch = _run( [ "convert", - "--from-svg", + "--in", str(input_svg), "--to", "png", @@ -233,39 +348,39 @@ def test_convert_rejects_invalid_dimension_contracts() -> None: out_dir = _unique_out_dir("convert-dimension-contracts") input_svg = _write_svg(out_dir / "icon.svg") - svg_with_width = _run( + width_zero = _run( [ "convert", - "--from-svg", + "--in", str(input_svg), "--to", - "svg", + "png", "--out", - str(out_dir / "icon.svg"), + str(out_dir / "icon.png"), "--width", - "256", + "0", "--json", ] ) - assert svg_with_width.returncode == 2 - assert "does not support --width/--height" in svg_with_width.stderr + assert width_zero.returncode == 2 + assert "--width must be > 0" in width_zero.stderr - width_zero = _run( + height_zero = _run( [ "convert", - "--from-svg", + "--in", str(input_svg), "--to", "png", "--out", str(out_dir / "icon.png"), - "--width", + "--height", "0", "--json", ] ) - assert width_zero.returncode == 2 - assert "--width must be > 0" in width_zero.stderr + assert height_zero.returncode == 2 + assert "--height must be > 0" in height_zero.stderr def test_convert_overwrite_flag_controls_output_replacement() -> None: @@ -277,7 +392,7 @@ def test_convert_overwrite_flag_controls_output_replacement() -> None: blocked = _run( [ "convert", - "--from-svg", + "--in", str(input_svg), "--to", "png", @@ -292,7 +407,7 @@ def test_convert_overwrite_flag_controls_output_replacement() -> None: replaced = _run_json( [ "convert", - "--from-svg", + "--in", str(input_svg), "--to", "png", @@ -313,7 +428,8 @@ def test_svg_validate_success() -> None: j = _run_json(["svg-validate", "--in", str(input_svg), "--out", str(output_svg)]) assert j["operation"] == "svg-validate" - assert j["source"]["mode"] == "svg_validate" + assert j["source"]["mode"] == "svg" + assert j["source"]["input_format"] == "svg" assert output_svg.is_file() assert j["items"][0]["status"] == "ok" assert j["items"][0]["output_info"]["format"] == "SVG" @@ -343,6 +459,15 @@ def test_svg_validate_requires_single_input_and_out() -> None: assert "requires exactly one --in" in many_inputs.stderr +def test_svg_validate_requires_svg_output_extension() -> None: + out_dir = _unique_out_dir("svg-validate-out-extension") + input_svg = _write_svg(out_dir / "valid.svg") + + proc = _run(["svg-validate", "--in", str(input_svg), "--out", str(out_dir / "valid.txt"), "--json"]) + assert proc.returncode == 2 + assert "svg-validate --out must end with .svg" in proc.stderr + + def test_svg_validate_rejects_convert_only_flags() -> None: out_dir = _unique_out_dir("svg-validate-flags") one = _write_svg(out_dir / "one.svg") @@ -389,7 +514,7 @@ def test_report_written() -> None: j = _run_json( [ "convert", - "--from-svg", + "--in", str(input_svg), "--to", "webp", @@ -399,5 +524,7 @@ def test_report_written() -> None: ] ) report_path = j.get("report_path") + summary_path = _repo_root() / "out" / "image-processing" / "runs" / j["run_id"] / "summary.json" assert isinstance(report_path, str) and report_path assert _as_path_in_repo(report_path).is_file() + assert summary_path.is_file()