Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ __pycache__/
coverage.xml
coverage.json
htmlcov/
node_modules/
playwright-report/
test-results/
build/
dist/

Expand Down
29 changes: 23 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -35,11 +36,24 @@ python app.py
Open <http://127.0.0.1:5000>. 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,
Expand All @@ -49,16 +63,19 @@ 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)

The internal recipe contract supports a schema version, root seed, ordered effect
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

Expand Down
18 changes: 17 additions & 1 deletion app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__":
Expand Down
2 changes: 2 additions & 0 deletions check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 6 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
60 changes: 60 additions & 0 deletions docs/image-workflow.md
Original file line number Diff line number Diff line change
@@ -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/<id>/original` — inline original with `no-store`.
- `POST /api/image-sources/<id>/preview` — strict `{ "recipe": ... }` JSON;
returns inline PNG bytes without creating an output.
- `POST /api/image-sources/<id>/export` — the same strict recipe; returns an
opaque output ID, display filename, inline URL, and download URL.
- `GET /api/image-outputs/<id>` — inline exported PNG.
- `GET /api/image-outputs/<id>/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.
21 changes: 20 additions & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.
9 changes: 9 additions & 0 deletions glitchcraft/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
27 changes: 22 additions & 5 deletions glitchcraft/cleanup.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,41 @@

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:
logger.exception("Could not remove expired file %s", path)


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)

Expand All @@ -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(),
)
15 changes: 15 additions & 0 deletions glitchcraft/contracts/image_workflow.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading