← Files note Workspace|ROGNALIAARCHIVED FILE

skills/note-image/scripts/save_image_asset.py

8.63 KB · Oct 5, 2026 · 18:34 UTC

↓ Download file

#!/usr/bin/env python3
"""Save one QA-passed image asset and its immutable metadata."""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path
from typing import Any, Dict, Optional

from image_common import (
    ImageDataError,
    append_image_registry,
    asset_payload_hash,
    brief_dimensions,
    build_asset_metadata,
    build_image_event,
    image_lock,
    json_text,
    load_brief,
    load_image_events,
    load_json_argument,
    normalize_qa,
    parse_timestamp,
    read_image,
    resolve_workspace,
    safe_article_directory,
    sha256_bytes,
    sha256_text,
    timestamp_moment,
    validate_generation_timeline,
    validate_image_data,
    write_new_bytes,
    write_new_text,
)
from png_resize import PngResizeError, resize_png_bytes


def _parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Save one QA-passed note image without external actions."
    )
    parser.add_argument("workspace", help="Absolute path to the selected workspace")
    parser.add_argument("--brief", required=True, help="Workspace-relative brief path")
    parser.add_argument("--image-file", required=True, help="Absolute final image path")
    parser.add_argument("--qa", required=True, help="Image QA JSON path, or -")
    parser.add_argument("--timestamp", help="ISO 8601 timestamp override")
    return parser


def _outside_workspace(path: Path, workspace: Path, label: str) -> None:
    if not path.is_absolute():
        raise ImageDataError(f"{label} must be an absolute path")
    try:
        path.resolve().relative_to(workspace)
    except ValueError:
        return
    raise ImageDataError(f"{label} must be outside the managed workspace")


def _output(event: Dict[str, Any], status: str) -> Dict[str, Any]:
    return {
        "status": status,
        "asset_status": event["status"],
        "article_id": event["article_id"],
        "article_revision": event["article_revision"],
        "kind": event["kind"],
        "asset_revision": event["asset_revision"],
        "image_path": event["image_path"],
        "preview_path": event["preview_path"],
        "metadata_path": event["metadata_path"],
        "width": event["width"],
        "height": event["height"],
        "caption": event["caption"],
        "alt": event["alt"],
        "external_actions": [],
    }


def main(argv: Optional[list[str]] = None) -> int:
    args = _parser().parse_args(argv)
    try:
        workspace = resolve_workspace(args.workspace)
        brief, _, brief_hash = load_brief(workspace, args.brief)
        if brief["status"] != "ready_to_generate":
            raise ImageDataError("image generation was unavailable for this brief")

        source_path = Path(args.image_file)
        _outside_workspace(source_path, workspace, "image_file")
        _, image_data, image_format, width, height = read_image(
            args.image_file, "image_file"
        )
        target_width, target_height = brief_dimensions(brief["kind"])
        if (width, height) != (target_width, target_height):
            raise ImageDataError(
                f"image_file must be {target_width}x{target_height} pixels"
            )

        qa = normalize_qa(load_json_argument(args.qa, "image QA config"), brief)
        validate_generation_timeline(brief, qa["generated_at"], "image asset")
        saved_at = parse_timestamp(args.timestamp)
        if timestamp_moment(qa["reviewed_at"], "reviewed_at") > timestamp_moment(
            saved_at, "saved_at"
        ):
            raise ImageDataError("saved_at must not be earlier than reviewed_at")
        image_hash = sha256_bytes(image_data)
        preview_data: Optional[bytes] = None
        preview_hash: Optional[str] = None
        if brief["kind"] == "thumbnail":
            if image_format != "png":
                raise ImageDataError(
                    "thumbnail image_file must be PNG so its reviewed preview can be reproduced"
                )
            try:
                preview_data = resize_png_bytes(image_data, 320, 168)
            except PngResizeError as exc:
                raise ImageDataError(
                    "thumbnail PNG cannot create the required 320x168 preview: " + str(exc)
                ) from exc
            preview_hash = sha256_bytes(preview_data)
            if qa["preview_sha256"] != preview_hash:
                raise ImageDataError(
                    "preview_sha256 does not match the preview derived from image_file"
                )
        payload_hash = asset_payload_hash(qa, brief_hash, image_hash)

        with image_lock(workspace, saved_at):
            existing_errors = validate_image_data(workspace)
            if existing_errors:
                raise ImageDataError(
                    "existing image data is invalid: " + "; ".join(existing_errors)
                )
            events = load_image_events(workspace)
            for event in events:
                if event["request_id"] != qa["request_id"]:
                    continue
                if event["payload_sha256"] == payload_hash:
                    print(json.dumps(_output(event, "duplicate"), ensure_ascii=False, indent=2))
                    return 0
                raise ImageDataError("request_id is already used for a different image asset")

            revisions = [
                int(event["asset_revision"])
                for event in events
                if event["article_id"] == brief["article_id"]
                and event["kind"] == brief["kind"]
            ]
            revision = max(revisions, default=0) + 1
            stem = f"{brief['kind']}-r{revision:03d}"
            extension = ".png" if image_format == "png" else ".jpg"
            image_relative = f"assets/{brief['article_id']}/{stem}{extension}"
            preview_relative = (
                f"assets/{brief['article_id']}/{stem}-preview.png"
                if preview_data is not None
                else None
            )
            metadata_relative = f"assets/{brief['article_id']}/{stem}.json"
            metadata = build_asset_metadata(
                brief,
                qa,
                asset_revision=revision,
                saved_at=saved_at,
                brief_sha256=brief_hash,
                image_path=image_relative,
                image_sha256=image_hash,
                image_format=image_format,
                width=width,
                height=height,
                preview_path=preview_relative,
                preview_sha256=preview_hash,
                preview_source_sha256=image_hash if preview_data is not None else None,
                preview_width=320 if preview_data is not None else None,
                preview_height=168 if preview_data is not None else None,
                metadata_path=metadata_relative,
                payload_sha256=payload_hash,
            )
            metadata_text = json_text(metadata)
            event = build_image_event(metadata, sha256_text(metadata_text))
            article_directory = safe_article_directory(
                workspace, brief["article_id"], create=False
            )
            image_path = workspace / image_relative
            preview_path = workspace / preview_relative if preview_relative else None
            metadata_path = workspace / metadata_relative
            registry_path = article_directory / "registry.jsonl"
            created_paths = []
            try:
                write_new_bytes(image_path, image_data)
                created_paths.append(image_path)
                if preview_path is not None and preview_data is not None:
                    write_new_bytes(preview_path, preview_data)
                    created_paths.append(preview_path)
                write_new_text(metadata_path, metadata_text)
                created_paths.append(metadata_path)
                append_image_registry(registry_path, event)
            except Exception:
                for path in reversed(created_paths):
                    try:
                        path.unlink()
                    except FileNotFoundError:
                        pass
                raise

            final_errors = validate_image_data(workspace)
            if final_errors:
                raise ImageDataError(
                    "saved image asset failed validation: " + "; ".join(final_errors)
                )

        print(json.dumps(_output(event, "created"), ensure_ascii=False, indent=2))
        return 0
    except (OSError, UnicodeError, ImageDataError, json.JSONDecodeError) as exc:
        print(
            json.dumps({"status": "error", "error": str(exc)}, ensure_ascii=False),
            file=sys.stderr,
        )
        return 2


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: 5414bd5684c37c827ac1134e30bdae7c79464b6b2bb44ad44cb44e9ac57a0087