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
24 changes: 14 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,17 @@ scan, distortion, and signal treatments for images and video.

![App preview](./glitchcraft-ss01.png)

The current interface is a transitional Flask/jQuery workspace. Images upload
The current interface is a dependency-free Flask 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.
Image sources and explicit exports now have persistent local identity. Video
remains on the temporary legacy preview and background-processing path.
Image and video sources and explicit outputs have persistent local identity.
Video processing uses a bounded, cancellable queue whose job state survives
application restarts.

## Requirements

- Python 3.11 or newer
- FFmpeg on `PATH` for browser-compatible video re-encoding
- FFmpeg and FFprobe on `PATH` for video inspection and browser-safe output

FFmpeg is not required for effect-engine unit tests.

Expand All @@ -35,7 +36,7 @@ python app.py
```

Open <http://127.0.0.1:5000>. The default port and existing routes are preserved.
The managed image library defaults to `data/` and is ignored by Git. Configure
The managed media library defaults to `data/` and is ignored by Git. Configure
`DATA_ROOT` to move it to another local user-data location. The service has no
authentication and is intended only for loopback use; do not expose it directly
to the public internet.
Expand Down Expand Up @@ -72,6 +73,8 @@ 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)
- [Video workflow and API](docs/video-workflow.md)
- [Video job lifecycle](docs/video-jobs.md)
- [Persistent storage](docs/storage.md)
- [Recovery](docs/recovery.md)
- [Craft service contract](docs/service-contract.md)
Expand All @@ -82,11 +85,12 @@ 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.

Image source and explicit output identity are persistent. Video task state,
sources, and outputs remain temporary and process-local with no cancellation,
bounded queue, or restart recovery. 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.
Video uploads are inspected once with FFprobe, timestamp previews reuse the
persisted source, and full jobs produce H.264/yuv420p MP4 output. Source audio is
preserved as AAC at 192 kbps by default or can be removed. Streaming supports
HEAD and one closed, open-ended, or suffix byte range. Legacy filename-oriented
routes remain temporarily available for compatibility, but the current UI does
not use them. True geometric distortion/datamoshing is not implemented.

## License

Expand Down
1 change: 1 addition & 0 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@
if __name__ == "__main__":
cleanup.start()
atexit.register(cleanup.stop)
atexit.register(app.extensions["video_job_manager"].shutdown)
app.run(debug=False)
12 changes: 7 additions & 5 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ The package boundaries are:

- `contracts`: strict recipe and legacy-request validation.
- `effects`: metadata, isolated randomness, operations, and ordered execution.
- `media`: Pillow/OpenCV color boundaries, file I/O, and FFmpeg invocation.
- `media`: Pillow/OpenCV boundaries plus strict FFprobe and cancellable FFmpeg adapters.
- `jobs`: bounded worker coordination backed by persistent video job records.
- `web`: compatibility routes and request-to-recipe translation.
- `tasks`: the temporary thread-safe in-memory video task store.
- `tasks`: deprecated in-memory compatibility state for legacy video routes.
- `storage`: strict manifest contracts, atomic persistence, path ownership,
leases, reconciliation, deletion, metrics, and cleanup for image assets.
leases, reconciliation, deletion, metrics, and cleanup for media and video jobs.
- `service_contract`: static capability slugs and runtime availability metadata.
- `cleanup`: launcher-owned expiration of temporary legacy media files.

Expand All @@ -25,7 +26,8 @@ Image sources and explicit outputs live in a versioned managed library and retai
opaque identity across restarts. Manifest mutations are serialized by a process
reentrant lock; multiple writer processes sharing a data root are not supported.

Video task and asset identity remains process-local. A restart loses video job
status, and there is no bounded queue, cancellation, or persistent video library.
Video source, output, and job identity persists. The factory constructs a bounded
worker manager; import itself does not start work, and test configurations can
disable autostart. Processing and muxing poll persisted cancellation state.
The current Flask process serves the interface and API together on port 5000. A
future 5175/4200 frontend/API split is a plan, not current behavior.
7 changes: 4 additions & 3 deletions docs/image-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ full-resolution image.

`ImageAssetRepository` issues unpredictable opaque identifiers. Browser requests
contain those IDs rather than server paths or managed filenames. A
schema-version-1 manifest persists records and output recipe snapshots. Sources
schema-version-2 manifest persists records and output recipe snapshots. Sources
and outputs survive restart with the same data root and remain until explicit
deletion. Age cleanup never removes manifest-referenced image files.

Expand Down Expand Up @@ -55,5 +55,6 @@ 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. The planned family-aligned
React workspace, visible library, and final jQuery removal remain future work.
The legacy video endpoints remain for compatibility, but the current interface
uses dependency-free browser APIs. A family-aligned React workspace and visible
library remain future work.
8 changes: 5 additions & 3 deletions docs/product-direction.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ grammar. GlitchCraft will retain its own signal, interference, and transformatio
identity; another application's visual theme will not be copied wholesale.

The current sequence deliberately avoids a final visual redesign. It establishes
inspectable recipes, deterministic processing, persistent image identity, Craft
discovery metadata, truthful readiness, and testable boundaries needed by that
future workspace.
inspectable recipes, deterministic processing, persistent image and video
identity, bounded cancellable jobs, Craft discovery metadata, truthful
readiness, and testable boundaries needed by that future workspace. GlitchCraft
owns creative treatment; Web Video Optimizer remains the detailed
delivery-optimization and packaging tool.

A future orchestration dashboard may discover and check GlitchCraft, ColorCraft,
and Web Video Optimizer through related contracts. It is not implemented here
Expand Down
8 changes: 7 additions & 1 deletion docs/recovery.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Storage recovery and reconciliation

Startup creates an empty schema-version-1 manifest when no managed state exists.
Startup creates an empty schema-version-2 manifest when no managed state exists.
A valid primary loads normally. If the primary is missing or invalid and the
backup is valid, GlitchCraft restores the primary without overwriting the
last-known-good backup, records a redacted warning, writes a private recovery
Expand All @@ -21,6 +21,12 @@ Reconciliation uses deterministic rules:
- unreferenced source and output files are reported as orphans and are not
deleted automatically.

Validated schema-v1 manifests migrate to v2 after a private pre-migration copy is
written. Unknown or invalid versions are never overwritten. Queued jobs remain
queued after restart. Interrupted preparing, processing, or muxing jobs are
requeued with a persisted attempt and recovery marker; cancellation requests are
honored, and jobs beyond the configured recovery-attempt limit fail cleanly.

Startup changes and backup restoration create timestamped JSON reports inside
`recovery/`. That directory is not served publicly. `/api/storage` exposes only
redacted counts, bytes, writability, free space when available, and the last
Expand Down
10 changes: 5 additions & 5 deletions docs/service-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ future Craft application dashboard:
free space when available, and reconciliation state.

Health does not write storage, mutate the manifest, or invoke FFmpeg. Readiness
is `not_ready` for core manifest or image-storage failure. Missing FFmpeg,
is `not_ready` for core manifest or image-storage failure. Missing FFmpeg or FFprobe,
backup recovery, or reported orphans produce `degraded` while image work remains
available. An empty healthy library is `ready`.

Expand All @@ -35,7 +35,7 @@ The current Flask application serves both web and API traffic at
frontend/API split and are not advertised as running services. There is no
dashboard remote control or application launch contract in this version.

Persistent storage currently covers images only. Video jobs and assets remain
temporary, task state remains in memory, and audio behavior is unchanged. Saved
recipe management, WVO handoff, persistent video identity, authentication, and a
visible library interface are deferred.
Persistent storage covers image/video sources, explicit outputs, and video job
state. Readiness reports queue capacity, worker concurrency, manifest migration
or reconciliation state, and both required video tools. Saved recipe management,
WVO handoff, authentication, and a visible library interface are deferred.
20 changes: 11 additions & 9 deletions docs/storage.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
# Persistent image storage
# Persistent media storage

GlitchCraft 0.1.0 uses a configurable managed data root for image sources and
explicit image outputs:
GlitchCraft 0.2.0 uses a configurable managed data root:

```text
data/
manifest.json
manifest.json.bak
sources/images/
sources/videos/
outputs/images/
outputs/videos/
temporary/
video-jobs/
recovery/
```

Expand All @@ -20,10 +22,10 @@ the manifest are normalized relative POSIX paths; absolute paths,
traversal, unknown fields, unsupported kinds, inconsistent record IDs, invalid
dimensions or MIME types, and invalid Recipe v1 snapshots are rejected.

The schema-version-1 manifest contains immutable source metadata and output
metadata. Output records include the exact ordered recipe and seed used for
export. Record order has no meaning. Unknown manifest fields are rejected until
an explicit compatibility policy is introduced for a later schema version.
Schema version 2 contains discriminated image/video sources and outputs plus
persistent video jobs. Output and job records include the exact ordered recipe
and seed. A validated schema-v1 image manifest migrates without data loss; the
original bytes are preserved privately before only schema v2 is serialized.

## Lifecycle

Expand Down Expand Up @@ -54,5 +56,5 @@ byte counts, `temporaryBytes`, `orphanFileCount`, `cleanupAvailable`, and
`missingRecordCount`, and the nested reconciliation detail are operational
diagnostics whose values and granularity may vary by platform.

Legacy video uploads, jobs, previews, and outputs remain temporary. They are not
manifest records and still use the legacy cleanup lifecycle.
Video completion installs the final MP4 and marks its job completed in one
manifest mutation. Intermediate job files remain temporary and are never served.
7 changes: 6 additions & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ 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
python -m pytest tests/test_storage_manifest.py tests/test_service_contract.py
python -m pytest tests/test_video_workflow.py
```

Tests use synthetic NumPy frames and temporary directories. The narrowly marked
Expand All @@ -45,8 +46,12 @@ operation, responsive containment at 320/768/1024/desktop widths, and axe result
with no serious or critical violations. GitHub Actions keeps this browser job
separate from the fast Python quality job.

Storage tests cover strict path and manifest validation, atomic replacement,
Storage and video tests cover strict path and manifest validation, v1-to-v2
migration, atomic replacement,
backup recovery, dual-manifest failure, concurrent thread mutations, restart-safe
IDs, reconciliation, deletion rollback, cleanup, and public response redaction.
Every application fixture overrides the data root, manifest, temporary folder,
and legacy media locations with test-owned temporary directories.
The video suite also covers FFprobe translation, finalizer cancellation,
persistent job transitions/recovery, queue bounds, timestamp previews, and full,
closed, open-ended, suffix, HEAD, and unsatisfiable byte-range responses.
22 changes: 22 additions & 0 deletions docs/video-jobs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Video job lifecycle

Persistent video jobs move only through:

`queued → preparing → processing → muxing → completed`

Cancellation or a controlled failure may terminate any nonterminal stage.
Terminal jobs are `completed`, `failed`, and `canceled`. The default manager has
one daemon worker, four queued-job slots, and two attempts. Queue saturation
returns HTTP 429. Progress is stage-based and manifest writes are throttled
rather than performed for every frame.

Queued cancellation becomes terminal without processing. Active frame work
checks between frames; muxing polls FFmpeg and terminates, then kills it after a
timeout when necessary. Only terminal job history can be deleted, and deleting
history does not delete its output.

After restart, queued work is requeued. Interrupted work increments its attempt,
sets `recoveredAfterRestart`, removes partial temporary artifacts through the
temporary-job lifecycle, and requeues below the attempt limit. Missing source
media and exhausted attempts produce controlled failures. A completed output
and job are committed in one manifest mutation.
35 changes: 35 additions & 0 deletions docs/video-workflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Persistent video workflow

`POST /api/video-sources` accepts MP4, MOV, MKV, or AVI once. FFprobe validates
the first video stream, optional first audio stream, dimensions, duration, frame
rate, codecs, pixel format, and rotation before the source is persisted.
Configured duration and pixel limits are then applied.

`POST /api/video-sources/{sourceId}/preview` accepts a Recipe v1 and
`timestampSeconds`, seeks to that time, and returns temporary PNG bytes. The
frame index participates in deterministic seeded effects.

`POST /api/video-sources/{sourceId}/jobs` creates a persistent queued job with
`audioMode` set to `preserve` or `remove`. A bounded worker processes frames into
an intermediate, then FFmpeg creates H.264/yuv420p/faststart MP4. Preserved audio
uses the source's first audio stream and AAC at 192 kbps. The worker polls
cancellation between frames and while FFmpeg runs. Clients poll
`GET /api/video-jobs/{jobId}` or cancel with
`POST /api/video-jobs/{jobId}/cancel`.

Completed metadata is available at
`GET /api/video-outputs/{outputId}/metadata`. The output resource supports GET,
HEAD, and one byte range: closed (`bytes=0-99`), open-ended (`bytes=100-`), or
suffix (`bytes=-100`). Multiple or unsatisfiable ranges return 416 with the
complete size. Streaming uses bounded 64 KiB reads and holds the repository
lease until the response closes.

The older `/upload_preview`, `/process_video_async`, `/progress/{taskId}`, and
filename-oriented serving routes remain deprecated compatibility endpoints.
The current browser UI uses only the persistent APIs above.

OpenCV processes at the probed rate and may normalize variable-frame-rate input
to constant frame rate. Only the first audio stream is preserved. Subtitles,
chapters, attachments, multiple audio tracks, and hardware encoding are not
preserved or implemented. Detailed delivery optimization remains the
responsibility of Web Video Optimizer.
22 changes: 20 additions & 2 deletions glitchcraft/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@

from flask import Flask

from glitchcraft.storage.repository import ImageAssetRepository
from glitchcraft.jobs.manager import VideoJobManager
from glitchcraft.storage.repository import MediaAssetRepository
from glitchcraft.tasks import TaskStore
from glitchcraft.version import APP_VERSION, MANIFEST_SCHEMA_VERSION
from glitchcraft.web.routes import bp
Expand Down Expand Up @@ -37,6 +38,12 @@ def create_app(config: dict[str, Any] | None = None) -> Flask:
MAX_IMAGE_PIXELS=40_000_000,
TEMPORARY_MAXIMUM_AGE=24 * 60 * 60,
ORPHAN_CLEANUP_MINIMUM_AGE_HOURS=24,
MAX_VIDEO_DURATION_SECONDS=60 * 60,
MAX_VIDEO_PIXELS=3840 * 2160,
VIDEO_JOB_QUEUE_CAPACITY=4,
VIDEO_JOB_CONCURRENCY=1,
VIDEO_JOB_MAX_ATTEMPTS=2,
VIDEO_JOB_AUTOSTART=True,
)
if config:
app.config.update(config)
Expand All @@ -48,11 +55,22 @@ def create_app(config: dict[str, Any] | None = None) -> Flask:
for key in ("UPLOAD_FOLDER", "OUTPUT_FOLDER", "PREVIEW_FOLDER", "TEMPORARY_FOLDER"):
Path(app.config[key]).mkdir(parents=True, exist_ok=True)
app.extensions["task_store"] = TaskStore()
app.extensions["image_repository"] = ImageAssetRepository(
repository = MediaAssetRepository(
data_root=data_root,
manifest_path=Path(app.config["MANIFEST_PATH"]),
temporary_folder=Path(app.config["TEMPORARY_FOLDER"]),
temporary_maximum_age=float(app.config["TEMPORARY_MAXIMUM_AGE"]),
video_job_maximum_attempts=int(app.config["VIDEO_JOB_MAX_ATTEMPTS"]),
)
app.extensions["media_repository"] = repository
app.extensions["image_repository"] = repository
manager = VideoJobManager(
repository,
capacity=int(app.config["VIDEO_JOB_QUEUE_CAPACITY"]),
concurrency=int(app.config["VIDEO_JOB_CONCURRENCY"]),
)
app.extensions["video_job_manager"] = manager
if app.config["VIDEO_JOB_AUTOSTART"] and not app.config.get("TESTING"):
manager.start()
app.register_blueprint(bp)
return app
8 changes: 8 additions & 0 deletions glitchcraft/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,11 @@ class MediaWriteError(GlitchCraftError):

class ExternalToolError(GlitchCraftError):
"""An external media tool failed."""


class ProcessingCanceled(GlitchCraftError):
"""A cooperative media operation was canceled."""


class QueueCapacityError(GlitchCraftError):
"""The bounded background queue cannot accept more work."""
1 change: 1 addition & 0 deletions glitchcraft/jobs/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Persistent bounded background video jobs."""
29 changes: 29 additions & 0 deletions glitchcraft/jobs/contracts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Strict request contracts for the persistent video workflow."""

from math import isfinite

from pydantic import BaseModel, ConfigDict, Field, field_validator

from glitchcraft.contracts.effects import Recipe
from glitchcraft.storage.contracts import AudioMode


class VideoRequestModel(BaseModel):
model_config = ConfigDict(extra="forbid")


class VideoPreviewRequest(VideoRequestModel):
recipe: Recipe
timestamp_seconds: float = Field(alias="timestampSeconds", ge=0)

@field_validator("timestamp_seconds")
@classmethod
def finite_timestamp(cls, value: float) -> float:
if not isfinite(value):
raise ValueError("timestamp must be finite")
return value


class VideoJobRequest(VideoRequestModel):
recipe: Recipe
audio_mode: AudioMode = Field(default=AudioMode.PRESERVE, alias="audioMode")
Loading
Loading