diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94347f4..3846448 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,3 +25,28 @@ jobs: - name: Run quality baseline run: python check.py + + browser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: requirements.txt + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - name: Install application and browser dependencies + run: | + python -m pip install -r requirements.txt + npm ci + npx playwright install --with-deps chromium + + - name: Run browser workflow + run: npm run test:browser diff --git a/.gitignore b/.gitignore index 90e8ed3..a3dac47 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ __pycache__/ coverage.xml coverage.json htmlcov/ +node_modules/ +playwright-report/ +test-results/ build/ dist/ diff --git a/README.md b/README.md index b8f3faf..a09a515 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,10 @@ scan, distortion, and signal treatments for images and video. ![App preview](./glitchcraft-ss01.png) -The current interface is the original Flask/jQuery workflow. The backend now uses -a typed, deterministic RGB effect engine so the same recipe and seed produce the -same frame output across runs and processes. +The current interface is a transitional Flask/jQuery workspace. Images upload +once, keep their original visible, update a deterministic processed preview as +controls change, and create a downloadable PNG only when explicitly exported. +Video remains on the legacy preview and background-processing path. ## Requirements @@ -35,11 +36,24 @@ python app.py Open . The default port and existing routes are preserved. Uploaded and generated files are temporary and ignored by Git. +### Image workflow + +- Upload PNG, JPEG, BMP, or TIFF content up to 40 million pixels. +- Compare the original and processed image inline. +- Adjust controls to request a debounced preview without re-uploading. +- Reuse the same recipe and seed for preview and full-resolution export. +- Choose **Export PNG**, then explicitly download the managed result. + +Automatic previews return temporary PNG bytes and do not create output files. +Sources and exported outputs use opaque, in-memory identity and expire from +temporary storage. Their IDs do not survive an application restart. + ## Validate ```powershell python check.py git diff --check +npm run test:browser ``` The validation command runs formatting, lint, strict type checking, tests, @@ -49,6 +63,7 @@ statement and branch coverage, and repository consistency checks. - [Architecture](docs/architecture.md) - [Effect engine](docs/effect-engine.md) +- [Image workflow and API](docs/image-workflow.md) - [Testing](docs/testing.md) - [Product direction](docs/product-direction.md) @@ -56,9 +71,11 @@ The internal recipe contract supports a schema version, root seed, ordered effec instances, stable IDs, enabled states, and strict effect-specific parameters. User-facing recipe import/export and seed controls are intentionally deferred. -Task state remains in memory with no cancellation, bounded queue, or restart -recovery. Storage remains temporary and path-oriented, audio handling is -unchanged, and true geometric distortion/datamoshing are not implemented. +Task, image-source, and output identity remain in memory with no cancellation, +bounded queue, library, or restart recovery. Storage remains temporary, video +and audio behavior are unchanged, and true geometric distortion/datamoshing are +not implemented. jQuery remains the one CDN dependency for the legacy video +path; Bootstrap and Google Fonts are no longer used. ## License diff --git a/app.py b/app.py index 3e7a49f..896b4eb 100644 --- a/app.py +++ b/app.py @@ -5,10 +5,26 @@ from glitchcraft import create_app from glitchcraft.cleanup import CleanupScheduler +from glitchcraft.image_assets import ImageOutputStore, ImageSourceStore app = create_app() +source_store: ImageSourceStore = app.extensions["image_source_store"] +output_store: ImageOutputStore = app.extensions["image_output_store"] + + +def cleanup_image_records() -> None: + source_store.cleanup_expired() + output_store.cleanup_expired() + + +def protected_image_paths() -> set[Path]: + return source_store.known_paths() | output_store.known_paths() + + cleanup = CleanupScheduler( - Path(app.config[key]) for key in ("UPLOAD_FOLDER", "OUTPUT_FOLDER", "PREVIEW_FOLDER") + (Path(app.config[key]) for key in ("UPLOAD_FOLDER", "OUTPUT_FOLDER", "PREVIEW_FOLDER")), + protected_paths=protected_image_paths, + before_cleanup=cleanup_image_records, ) if __name__ == "__main__": diff --git a/check.py b/check.py index e9a0105..409eb0b 100644 --- a/check.py +++ b/check.py @@ -18,6 +18,8 @@ def repository_consistency() -> None: Path("glitchcraft/contracts/effects.py"), Path("glitchcraft/effects/registry.py"), Path("docs/effect-engine.md"), + Path("docs/image-workflow.md"), + Path("static/app.js"), ] missing = [str(path) for path in required if not path.is_file()] if missing: diff --git a/docs/architecture.md b/docs/architecture.md index 128d64e..be74eff 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,12 +12,15 @@ The package boundaries are: - `media`: Pillow/OpenCV color boundaries, file I/O, and FFmpeg invocation. - `web`: compatibility routes and request-to-recipe translation. - `tasks`: the temporary thread-safe in-memory task store. +- `image_assets`: opaque, thread-safe identity and leases for temporary image + sources and explicit outputs. - `cleanup`: launcher-owned expiration of temporary local files. All processing is local. The application currently trusts the local operator and does not provide authentication, content isolation, persistent source identity, or a hardened multi-user deployment model. -Task state is process-local. A restart loses status, and there is no bounded queue, -cancellation, recovery, or persistence. These limitations are intentional in this -foundation PR. +Task and image identity state are process-local. A restart loses status, source +IDs, and output IDs. There is no bounded queue, cancellation, recovery, +persistent library, or database. These limitations are intentional in the +current transitional application. diff --git a/docs/image-workflow.md b/docs/image-workflow.md new file mode 100644 index 0000000..74b744d --- /dev/null +++ b/docs/image-workflow.md @@ -0,0 +1,60 @@ +# Source-aware image workflow + +The transitional Flask/jQuery interface now treats image work as a local, +interactive workspace: + +1. Upload a supported static image once. +2. Keep the original visible. +3. Build one version 1 recipe from the controls. +4. Request debounced inline previews as controls change. +5. Compare original and processed frames. +6. Export a PNG only after an explicit user action. + +Automatic previews return lossless PNG bytes directly. They do not create output +files. Export runs the same full-resolution source, recipe, seed, frame index, and +`apply_effect_stack` engine, then creates one temporary managed PNG. + +Preview currently processes the full-resolution image. A future workspace may +introduce a bounded preview representation without changing export semantics. + +## Identity and lifecycle + +`ImageSourceStore` and `ImageOutputStore` issue unpredictable opaque identifiers. +Browser requests contain those IDs rather than server paths or managed +filenames. The stores are thread-safe and remain in memory. Records protect their +files from generic cleanup while active and expire after 24 hours by default. +Missing files and expired IDs become controlled 404 responses. + +Sources and outputs are not persistent across application restart. This is +temporary local storage, not a project library. + +Uploads are extension-checked before writing, decoded with Pillow, restricted to +PNG, JPEG, BMP, or TIFF, limited to 40 million pixels, and guarded against +decompression-bomb conditions. Animated or mismatched content is rejected. + +## API + +- `POST /api/image-sources` — multipart source upload; returns metadata, seed, + opaque source ID, and inline original URL. +- `GET /api/image-sources//original` — inline original with `no-store`. +- `POST /api/image-sources//preview` — strict `{ "recipe": ... }` JSON; + returns inline PNG bytes without creating an output. +- `POST /api/image-sources//export` — the same strict recipe; returns an + opaque output ID, display filename, inline URL, and download URL. +- `GET /api/image-outputs/` — inline exported PNG. +- `GET /api/image-outputs//download` — the same PNG as an attachment. + +The legacy routes remain, and video preview/processing continues through the +legacy path. + +## Transitional frontend + +The image-side script owns explicit source, seed, recipe, request, preview, +export, and error state. A 175 ms debounce limits slider requests. An +`AbortController` cancels prior requests, a monotonically increasing revision +rejects late results, and replaced object URLs are revoked. + +jQuery remains CDN-hosted for the legacy video path. Bootstrap and Google Fonts +have been removed; the image workspace uses a system font stack and repository +CSS. The planned family-aligned React workspace and final jQuery removal remain +future work. diff --git a/docs/testing.md b/docs/testing.md index 5591f28..2a0429a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -10,7 +10,7 @@ python check.py `check.py` runs Ruff formatting, Ruff lint, strict Mypy, Pytest with statement and branch coverage, and lightweight repository consistency checks. Coverage must be -at least 85% for the `glitchcraft` package. +at least 95% for the `glitchcraft` package. Focused suites can be run with: @@ -19,8 +19,27 @@ python -m pytest tests/test_effects.py tests/test_randomness.py python -m pytest tests/test_contracts.py python -m pytest tests/test_routes.py python -m pytest tests/test_media.py +python -m pytest tests/test_image_assets.py tests/test_image_workflow.py ``` Tests use synthetic NumPy frames and temporary directories. The narrowly marked FFmpeg integration test is skipped with an explicit reason when FFmpeg is not available. Pure engine tests never require FFmpeg, Node, Docker, or a browser. + +## Browser workflow + +Install the locked Node dependencies and Chromium once, then run the separate +Playwright workflow: + +```powershell +npm ci +npx playwright install chromium +npm run test:browser +``` + +The browser suite starts the Flask application and generates its image fixture +in memory. It checks inline original/result rendering, no preview navigation, +debounced newest-result behavior, explicit export/download, reset, keyboard +operation, responsive containment at 320/768/1024/desktop widths, and axe results +with no serious or critical violations. GitHub Actions keeps this browser job +separate from the fast Python quality job. diff --git a/glitchcraft/application.py b/glitchcraft/application.py index c4cc0fd..b43ad36 100644 --- a/glitchcraft/application.py +++ b/glitchcraft/application.py @@ -5,6 +5,7 @@ from flask import Flask +from glitchcraft.image_assets import ImageOutputStore, ImageSourceStore from glitchcraft.tasks import TaskStore from glitchcraft.web.routes import bp @@ -21,11 +22,19 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: OUTPUT_FOLDER=str(project_root / "outputs"), PREVIEW_FOLDER=str(project_root / "static" / "previews"), MAX_CONTENT_LENGTH=1024 * 1024 * 1024, + MAX_IMAGE_PIXELS=40_000_000, + IMAGE_ASSET_MAX_AGE=24 * 60 * 60, ) if config: app.config.update(config) for key in ("UPLOAD_FOLDER", "OUTPUT_FOLDER", "PREVIEW_FOLDER"): Path(app.config[key]).mkdir(parents=True, exist_ok=True) app.extensions["task_store"] = TaskStore() + app.extensions["image_source_store"] = ImageSourceStore( + maximum_age=float(app.config["IMAGE_ASSET_MAX_AGE"]) + ) + app.extensions["image_output_store"] = ImageOutputStore( + maximum_age=float(app.config["IMAGE_ASSET_MAX_AGE"]) + ) app.register_blueprint(bp) return app diff --git a/glitchcraft/cleanup.py b/glitchcraft/cleanup.py index 8070f97..2dea186 100644 --- a/glitchcraft/cleanup.py +++ b/glitchcraft/cleanup.py @@ -2,18 +2,23 @@ import logging import time -from collections.abc import Iterable +from collections.abc import Callable, Iterable from pathlib import Path from threading import Event, Thread logger = logging.getLogger(__name__) -def remove_expired_files(folders: Iterable[Path], maximum_age: float = 86400) -> None: +def remove_expired_files( + folders: Iterable[Path], + maximum_age: float = 86400, + protected_paths: Iterable[Path] = (), +) -> None: cutoff = time.time() - maximum_age + protected = {path.resolve() for path in protected_paths} for folder in folders: for path in folder.iterdir(): - if path.is_file() and path.stat().st_mtime < cutoff: + if path.is_file() and path.resolve() not in protected and path.stat().st_mtime < cutoff: try: path.unlink() except OSError: @@ -21,9 +26,17 @@ def remove_expired_files(folders: Iterable[Path], maximum_age: float = 86400) -> class CleanupScheduler: - def __init__(self, folders: Iterable[Path], interval: float = 3600) -> None: + def __init__( + self, + folders: Iterable[Path], + interval: float = 3600, + protected_paths: Callable[[], Iterable[Path]] | None = None, + before_cleanup: Callable[[], None] | None = None, + ) -> None: self.folders = tuple(folders) self.interval = interval + self.protected_paths = protected_paths or (lambda: ()) + self.before_cleanup = before_cleanup or (lambda: None) self._stop = Event() self._thread = Thread(target=self._run, name="glitchcraft-cleanup", daemon=True) @@ -36,4 +49,8 @@ def stop(self) -> None: def _run(self) -> None: while not self._stop.wait(self.interval): - remove_expired_files(self.folders) + self.before_cleanup() + remove_expired_files( + self.folders, + protected_paths=self.protected_paths(), + ) diff --git a/glitchcraft/contracts/image_workflow.py b/glitchcraft/contracts/image_workflow.py new file mode 100644 index 0000000..1cc2173 --- /dev/null +++ b/glitchcraft/contracts/image_workflow.py @@ -0,0 +1,15 @@ +"""Contracts for source-aware image preview and export.""" + +from typing import Annotated + +from pydantic import Field + +from glitchcraft.contracts.effects import MAX_SEED, ContractModel, Recipe + + +class ImageRecipeRequest(ContractModel): + recipe: Recipe + + +class ImageSourceOptions(ContractModel): + seed: Annotated[int | None, Field(ge=0, le=MAX_SEED)] = None diff --git a/glitchcraft/image_assets.py b/glitchcraft/image_assets.py new file mode 100644 index 0000000..4be69f0 --- /dev/null +++ b/glitchcraft/image_assets.py @@ -0,0 +1,167 @@ +"""Opaque, thread-safe identity for temporary image sources and outputs.""" + +from __future__ import annotations + +import secrets +import time +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from threading import RLock +from typing import Generic, TypeVar + +RecordT = TypeVar("RecordT", bound="ImageAssetRecord") + + +@dataclass(frozen=True) +class ImageAssetRecord: + id: str + path: Path + display_name: str + width: int + height: int + created_at: float + + +@dataclass(frozen=True) +class ImageSourceRecord(ImageAssetRecord): + original_name: str + image_format: str + mime_type: str + seed: int + + +@dataclass(frozen=True) +class ImageOutputRecord(ImageAssetRecord): + source_id: str + recipe_json: str + seed: int + mime_type: str = "image/png" + + +class _ImageAssetStore(Generic[RecordT]): + """Store records independently of paths visible to the browser.""" + + def __init__(self, maximum_age: float = 86400) -> None: + self.maximum_age = maximum_age + self._records: dict[str, RecordT] = {} + self._leases: dict[str, int] = {} + self._lock = RLock() + + def _new_id(self) -> str: + return secrets.token_urlsafe(24) + + def _add(self, record: RecordT) -> RecordT: + with self._lock: + self._records[record.id] = record + self._leases[record.id] = 0 + return record + + def get(self, asset_id: str) -> RecordT | None: + with self._lock: + record = self._records.get(asset_id) + if record is None or self._is_expired(record, time.time()): + return None + if not record.path.is_file(): + self._discard_locked(asset_id) + return None + return record + + @contextmanager + def lease(self, asset_id: str) -> Iterator[RecordT]: + with self._lock: + record = self.get(asset_id) + if record is None: + raise KeyError(asset_id) + self._leases[asset_id] += 1 + try: + yield record + finally: + with self._lock: + if asset_id in self._leases: + self._leases[asset_id] = max(0, self._leases[asset_id] - 1) + + def known_paths(self) -> set[Path]: + with self._lock: + return {record.path.resolve() for record in self._records.values()} + + def cleanup_expired(self, now: float | None = None) -> int: + current = time.time() if now is None else now + removed = 0 + with self._lock: + for asset_id, record in list(self._records.items()): + if self._leases[asset_id] == 0 and self._is_expired(record, current): + record.path.unlink(missing_ok=True) + self._discard_locked(asset_id) + removed += 1 + return removed + + def __len__(self) -> int: + with self._lock: + return len(self._records) + + def _is_expired(self, record: RecordT, now: float) -> bool: + return now - record.created_at >= self.maximum_age + + def _discard_locked(self, asset_id: str) -> None: + self._records.pop(asset_id, None) + self._leases.pop(asset_id, None) + + +class ImageSourceStore(_ImageAssetStore[ImageSourceRecord]): + def create( + self, + *, + path: Path, + original_name: str, + display_name: str, + width: int, + height: int, + image_format: str, + mime_type: str, + seed: int, + created_at: float | None = None, + ) -> ImageSourceRecord: + return self._add( + ImageSourceRecord( + id=self._new_id(), + path=path, + original_name=original_name, + display_name=display_name, + width=width, + height=height, + image_format=image_format, + mime_type=mime_type, + seed=seed, + created_at=time.time() if created_at is None else created_at, + ) + ) + + +class ImageOutputStore(_ImageAssetStore[ImageOutputRecord]): + def create( + self, + *, + path: Path, + source_id: str, + display_name: str, + recipe_json: str, + seed: int, + width: int, + height: int, + created_at: float | None = None, + ) -> ImageOutputRecord: + return self._add( + ImageOutputRecord( + id=self._new_id(), + path=path, + source_id=source_id, + display_name=display_name, + recipe_json=recipe_json, + seed=seed, + width=width, + height=height, + created_at=time.time() if created_at is None else created_at, + ) + ) diff --git a/glitchcraft/media/image_io.py b/glitchcraft/media/image_io.py index 81d4eca..2fe8555 100644 --- a/glitchcraft/media/image_io.py +++ b/glitchcraft/media/image_io.py @@ -1,5 +1,8 @@ """Pillow image I/O at the canonical RGB boundary.""" +import warnings +from dataclasses import dataclass +from io import BytesIO from pathlib import Path import numpy as np @@ -8,6 +11,46 @@ from glitchcraft.errors import MediaReadError, MediaWriteError from glitchcraft.media.color import RGBFrame, validate_rgb +SUPPORTED_IMAGE_FORMATS = { + "PNG": ("image/png", frozenset({"png"})), + "JPEG": ("image/jpeg", frozenset({"jpg", "jpeg"})), + "BMP": ("image/bmp", frozenset({"bmp"})), + "TIFF": ("image/tiff", frozenset({"tif", "tiff"})), +} + + +@dataclass(frozen=True) +class ImageMetadata: + width: int + height: int + image_format: str + mime_type: str + + +def inspect_image(path: Path, extension: str, maximum_pixels: int) -> ImageMetadata: + """Decode and validate a supported static image without exposing Pillow errors.""" + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", Image.DecompressionBombWarning) + with Image.open(path) as image: + image_format = image.format or "" + if image_format not in SUPPORTED_IMAGE_FORMATS: + raise MediaReadError("The image format is not supported.") + mime_type, extensions = SUPPORTED_IMAGE_FORMATS[image_format] + if extension.lower() not in extensions: + raise MediaReadError("The image extension does not match its content.") + width, height = image.size + if width <= 0 or height <= 0 or width * height > maximum_pixels: + raise MediaReadError("The image exceeds the maximum pixel count.") + if getattr(image, "is_animated", False): + raise MediaReadError("Animated images are not supported.") + image.convert("RGB").load() + return ImageMetadata(width, height, image_format, mime_type) + except (Image.DecompressionBombError, Image.DecompressionBombWarning) as exc: + raise MediaReadError("The image exceeds safe decoding limits.") from exc + except (OSError, UnidentifiedImageError) as exc: + raise MediaReadError("The image could not be decoded.") from exc + def load_image_rgb(path: Path) -> RGBFrame: try: @@ -22,3 +65,12 @@ def save_image_rgb(frame_rgb: RGBFrame, path: Path) -> None: Image.fromarray(validate_rgb(frame_rgb)).save(path) except OSError as exc: raise MediaWriteError("The processed image could not be saved.") from exc + + +def encode_png(frame_rgb: RGBFrame) -> bytes: + try: + output = BytesIO() + Image.fromarray(validate_rgb(frame_rgb)).save(output, format="PNG") + return output.getvalue() + except OSError as exc: + raise MediaWriteError("The preview image could not be encoded.") from exc diff --git a/glitchcraft/web/routes.py b/glitchcraft/web/routes.py index ff6ce93..224643d 100644 --- a/glitchcraft/web/routes.py +++ b/glitchcraft/web/routes.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import secrets import uuid from pathlib import Path from threading import Thread @@ -15,6 +16,7 @@ jsonify, render_template, request, + send_file, send_from_directory, url_for, ) @@ -22,19 +24,34 @@ from werkzeug.datastructures import FileStorage from werkzeug.utils import secure_filename -from glitchcraft.contracts.effects import Recipe +from glitchcraft.contracts.effects import MAX_SEED, Recipe +from glitchcraft.contracts.image_workflow import ImageRecipeRequest, ImageSourceOptions from glitchcraft.contracts.legacy import FullVideoRequest, LegacyParameters, UploadMode from glitchcraft.effects.engine import apply_effect_stack from glitchcraft.errors import GlitchCraftError +from glitchcraft.image_assets import ( + ImageOutputRecord, + ImageOutputStore, + ImageSourceRecord, + ImageSourceStore, +) from glitchcraft.media.ffmpeg import reencode_for_browser -from glitchcraft.media.image_io import load_image_rgb, save_image_rgb +from glitchcraft.media.image_io import ( + SUPPORTED_IMAGE_FORMATS, + encode_png, + inspect_image, + load_image_rgb, + save_image_rgb, +) from glitchcraft.media.video import create_video_preview, process_video from glitchcraft.tasks import TaskStore logger = logging.getLogger(__name__) bp = Blueprint("glitchcraft", __name__) -IMAGE_EXTENSIONS = frozenset({"png", "jpg", "jpeg", "bmp", "tiff"}) +IMAGE_EXTENSIONS = frozenset( + extension for _, extensions in SUPPORTED_IMAGE_FORMATS.values() for extension in extensions +) VIDEO_EXTENSIONS = frozenset({"mp4", "avi", "mov", "mkv"}) PARAMETER_FIELDS = set(LegacyParameters.model_fields) @@ -104,6 +121,36 @@ def _task_store() -> TaskStore: return cast(TaskStore, current_app.extensions["task_store"]) +def _source_store() -> ImageSourceStore: + return cast(ImageSourceStore, current_app.extensions["image_source_store"]) + + +def _output_store() -> ImageOutputStore: + return cast(ImageOutputStore, current_app.extensions["image_output_store"]) + + +def _json_not_found(asset: str) -> tuple[Response, int]: + return jsonify(status="error", message=f"Unknown or expired image {asset}."), 404 + + +def _no_store(response: Response) -> Response: + response.headers["Cache-Control"] = "no-store, max-age=0" + response.headers["Pragma"] = "no-cache" + return response + + +def _parse_image_request() -> ImageRecipeRequest: + data = request.get_json(silent=True) + if not isinstance(data, dict): + raise ValueError("A valid JSON object is required.") + return ImageRecipeRequest.model_validate(data) + + +def _process_source(record: ImageSourceRecord, recipe: Recipe) -> bytes: + frame = load_image_rgb(record.path) + return encode_png(apply_effect_stack(frame, recipe, frame_index=0)) + + def _video_worker( store: TaskStore, task_id: str, @@ -141,6 +188,177 @@ def index() -> str: return render_template("index.html") +@bp.post("/api/image-sources") +def create_image_source() -> tuple[Response, int]: + file = request.files.get("image") or request.files.get("input_file") + if file is None or not file.filename: + return jsonify(status="error", message="Select an image to upload."), 400 + unknown_fields = set(request.form) - {"seed"} + if unknown_fields: + return _validation_error(ValueError("Unknown upload fields.")) + original_name = secure_filename(file.filename) + extension = _extension(original_name) + if not original_name or extension not in IMAGE_EXTENSIONS: + return jsonify(status="error", message="Unsupported image extension."), 400 + managed_path = _folder("UPLOAD_FOLDER") / f"image-source-{uuid.uuid4().hex}.{extension}" + try: + options = ImageSourceOptions.model_validate( + {"seed": request.form["seed"]} if "seed" in request.form else {} + ) + file.save(managed_path) + metadata = inspect_image( + managed_path, + extension, + int(current_app.config["MAX_IMAGE_PIXELS"]), + ) + seed = options.seed if options.seed is not None else secrets.randbelow(MAX_SEED + 1) + source = _source_store().create( + path=managed_path, + original_name=original_name, + display_name=original_name, + width=metadata.width, + height=metadata.height, + image_format=metadata.image_format, + mime_type=metadata.mime_type, + seed=seed, + ) + except (ValidationError, ValueError) as exc: + managed_path.unlink(missing_ok=True) + return _validation_error(exc) + except GlitchCraftError as exc: + managed_path.unlink(missing_ok=True) + logger.info("Image source rejected: %s", exc) + return jsonify(status="error", message=str(exc)), 400 + except Exception: + managed_path.unlink(missing_ok=True) + logger.exception("Unexpected image source upload failure") + return jsonify(status="error", message="Image upload failed."), 500 + return ( + jsonify( + sourceId=source.id, + originalName=source.original_name, + width=source.width, + height=source.height, + format=source.image_format, + originalUrl=url_for("glitchcraft.serve_image_source", source_id=source.id), + seed=source.seed, + ), + 201, + ) + + +@bp.get("/api/image-sources//original") +def serve_image_source(source_id: str) -> Response | tuple[Response, int]: + try: + with _source_store().lease(source_id) as source: + response = send_file( + source.path, + mimetype=source.mime_type, + as_attachment=False, + conditional=False, + ) + response.headers["Content-Disposition"] = "inline" + return _no_store(response) + except KeyError: + return _json_not_found("source") + + +@bp.post("/api/image-sources//preview") +def preview_image_source(source_id: str) -> Response | tuple[Response, int]: + try: + image_request = _parse_image_request() + with _source_store().lease(source_id) as source: + png = _process_source(source, image_request.recipe) + return _no_store(Response(png, mimetype="image/png")) + except KeyError: + return _json_not_found("source") + except (ValidationError, ValueError) as exc: + return _validation_error(exc) + except GlitchCraftError: + logger.exception("Image preview failed for source %s", source_id) + return jsonify(status="error", message="Image preview failed."), 500 + except Exception: + logger.exception("Unexpected image preview failure for source %s", source_id) + return jsonify(status="error", message="Image preview failed."), 500 + + +@bp.post("/api/image-sources//export") +def export_image_source(source_id: str) -> tuple[Response, int]: + output_path: Path | None = None + try: + image_request = _parse_image_request() + with _source_store().lease(source_id) as source: + frame = load_image_rgb(source.path) + processed = apply_effect_stack(frame, image_request.recipe, frame_index=0) + output_path = _folder("OUTPUT_FOLDER") / f"image-output-{uuid.uuid4().hex}.png" + save_image_rgb(processed, output_path) + base_name = secure_filename(Path(source.display_name).stem) or "image" + display_name = f"{base_name}-glitchcraft.png" + output = _output_store().create( + path=output_path, + source_id=source.id, + display_name=display_name, + recipe_json=image_request.recipe.model_dump_json(by_alias=True), + seed=image_request.recipe.seed, + width=source.width, + height=source.height, + ) + except KeyError: + return _json_not_found("source") + except (ValidationError, ValueError) as exc: + return _validation_error(exc) + except GlitchCraftError: + if output_path is not None: + output_path.unlink(missing_ok=True) + logger.exception("Image export failed for source %s", source_id) + return jsonify(status="error", message="Image export failed."), 500 + except Exception: + if output_path is not None: + output_path.unlink(missing_ok=True) + logger.exception("Unexpected image export failure for source %s", source_id) + return jsonify(status="error", message="Image export failed."), 500 + return ( + jsonify( + outputId=output.id, + fileName=output.display_name, + previewUrl=url_for("glitchcraft.serve_image_output", output_id=output.id), + downloadUrl=url_for("glitchcraft.download_image_output", output_id=output.id), + ), + 201, + ) + + +def _serve_output(output: ImageOutputRecord, *, as_attachment: bool) -> Response: + response = send_file( + output.path, + mimetype=output.mime_type, + as_attachment=as_attachment, + download_name=output.display_name if as_attachment else None, + conditional=False, + ) + if not as_attachment: + response.headers["Content-Disposition"] = "inline" + return _no_store(response) + + +@bp.get("/api/image-outputs/") +def serve_image_output(output_id: str) -> Response | tuple[Response, int]: + try: + with _output_store().lease(output_id) as output: + return _serve_output(output, as_attachment=False) + except KeyError: + return _json_not_found("output") + + +@bp.get("/api/image-outputs//download") +def download_image_output(output_id: str) -> Response | tuple[Response, int]: + try: + with _output_store().lease(output_id) as output: + return _serve_output(output, as_attachment=True) + except KeyError: + return _json_not_found("output") + + @bp.post("/upload_preview") def upload_preview() -> tuple[Response, int]: file = request.files.get("input_file") @@ -160,6 +378,10 @@ def upload_preview() -> tuple[Response, int]: jsonify( status="success", preview_url=url_for("glitchcraft.download_file", filename=output_filename), + inline_preview_url=url_for( + "glitchcraft.serve_legacy_image", filename=output_filename + ), + download_url=url_for("glitchcraft.download_file", filename=output_filename), seed=recipe.seed, ), 200, @@ -233,6 +455,11 @@ def download_file(filename: str) -> Response: return send_from_directory(_folder("OUTPUT_FOLDER"), filename, as_attachment=True) +@bp.get("/image/") +def serve_legacy_image(filename: str) -> Response: + return send_from_directory(_folder("OUTPUT_FOLDER"), filename, as_attachment=False) + + @bp.get("/video/") def serve_video(filename: str) -> Response | tuple[str, int]: file_path = _folder("OUTPUT_FOLDER") / filename diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0ab0b57 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,100 @@ +{ + "name": "glitchcraft-browser-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "glitchcraft-browser-tests", + "devDependencies": { + "@axe-core/playwright": "4.12.1", + "@playwright/test": "1.62.0" + } + }, + "node_modules/@axe-core/playwright": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", + "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.12.1" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz", + "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..649fd78 --- /dev/null +++ b/package.json @@ -0,0 +1,11 @@ +{ + "name": "glitchcraft-browser-tests", + "private": true, + "scripts": { + "test:browser": "playwright test" + }, + "devDependencies": { + "@axe-core/playwright": "4.12.1", + "@playwright/test": "1.62.0" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..c790192 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,31 @@ +const {defineConfig, devices} = require("@playwright/test"); + +const pythonCommand = + process.platform === "win32" + ? ".venv\\Scripts\\python.exe app.py" + : "python app.py"; + +module.exports = defineConfig({ + testDir: "tests/browser", + outputDir: "test-results", + timeout: 30_000, + fullyParallel: false, + reporter: [["list"], ["html", {open: "never", outputFolder: "playwright-report"}]], + use: { + baseURL: "http://127.0.0.1:5000", + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + webServer: { + command: pythonCommand, + url: "http://127.0.0.1:5000", + reuseExistingServer: false, + timeout: 30_000, + }, + projects: [ + { + name: "chromium", + use: {...devices["Desktop Chrome"]}, + }, + ], +}); diff --git a/pyproject.toml b/pyproject.toml index 8ddb688..9b30ae1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dev = [ include = ["glitchcraft*"] [tool.pytest.ini_options] -addopts = "--strict-markers --cov=glitchcraft --cov-branch --cov-report=term-missing --cov-fail-under=85" +addopts = "--strict-markers --cov=glitchcraft --cov-branch --cov-report=term-missing --cov-fail-under=95" testpaths = ["tests"] markers = [ "ffmpeg: requires an FFmpeg executable", diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..1d8d718 --- /dev/null +++ b/static/app.js @@ -0,0 +1,469 @@ +(() => { + "use strict"; + + const form = document.querySelector("#upload-form"); + const fileInput = document.querySelector("#input_file"); + const submitButton = document.querySelector("#submit-media"); + const imageWorkspace = document.querySelector("#image-workspace"); + const originalImage = document.querySelector("#original-image"); + const processedImage = document.querySelector("#processed-image"); + const previewStatus = document.querySelector("#image-preview-status"); + const notice = document.querySelector("#image-notice"); + const exportButton = document.querySelector("#export-image"); + const downloadImage = document.querySelector("#download-image"); + const newImageButton = document.querySelector("#new-image"); + const sourceMetadata = document.querySelector("#source-metadata"); + const comparisonHeading = document.querySelector("#comparison-heading"); + const uploadStatus = document.querySelector("#upload-status"); + const progressPanel = document.querySelector("#progress-indicator"); + const progressBar = document.querySelector("#progress-bar"); + + const imageState = { + sourceId: null, + originalUrl: null, + originalName: null, + seed: null, + recipe: null, + previewObjectUrl: null, + previewController: null, + previewRevision: 0, + debounceTimer: null, + exporting: false, + error: null, + videoPreview: null, + }; + + // Kept inspectable while this transitional interface remains in one script. + window.__glitchcraftState = imageState; + + const numberValue = (name) => Number(form.elements[name].value); + const enabled = (name) => form.elements[name].checked; + const currentMode = () => form.elements.mode.value; + + function buildRecipe() { + return { + schemaVersion: 1, + seed: imageState.seed, + effects: [ + { + id: "legacy-noise", + type: "noise", + enabled: true, + parameters: { + amount: numberValue("amount"), + strength: numberValue("strength"), + monochromatic: enabled("monochromatic"), + }, + }, + { + id: "legacy-pixelation", + type: "pixelation", + enabled: numberValue("pixel_size") > 1, + parameters: {pixel_size: numberValue("pixel_size")}, + }, + { + id: "legacy-horizontal-glitch", + type: "horizontal_glitch", + enabled: enabled("glitch"), + parameters: { + count: numberValue("glitch_count"), + shift: numberValue("glitch_shift"), + }, + }, + { + id: "legacy-distortion", + type: "frame_shift", + enabled: enabled("distortion"), + parameters: { + max_x: numberValue("distortion_x"), + max_y: numberValue("distortion_y"), + }, + }, + { + id: "legacy-color-bleed", + type: "color_bleed", + enabled: enabled("color_bleed"), + parameters: {max_shift: numberValue("color_bleed_shift")}, + }, + { + id: "legacy-scan-lines", + type: "scan_lines", + enabled: enabled("scan_lines"), + parameters: { + gap: numberValue("scan_line_gap"), + darkness: numberValue("scan_line_darkness"), + }, + }, + { + id: "legacy-static", + type: "static", + enabled: enabled("static"), + parameters: {intensity: numberValue("static_intensity")}, + }, + { + id: "legacy-flicker", + type: "flicker", + enabled: enabled("flicker"), + parameters: { + minimum: numberValue("flicker_min"), + maximum: numberValue("flicker_max"), + }, + }, + ], + }; + } + + function setNotice(message, kind = "status") { + imageState.error = kind === "error" ? message : null; + notice.textContent = message; + notice.dataset.kind = kind; + notice.setAttribute("role", kind === "error" ? "alert" : "status"); + notice.hidden = !message; + } + + async function errorMessage(response, fallback) { + try { + const payload = await response.json(); + return payload.message || fallback; + } catch { + return fallback; + } + } + + function replacePreviewUrl(blob, revision) { + const nextUrl = URL.createObjectURL(blob); + if (imageState.previewObjectUrl) { + URL.revokeObjectURL(imageState.previewObjectUrl); + } + imageState.previewObjectUrl = nextUrl; + processedImage.src = nextUrl; + processedImage.dataset.revision = String(revision); + } + + async function requestPreview() { + if (!imageState.sourceId || currentMode() !== "image") { + return; + } + if (imageState.previewController) { + imageState.previewController.abort(); + } + const revision = ++imageState.previewRevision; + const controller = new AbortController(); + imageState.previewController = controller; + imageState.recipe = buildRecipe(); + imageWorkspace.setAttribute("aria-busy", "true"); + previewStatus.textContent = "Updating preview…"; + + try { + const response = await fetch( + `/api/image-sources/${encodeURIComponent(imageState.sourceId)}/preview`, + { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({recipe: imageState.recipe}), + signal: controller.signal, + }, + ); + if (!response.ok) { + throw new Error(await errorMessage(response, "Preview could not be updated.")); + } + const blob = await response.blob(); + if (revision !== imageState.previewRevision) { + return; + } + replacePreviewUrl(blob, revision); + previewStatus.textContent = "Preview ready"; + setNotice(""); + } catch (error) { + if (error.name !== "AbortError" && revision === imageState.previewRevision) { + previewStatus.textContent = "Preview unavailable"; + setNotice(error.message, "error"); + } + } finally { + if (revision === imageState.previewRevision) { + imageWorkspace.setAttribute("aria-busy", "false"); + imageState.previewController = null; + } + } + } + + function schedulePreview() { + if (!imageState.sourceId || currentMode() !== "image") { + return; + } + window.clearTimeout(imageState.debounceTimer); + imageState.debounceTimer = window.setTimeout(requestPreview, 175); + } + + async function uploadImageSource() { + const file = fileInput.files[0]; + if (!file) { + setNotice("Choose an image before continuing.", "error"); + fileInput.focus(); + return; + } + submitButton.disabled = true; + uploadStatus.textContent = "Uploading source…"; + setNotice(""); + const body = new FormData(); + body.append("image", file); + try { + const response = await fetch("/api/image-sources", {method: "POST", body}); + if (!response.ok) { + throw new Error(await errorMessage(response, "The image could not be uploaded.")); + } + const source = await response.json(); + imageState.sourceId = source.sourceId; + imageState.originalUrl = source.originalUrl; + imageState.originalName = source.originalName; + imageState.seed = source.seed; + imageState.previewRevision = 0; + originalImage.src = source.originalUrl; + processedImage.removeAttribute("src"); + sourceMetadata.textContent = + `${source.originalName} · ${source.width} × ${source.height} · ${source.format}`; + downloadImage.hidden = true; + imageWorkspace.hidden = false; + await requestPreview(); + comparisonHeading.focus(); + } catch (error) { + setNotice(error.message, "error"); + } finally { + submitButton.disabled = false; + uploadStatus.textContent = ""; + } + } + + async function exportImage() { + if (!imageState.sourceId || imageState.exporting) { + return; + } + imageState.exporting = true; + exportButton.disabled = true; + exportButton.textContent = "Exporting image…"; + imageState.recipe = buildRecipe(); + try { + const response = await fetch( + `/api/image-sources/${encodeURIComponent(imageState.sourceId)}/export`, + { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({recipe: imageState.recipe}), + }, + ); + if (!response.ok) { + throw new Error(await errorMessage(response, "The image could not be exported.")); + } + const output = await response.json(); + downloadImage.href = output.downloadUrl; + downloadImage.download = output.fileName; + downloadImage.hidden = false; + setNotice(`${output.fileName} is ready to download.`, "success"); + downloadImage.focus(); + } catch (error) { + setNotice(error.message, "error"); + } finally { + imageState.exporting = false; + exportButton.disabled = false; + exportButton.textContent = "Export PNG"; + } + } + + function resetImageWorkspace() { + window.clearTimeout(imageState.debounceTimer); + if (imageState.previewController) { + imageState.previewController.abort(); + } + if (imageState.previewObjectUrl) { + URL.revokeObjectURL(imageState.previewObjectUrl); + } + Object.assign(imageState, { + sourceId: null, + originalUrl: null, + originalName: null, + seed: null, + recipe: null, + previewObjectUrl: null, + previewController: null, + previewRevision: 0, + exporting: false, + error: null, + }); + originalImage.removeAttribute("src"); + processedImage.removeAttribute("src"); + downloadImage.hidden = true; + imageWorkspace.hidden = true; + fileInput.value = ""; + setNotice(""); + fileInput.focus(); + } + + function toggleSubControls(checkboxName, targetId) { + const checkbox = form.elements[checkboxName]; + const target = document.querySelector(targetId); + const update = () => { + target.hidden = !checkbox.checked; + }; + checkbox.addEventListener("change", update); + update(); + } + + function updateMode() { + const imageMode = currentMode() === "image"; + submitButton.textContent = imageMode ? "Upload image" : "Create video preview"; + fileInput.accept = imageMode + ? ".png,.jpg,.jpeg,.bmp,.tif,.tiff" + : ".mp4,.avi,.mov,.mkv"; + if (imageState.sourceId) { + imageWorkspace.hidden = !imageMode; + } + } + + function updateRangeOutputs() { + document.querySelector("#amount-value").textContent = `${form.elements.amount.value}%`; + document.querySelector("#strength-value").textContent = form.elements.strength.value; + document.querySelector("#pixel_size-value").textContent = + `${form.elements.pixel_size.value} px`; + } + + function submitVideoPreview() { + if (!window.jQuery) { + window.alert("Video controls require the remaining legacy jQuery dependency."); + return; + } + const data = new FormData(form); + progressPanel.hidden = false; + progressBar.value = 0; + window.jQuery.ajax({ + url: "/upload_preview", + type: "POST", + data, + contentType: false, + processData: false, + success(response) { + progressPanel.hidden = true; + if (response.status !== "success") { + window.alert(response.message); + return; + } + imageState.videoPreview = response; + document.querySelector("#preview-image").src = response.preview_image; + document.querySelector("#preview-section").hidden = false; + }, + error(xhr) { + progressPanel.hidden = true; + window.alert(xhr.responseJSON?.message || "Video preview failed."); + }, + }); + } + + function processFullVideo() { + const data = imageState.videoPreview; + if (!data || !window.jQuery) { + return; + } + const requestFields = [ + "input_file", "output_file", "amount", "strength", "pixel_size", + "monochromatic", "glitch", "distortion", "color_bleed", "scan_lines", + "static", "flicker", "glitch_count", "glitch_shift", "distortion_x", + "distortion_y", "color_bleed_shift", "scan_line_gap", + "scan_line_darkness", "static_intensity", "flicker_min", "flicker_max", + "seed", + ]; + const payload = {}; + requestFields.forEach((field) => { + payload[field] = data[field]; + }); + progressPanel.hidden = false; + progressBar.value = 0; + window.jQuery.ajax({ + url: "/process_video_async", + type: "POST", + data: JSON.stringify(payload), + contentType: "application/json", + success(response) { + pollVideoProgress(response.task_id); + }, + error() { + progressPanel.hidden = true; + window.alert("Error initiating video processing."); + }, + }); + } + + function pollVideoProgress(taskId) { + const interval = window.setInterval(() => { + window.jQuery.getJSON(`/progress/${taskId}`) + .done((response) => { + progressBar.value = response.progress || 0; + if (response.status === "completed") { + window.clearInterval(interval); + progressPanel.hidden = true; + const videoResult = document.querySelector("#video-result"); + document.querySelector("#processed-video").src = `/video/${response.result}`; + document.querySelector("#download-link").href = `/download/${response.result}`; + videoResult.hidden = false; + } else if (response.status === "failed") { + window.clearInterval(interval); + progressPanel.hidden = true; + window.alert(response.message || "Video processing failed."); + } + }) + .fail(() => { + window.clearInterval(interval); + progressPanel.hidden = true; + window.alert("Error fetching progress."); + }); + }, 1000); + } + + form.addEventListener("submit", (event) => { + event.preventDefault(); + if (currentMode() === "image") { + uploadImageSource(); + } else { + submitVideoPreview(); + } + }); + + form.addEventListener("input", (event) => { + if (event.target.matches('input[type="range"]')) { + updateRangeOutputs(); + } + if (event.target.closest("#effect-controls")) { + schedulePreview(); + } + }); + form.addEventListener("change", (event) => { + if (event.target.name === "mode") { + updateMode(); + } else if (event.target.closest("#effect-controls")) { + schedulePreview(); + } + }); + + toggleSubControls("glitch", "#glitch-params"); + toggleSubControls("distortion", "#distortion-params"); + toggleSubControls("color_bleed", "#color_bleed-params"); + toggleSubControls("scan_lines", "#scan_lines-params"); + toggleSubControls("static", "#static-params"); + toggleSubControls("flicker", "#flicker-params"); + updateRangeOutputs(); + updateMode(); + + exportButton.addEventListener("click", exportImage); + newImageButton.addEventListener("click", resetImageWorkspace); + document.querySelector("#process-full").addEventListener("click", processFullVideo); + document.querySelector("#cancel-preview").addEventListener("click", () => { + document.querySelector("#preview-section").hidden = true; + imageState.videoPreview = null; + fileInput.value = ""; + fileInput.focus(); + }); + document.querySelector("#process-again").addEventListener("click", () => { + document.querySelector("#video-result").hidden = true; + document.querySelector("#preview-section").hidden = true; + fileInput.value = ""; + fileInput.focus(); + }); +})(); diff --git a/static/style.css b/static/style.css index ed295ed..a35f72e 100644 --- a/static/style.css +++ b/static/style.css @@ -1,122 +1,475 @@ +:root { + color-scheme: dark; + --bg: #111218; + --surface: #1a1c25; + --surface-raised: #222530; + --border: #383c49; + --border-strong: #555b6c; + --text: #f4f4f7; + --muted: #b5b8c4; + --accent: #d18be7; + --accent-strong: #e3a6f5; + --accent-ink: #231526; + --danger: #ffaaa5; + --success: #9bd8b0; + --focus: #f2c4ff; + --radius: 12px; + --space-1: 0.375rem; + --space-2: 0.75rem; + --space-3: 1rem; + --space-4: 1.5rem; + --space-5: 2rem; +} + +* { + box-sizing: border-box; +} + +[hidden] { + display: none !important; +} + body { - font-family: "Roboto", serif; - font-optical-sizing: auto; - font-weight: 100!important; - font-style: normal; - font-variation-settings: - "wdth" 100; - background-color: rgba(22,23,34,1); - color: rgba(255,225,255,1); -} - -.container { - width: 60%; - margin: auto; - background-color: rgba(45,43,54,1); - padding: 20px; - margin-top: 30px; - border-radius: 10px; - box-shadow: 0 0 15px rgba(0,0,0,0.2); -} - -h1, h2 { - text-align: center; - color: rgba(255,175,255,1); -} - -label { - font-weight: 100; - display: inline-block; - margin-top: 10px; -} - -input[type="number"], -input[type="file"], -input[type="text"], + min-width: 320px; + margin: 0; + background: var(--bg); + color: var(--text); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + font-size: 1rem; + line-height: 1.5; +} + +button, +input, select { + font: inherit; +} + +button, +a, +input { + outline-offset: 3px; +} + +:focus-visible { + outline: 3px solid var(--focus); +} + +.workspace-shell { + width: min(100% - 2rem, 1440px); + margin: 0 auto; + padding: var(--space-5) 0 4rem; +} + +.workspace-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-4); + margin-bottom: var(--space-5); +} + +.workspace-header h1, +.section-heading h2, +.image-panel h3 { + margin: 0; + color: var(--text); +} + +.workspace-header h1 { + font-size: clamp(2rem, 6vw, 3.5rem); + letter-spacing: -0.04em; +} + +.eyebrow, +.step-label { + margin: 0 0 var(--space-1); + color: var(--accent-strong); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.workspace-summary, +.section-heading > p, +.field-help, +.source-metadata, +.progress-panel p { + color: var(--muted); +} + +.workspace-summary { + max-width: 48rem; + margin: var(--space-2) 0 0; +} + +.local-badge { + flex: 0 0 auto; + padding: 0.45rem 0.75rem; + border: 1px solid var(--border-strong); + border-radius: 999px; + color: var(--success); + font-size: 0.8rem; + font-weight: 700; +} + +.control-panel, +.result-panel, +.progress-panel { + margin-top: var(--space-4); + padding: clamp(1rem, 3vw, 2rem); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); +} + +.section-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--space-4); + margin-bottom: var(--space-4); +} + +.section-heading > p { + max-width: 34rem; + margin: 0; + text-align: right; +} + +fieldset { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +legend, +.file-field > label { + margin-bottom: var(--space-2); + color: var(--text); + font-weight: 700; +} + +.mode-fieldset, +.file-field, +.effects-fieldset { + margin-bottom: var(--space-4); +} + +.segmented-control { + display: inline-flex; + padding: 0.25rem; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); +} + +.segmented-control label { + margin: 0; + cursor: pointer; +} + +.segmented-control input { + position: absolute; + opacity: 0; +} + +.segmented-control span { + display: block; + min-width: 6rem; + padding: 0.55rem 1rem; + border-radius: 7px; + color: var(--muted); + text-align: center; +} + +.segmented-control input:checked + span { + background: var(--surface-raised); + color: var(--text); + box-shadow: inset 0 0 0 1px var(--border-strong); +} + +.segmented-control input:focus-visible + span { + outline: 3px solid var(--focus); + outline-offset: 3px; +} + +input[type="file"], +input[type="number"] { + width: 100%; + padding: 0.65rem 0.75rem; + border: 1px solid var(--border-strong); + border-radius: 8px; + background: var(--bg); + color: var(--text); +} + +input[type="range"] { + width: 100%; + accent-color: var(--accent); +} + +.field-help { + margin: var(--space-1) 0 0; + font-size: 0.86rem; +} + +.control-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr)); + gap: var(--space-3); +} + +.control-card { + min-width: 0; + padding: var(--space-3); + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface-raised); +} + +.control-label, +.image-panel-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); +} + +.control-label:not(:first-child) { + margin-top: var(--space-3); +} + +.control-label label, +.check-control { + font-weight: 650; +} + +output, +.preview-status { + color: var(--accent-strong); + font-size: 0.85rem; +} + +.check-control { + display: flex; + align-items: center; + gap: var(--space-2); + margin: 0; + cursor: pointer; +} + +.check-control input { + width: 1.1rem; + height: 1.1rem; + margin: 0; + accent-color: var(--accent); +} + +.sub-controls { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-1); + margin-top: var(--space-3); + padding-top: var(--space-3); + border-top: 1px solid var(--border); +} + +.sub-controls label:not(:first-child) { + margin-top: var(--space-2); +} + +.primary-actions, +.export-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); +} + +.action { + display: inline-flex; + min-height: 2.75rem; + align-items: center; + justify-content: center; + padding: 0.65rem 1rem; + border: 1px solid transparent; + border-radius: 8px; + font-weight: 700; + text-decoration: none; + cursor: pointer; +} + +.action:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.action-primary { + background: var(--accent); + color: var(--accent-ink); +} + +.action-primary:hover:not(:disabled) { + background: var(--accent-strong); +} + +.action-secondary { + border-color: var(--accent); + background: transparent; + color: var(--accent-strong); +} + +.action-quiet { + border-color: var(--border-strong); + background: transparent; + color: var(--text); +} + +.quiet-status { + color: var(--muted); +} + +.notice { + margin-bottom: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--border-strong); + border-left: 4px solid var(--accent); + border-radius: 8px; + background: var(--surface); +} + +.notice[data-kind="error"] { + border-left-color: var(--danger); + color: var(--danger); +} + +.notice[data-kind="success"] { + border-left-color: var(--success); +} + +.source-metadata { + max-width: 28rem; + overflow-wrap: anywhere; +} + +.comparison-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); +} + +.image-panel { + min-width: 0; +} + +.image-panel h3 { + margin-bottom: var(--space-2); + font-size: 1rem; +} + +.image-stage { + display: grid; + min-height: 18rem; + place-items: center; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 10px; + background-color: #0b0c10; + background-image: + linear-gradient(45deg, #151720 25%, transparent 25%), + linear-gradient(-45deg, #151720 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, #151720 75%), + linear-gradient(-45deg, transparent 75%, #151720 75%); + background-position: 0 0, 0 8px, 8px -8px, -8px 0; + background-size: 16px 16px; +} + +.image-stage img { + display: block; + width: auto; + max-width: 100%; + height: auto; + max-height: 70vh; + object-fit: contain; +} + +.video-preview-stage { + min-height: 12rem; +} + +.export-row { + margin-top: var(--space-4); +} + +#processed-video { + display: block; + width: 100%; + max-width: 960px; + height: auto; + margin-top: var(--space-3); + border-radius: 10px; + background: #000; +} + +progress { + width: 100%; + height: 1rem; + accent-color: var(--accent); +} + +@media (max-width: 767px) { + .workspace-shell { + width: min(100% - 1rem, 1440px); + padding-top: var(--space-3); + } + + .workspace-header, + .section-heading { + align-items: flex-start; + flex-direction: column; + } + + .section-heading > p { + text-align: left; + } + + .local-badge { + align-self: flex-start; + } + + .comparison-grid { + grid-template-columns: 1fr; + } + + .image-stage { + min-height: 12rem; + } + + .action { + flex: 1 1 auto; + } +} + +@media (max-width: 380px) { + .segmented-control { + display: grid; width: 100%; - padding: 8px; - margin-top: 5px; - box-sizing: border-box; - border: 1px solid rgba(255,225,255,1); - border-radius: 4px; -} - -input[type="checkbox"] { - margin-right: 5px; -} - -input[type="submit"], button { - background-color: #4CAF50; - color: white; - padding: 10px 25px; - border: none; - border-radius: 4px; - cursor: pointer; - margin-top: 10px; -} - -input[type="submit"]:hover, button:hover { - background-color: #45a049; -} - -.flashes { - list-style-type: none; - padding: 0; - margin-bottom: 20px; -} - -.flashes li { - background-color: #f8d7da; - color: #721c24; - padding: 10px; - border-left: 6px solid #f5c6cb; - margin-bottom: 10px; - border-radius: 4px; -} - -#preview-section{ - margin-top: 2rem; - border-top: 1px solid rgba(255,225,255,.3); -} - -#preview-wrapper{ - background:rgba(0,0,0,1); -} - -#button-wrapper{ - text-align:center; -} - -#preview-image { - display: block; - max-width: 100%; - width: auto; - max-height: 1980px; - height: auto; - border-radius: 4px; - margin: 10px auto; - position:relative; -} - -#progress-indicator { - text-align: center; - margin-top: 20px; -} - -progress { - width: 80%; - height: 20px; - margin-top: 1rem; -} - -.spinner img { - width: 50px; - height: 50px; - display: inline-block; -} - -.sub-params { - margin-left: 25px; - margin-top: 5px; -} + grid-template-columns: 1fr 1fr; + } + + .segmented-control span { + min-width: 0; + padding-inline: 0.5rem; + } + + .control-panel, + .result-panel, + .progress-panel { + padding: var(--space-3); + } +} diff --git a/templates/index.html b/templates/index.html index 3284fef..b51b4d8 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,449 +1,253 @@ - - - - - GlitchCraft - - - - - - - - - - - - - - - - - -
-

GlitchCraft

- -
-
- -
- - -
-
- - -
-
- -
- - -
- -
- - - 10 -
- -
- - - 10 -
- -
- - -
- -
- - - 1 -
- - -
- - -
- - - -
- - -
- - - -
- - -
- - - -
- - -
- - - -
- - -
- - - -
- - -
- - - -
- - - - - -
- -
- - -
- - - -
- - - - + + + + + + GlitchCraft — Local visual effects + + + + + +
+
+
+

Local visual-effects workspace

+

GlitchCraft

+

+ Shape signal, noise, pixels, and color locally. Your media stays on this device. +

+
+ Local processing +
+ + + +
+
+
+

Step 1

+

Choose media and treatment

+
+

Upload once, then adjust controls while the image remains available.

+
+ +
+
+ Media type +
+ + +
+
+ +
+ + +

Supported images: PNG, JPEG, BMP, and TIFF.

+
+ +
+ Effect controls + +
+
+
+ + 10% +
+ + +
+ + 10 +
+ + + +
+ +
+
+ + 1 px +
+ +

A value of 1 leaves the image unpixelated.

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + + + + + + + +
+ + diff --git a/tests/browser/image-workflow.spec.js b/tests/browser/image-workflow.spec.js new file mode 100644 index 0000000..c3f6097 --- /dev/null +++ b/tests/browser/image-workflow.spec.js @@ -0,0 +1,145 @@ +const {test, expect} = require("@playwright/test"); +const AxeBuilder = require("@axe-core/playwright").default; + +const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAGCAIAAABxZ0isAAAAFElEQVR4nGMUiTrBgA0wYRWlkwQAtp4BQqPZizoAAAAASUVORK5CYII=", + "base64", +); + +async function uploadImage(page) { + await page.setInputFiles("#input_file", { + name: "signal-source.png", + mimeType: "image/png", + buffer: png, + }); + await page.getByRole("button", {name: "Upload image"}).click(); + await expect(page.locator("#image-workspace")).toBeVisible(); + await expect(page.locator("#original-image")).toHaveAttribute( + "src", + /\/api\/image-sources\/.+\/original/, + ); + await expect(page.locator("#processed-image")).toHaveAttribute("src", /^blob:/); + await expect(page.locator("#image-preview-status")).toHaveText("Preview ready"); +} + +test.beforeEach(async ({page}) => { + await page.goto("/"); +}); + +test("uploads once and shows original and processed images without navigation", async ({ + page, +}) => { + const startUrl = page.url(); + await uploadImage(page); + await expect(page).toHaveURL(startUrl); + await expect(page.getByRole("heading", {name: "Original", exact: true})).toBeVisible(); + await expect(page.getByRole("heading", {name: "Processed", exact: true})).toBeVisible(); + await expect(page.getByRole("link", {name: "Download image"})).toBeHidden(); + if (process.env.GLITCHCRAFT_CAPTURE) { + await page.screenshot({ + path: "test-results/manual-image-workspace-desktop.png", + fullPage: true, + }); + } +}); + +test("rapid changes settle on the newest recipe, then export explicitly", async ({ + page, +}) => { + await uploadImage(page); + const firstRevision = Number( + await page.locator("#processed-image").getAttribute("data-revision"), + ); + const initialSeed = await page.evaluate(() => window.__glitchcraftState.seed); + await page.route("**/api/image-sources/*/preview", async (route) => { + const amount = route.request().postDataJSON().recipe.effects[0].parameters.amount; + const response = await route.fetch(); + if (amount === 20) { + await new Promise((resolve) => setTimeout(resolve, 350)); + } + try { + await route.fulfill({response}); + } catch { + // The application is expected to abort the superseded request. + } + }); + const slider = page.locator("#amount"); + await slider.fill("20"); + await page.waitForTimeout(220); + await slider.fill("35"); + await expect(page.locator("#processed-image")).toHaveAttribute( + "data-revision", + String(firstRevision + 2), + ); + await expect + .poll(() => + page.evaluate( + () => window.__glitchcraftState.recipe.effects[0].parameters.amount, + ), + ) + .toBe(35); + await expect + .poll(() => page.evaluate(() => window.__glitchcraftState.seed)) + .toBe(initialSeed); + await expect(page.locator("#image-preview-status")).toHaveText("Preview ready"); + + await page.getByRole("button", {name: "Export PNG"}).click(); + const downloadLink = page.getByRole("link", {name: "Download image"}); + await expect(downloadLink).toBeVisible(); + await expect(downloadLink).toHaveAttribute("href", /\/api\/image-outputs\/.+\/download/); + const downloadPromise = page.waitForEvent("download"); + await downloadLink.click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe("signal-source-glitchcraft.png"); + + await page.getByRole("button", {name: "New image"}).click(); + await expect(page.locator("#image-workspace")).toBeHidden(); + await expect(page.locator("#input_file")).toBeFocused(); +}); + +test("primary image workflow is keyboard operable and has no serious axe findings", async ({ + page, +}) => { + await uploadImage(page); + await page.getByRole("button", {name: "Export PNG"}).focus(); + await page.keyboard.press("Enter"); + await expect(page.getByRole("link", {name: "Download image"})).toBeFocused(); + + const results = await new AxeBuilder({page}).analyze(); + const serious = results.violations.filter((violation) => + ["serious", "critical"].includes(violation.impact), + ); + expect(serious).toEqual([]); +}); + +test("comparison remains contained at supported responsive widths", async ({page}) => { + await uploadImage(page); + for (const width of [320, 768, 1024, 1440]) { + await page.setViewportSize({width, height: 900}); + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth > document.documentElement.clientWidth, + ); + expect(overflow, `horizontal overflow at ${width}px`).toBe(false); + await expect(page.locator("#original-image")).toBeVisible(); + if (process.env.GLITCHCRAFT_CAPTURE && width === 320) { + await page.screenshot({ + path: "test-results/manual-image-workspace-320.png", + fullPage: true, + }); + } + } +}); + +test("invalid image errors stay inline", async ({page}) => { + await page.setInputFiles("#input_file", { + name: "broken.png", + mimeType: "image/png", + buffer: Buffer.from("not an image"), + }); + await page.getByRole("button", {name: "Upload image"}).click(); + const error = page.locator("#image-notice"); + await expect(error).toBeVisible(); + await expect(error).toHaveAttribute("role", "alert"); + await expect(error).toContainText("could not be decoded"); + await expect(page.locator("#image-workspace")).toBeHidden(); +}); diff --git a/tests/test_cleanup.py b/tests/test_cleanup.py index ee0dd48..bdf94b3 100644 --- a/tests/test_cleanup.py +++ b/tests/test_cleanup.py @@ -17,6 +17,15 @@ def test_remove_expired_files_only_removes_old_files(tmp_path: Path) -> None: assert recent.exists() +def test_remove_expired_files_preserves_registered_paths(tmp_path: Path) -> None: + protected = tmp_path / "protected.tmp" + protected.write_text("active") + timestamp = time.time() - 100 + os.utime(protected, (timestamp, timestamp)) + remove_expired_files([tmp_path], maximum_age=50, protected_paths=[protected]) + assert protected.exists() + + def test_remove_expired_files_logs_unlink_failure(tmp_path: Path, monkeypatch, caplog) -> None: old = tmp_path / "old.tmp" old.write_text("old") @@ -32,7 +41,14 @@ def fail(_self): def test_cleanup_scheduler_starts_and_stops(tmp_path: Path) -> None: - scheduler = CleanupScheduler([tmp_path], interval=0.01) + calls: list[str] = [] + scheduler = CleanupScheduler( + [tmp_path], + interval=0.01, + before_cleanup=lambda: calls.append("cleanup"), + ) scheduler.start() + time.sleep(0.03) scheduler.stop() assert scheduler._stop.is_set() + assert calls diff --git a/tests/test_image_assets.py b/tests/test_image_assets.py new file mode 100644 index 0000000..099e07c --- /dev/null +++ b/tests/test_image_assets.py @@ -0,0 +1,71 @@ +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from glitchcraft.image_assets import ImageOutputStore, ImageSourceStore + + +def create_source(store: ImageSourceStore, path: Path, index: int = 0): + path.write_bytes(b"image") + return store.create( + path=path, + original_name=f"source-{index}.png", + display_name=f"source-{index}.png", + width=2, + height=3, + image_format="PNG", + mime_type="image/png", + seed=index, + ) + + +def test_source_ids_are_opaque_unique_and_thread_safe(tmp_path: Path) -> None: + store = ImageSourceStore() + + def add(index: int) -> str: + return create_source(store, tmp_path / f"{index}.png", index).id + + with ThreadPoolExecutor(max_workers=8) as executor: + identifiers = list(executor.map(add, range(40))) + assert len(set(identifiers)) == 40 + assert all(len(identifier) >= 32 for identifier in identifiers) + assert len(store) == 40 + + +def test_source_lease_prevents_cleanup_until_released(tmp_path: Path) -> None: + store = ImageSourceStore(maximum_age=10) + source = create_source(store, tmp_path / "source.png") + with store.lease(source.id): + assert store.cleanup_expired(now=source.created_at + 11) == 0 + assert source.path.exists() + assert store.cleanup_expired(now=source.created_at + 11) == 1 + assert store.get(source.id) is None + assert not source.path.exists() + + +def test_missing_file_and_expired_records_are_unavailable(tmp_path: Path) -> None: + store = ImageSourceStore(maximum_age=1) + source = create_source(store, tmp_path / "source.png") + source.path.unlink() + assert store.get(source.id) is None + + expired = create_source(store, tmp_path / "expired.png") + time.sleep(0.01) + assert store.cleanup_expired(now=expired.created_at + 2) == 1 + + +def test_output_store_tracks_recipe_and_known_path(tmp_path: Path) -> None: + path = tmp_path / "output.png" + path.write_bytes(b"output") + store = ImageOutputStore() + output = store.create( + path=path, + source_id="source", + display_name="result.png", + recipe_json='{"seed":1}', + seed=1, + width=10, + height=20, + ) + assert store.get(output.id) == output + assert path.resolve() in store.known_paths() diff --git a/tests/test_image_workflow.py b/tests/test_image_workflow.py new file mode 100644 index 0000000..632d5cf --- /dev/null +++ b/tests/test_image_workflow.py @@ -0,0 +1,237 @@ +from io import BytesIO +from pathlib import Path + +import numpy as np +import pytest +from PIL import Image + + +def image_file( + *, + size: tuple[int, int] = (8, 6), + color: tuple[int, int, int] = (20, 90, 200), + image_format: str = "PNG", +) -> BytesIO: + stream = BytesIO() + Image.new("RGB", size, color).save(stream, image_format) + stream.seek(0) + return stream + + +def upload_source(client, *, seed: int | None = 123, filename: str = "example.png"): + data = {"image": (image_file(), filename)} + if seed is not None: + data["seed"] = str(seed) + return client.post( + "/api/image-sources", + data=data, + content_type="multipart/form-data", + ) + + +def recipe( + seed: int = 123, + *, + effect_type: str = "noise", + parameters: dict | None = None, +) -> dict: + return { + "recipe": { + "schemaVersion": 1, + "seed": seed, + "effects": [ + { + "id": "effect-1", + "type": effect_type, + "enabled": True, + "parameters": parameters + or {"amount": 100, "strength": 40, "monochromatic": False}, + } + ], + } + } + + +def decode_rgb(data: bytes) -> np.ndarray: + with Image.open(BytesIO(data)) as image: + return np.asarray(image.convert("RGB"), dtype=np.uint8) + + +def test_successful_upload_returns_opaque_metadata_without_processing(client, app) -> None: + response = upload_source(client, seed=456) + assert response.status_code == 201 + assert response.json == { + "sourceId": response.json["sourceId"], + "originalName": "example.png", + "width": 8, + "height": 6, + "format": "PNG", + "originalUrl": f"/api/image-sources/{response.json['sourceId']}/original", + "seed": 456, + } + assert len(response.json["sourceId"]) >= 32 + assert str(Path(app.config["UPLOAD_FOLDER"])) not in response.text + assert not list(Path(app.config["OUTPUT_FOLDER"]).glob("*.png")) + + +def test_upload_generates_seed_when_absent(client) -> None: + response = upload_source(client, seed=None) + assert response.status_code == 201 + assert 0 <= response.json["seed"] < 2**63 + + +@pytest.mark.parametrize( + ("data", "message"), + [ + ({"image": (image_file(), "source.exe")}, "Unsupported image extension"), + ({"image": (BytesIO(b"not an image"), "source.png")}, "could not be decoded"), + ( + {"image": (image_file(image_format="GIF"), "source.png")}, + "format is not supported", + ), + ({"seed": "1"}, "Select an image"), + ], +) +def test_upload_rejects_invalid_images(client, data: dict, message: str) -> None: + response = client.post( + "/api/image-sources", + data=data, + content_type="multipart/form-data", + ) + assert response.status_code == 400 + assert message in response.json["message"] + + +def test_upload_rejects_pixel_limit_and_unknown_field(client, app) -> None: + app.config["MAX_IMAGE_PIXELS"] = 10 + too_large = upload_source(client) + assert too_large.status_code == 400 + assert "pixel count" in too_large.json["message"] + + unknown = client.post( + "/api/image-sources", + data={"image": (image_file(size=(1, 1)), "x.png"), "unknown": "value"}, + content_type="multipart/form-data", + ) + assert unknown.status_code == 400 + + +def test_original_is_inline_no_store_and_unknown_is_404(client) -> None: + uploaded = upload_source(client) + response = client.get(uploaded.json["originalUrl"]) + assert response.status_code == 200 + assert response.mimetype == "image/png" + assert response.headers["Content-Disposition"] == "inline" + assert "no-store" in response.headers["Cache-Control"] + assert client.get("/api/image-sources/not-real/original").status_code == 404 + + +def test_preview_is_deterministic_png_and_creates_no_output(client, app) -> None: + source_id = upload_source(client).json["sourceId"] + endpoint = f"/api/image-sources/{source_id}/preview" + first = client.post(endpoint, json=recipe()) + second = client.post(endpoint, json=recipe()) + assert first.status_code == 200 + assert first.mimetype == "image/png" + assert "no-store" in first.headers["Cache-Control"] + assert first.data == second.data + assert not list(Path(app.config["OUTPUT_FOLDER"]).glob("image-output-*.png")) + + +def test_stochastic_preview_changes_by_seed_but_deterministic_does_not(client) -> None: + source_id = upload_source(client).json["sourceId"] + endpoint = f"/api/image-sources/{source_id}/preview" + stochastic_a = client.post(endpoint, json=recipe(1)).data + stochastic_b = client.post(endpoint, json=recipe(2)).data + assert stochastic_a != stochastic_b + + pixel = {"pixel_size": 3} + deterministic_a = client.post( + endpoint, json=recipe(1, effect_type="pixelation", parameters=pixel) + ).data + deterministic_b = client.post( + endpoint, json=recipe(2, effect_type="pixelation", parameters=pixel) + ).data + assert deterministic_a == deterministic_b + + +@pytest.mark.parametrize( + "payload", + [ + None, + [], + {"recipe": {"schemaVersion": 1, "seed": 1, "effects": []}, "unknown": True}, + {"recipe": {"schemaVersion": 2, "seed": 1, "effects": []}}, + {"recipe": {"schemaVersion": 1, "seed": -1, "effects": []}}, + ], +) +def test_preview_rejects_invalid_contracts(client, payload) -> None: + source_id = upload_source(client).json["sourceId"] + response = client.post( + f"/api/image-sources/{source_id}/preview", + json=payload, + ) + assert response.status_code == 400 + assert response.json["status"] == "error" + + +def test_unknown_source_preview_and_export_return_404(client) -> None: + assert client.post("/api/image-sources/missing/preview", json=recipe()).status_code == 404 + assert client.post("/api/image-sources/missing/export", json=recipe()).status_code == 404 + + +def test_export_creates_one_opaque_output_identical_to_preview(client, app) -> None: + uploaded = upload_source(client) + source_id = uploaded.json["sourceId"] + endpoint = f"/api/image-sources/{source_id}" + preview = client.post(f"{endpoint}/preview", json=recipe()) + exported = client.post(f"{endpoint}/export", json=recipe()) + assert exported.status_code == 201 + assert exported.json["fileName"] == "example-glitchcraft.png" + assert "outputId" in exported.json + assert str(Path(app.config["OUTPUT_FOLDER"])) not in exported.text + assert len(list(Path(app.config["OUTPUT_FOLDER"]).glob("image-output-*.png"))) == 1 + + inline = client.get(exported.json["previewUrl"]) + download = client.get(exported.json["downloadUrl"]) + assert inline.status_code == 200 + assert inline.headers["Content-Disposition"] == "inline" + assert download.status_code == 200 + assert download.headers["Content-Disposition"].startswith("attachment;") + assert decode_rgb(preview.data).tolist() == decode_rgb(inline.data).tolist() + assert inline.data == download.data + + +def test_unknown_outputs_return_404(client) -> None: + assert client.get("/api/image-outputs/missing").status_code == 404 + assert client.get("/api/image-outputs/missing/download").status_code == 404 + + +def test_unexpected_image_failures_are_controlled(client, monkeypatch) -> None: + monkeypatch.setattr( + "glitchcraft.web.routes.inspect_image", + lambda *_args: (_ for _ in ()).throw(RuntimeError("private path")), + ) + uploaded = upload_source(client) + assert uploaded.status_code == 500 + assert uploaded.json == {"status": "error", "message": "Image upload failed."} + + monkeypatch.undo() + source_id = upload_source(client).json["sourceId"] + monkeypatch.setattr( + "glitchcraft.web.routes._process_source", + lambda *_args: (_ for _ in ()).throw(RuntimeError("private path")), + ) + preview = client.post(f"/api/image-sources/{source_id}/preview", json=recipe()) + assert preview.status_code == 500 + assert "private path" not in preview.text + + monkeypatch.undo() + source_id = upload_source(client).json["sourceId"] + monkeypatch.setattr( + "glitchcraft.web.routes.save_image_rgb", + lambda *_args: (_ for _ in ()).throw(RuntimeError("private path")), + ) + exported = client.post(f"/api/image-sources/{source_id}/export", json=recipe()) + assert exported.status_code == 500 + assert "private path" not in exported.text diff --git a/tests/test_routes.py b/tests/test_routes.py index dd6694a..d847cc4 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -77,8 +77,11 @@ def test_successful_image_flow_generates_seed_and_output(client, app) -> None: assert response.status_code == 200 assert response.json["status"] == "success" assert isinstance(response.json["seed"], int) + assert response.json["inline_preview_url"].startswith("/image/") + assert response.json["download_url"] == response.json["preview_url"] download = client.get(response.json["preview_url"]) assert download.status_code == 200 + assert client.get(response.json["inline_preview_url"]).status_code == 200 assert list(Path(app.config["OUTPUT_FOLDER"]).glob("*.png"))