diff --git a/README.md b/README.md index d0e8f13..b6eb6fc 100644 --- a/README.md +++ b/README.md @@ -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. @@ -35,7 +36,7 @@ python app.py ``` Open . 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. @@ -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) @@ -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 diff --git a/app.py b/app.py index 362a1b4..93a51a0 100644 --- a/app.py +++ b/app.py @@ -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) diff --git a/docs/architecture.md b/docs/architecture.md index 06f583e..6d8259c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. @@ -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. diff --git a/docs/image-workflow.md b/docs/image-workflow.md index 5a5deb3..b19ef6f 100644 --- a/docs/image-workflow.md +++ b/docs/image-workflow.md @@ -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. @@ -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. diff --git a/docs/product-direction.md b/docs/product-direction.md index 04b89ab..65b9f10 100644 --- a/docs/product-direction.md +++ b/docs/product-direction.md @@ -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 diff --git a/docs/recovery.md b/docs/recovery.md index c0828e2..fa3eb1e 100644 --- a/docs/recovery.md +++ b/docs/recovery.md @@ -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 @@ -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 diff --git a/docs/service-contract.md b/docs/service-contract.md index e3a0967..4793e36 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -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`. @@ -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. diff --git a/docs/storage.md b/docs/storage.md index 0f1cfa9..3bc5fd9 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -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/ ``` @@ -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 @@ -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. diff --git a/docs/testing.md b/docs/testing.md index 9506ebf..f512063 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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 @@ -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. diff --git a/docs/video-jobs.md b/docs/video-jobs.md new file mode 100644 index 0000000..9aab126 --- /dev/null +++ b/docs/video-jobs.md @@ -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. diff --git a/docs/video-workflow.md b/docs/video-workflow.md new file mode 100644 index 0000000..49ac2c5 --- /dev/null +++ b/docs/video-workflow.md @@ -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. diff --git a/glitchcraft/application.py b/glitchcraft/application.py index 824109e..4a9ee16 100644 --- a/glitchcraft/application.py +++ b/glitchcraft/application.py @@ -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 @@ -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) @@ -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 diff --git a/glitchcraft/errors.py b/glitchcraft/errors.py index 4092aef..64a26e3 100644 --- a/glitchcraft/errors.py +++ b/glitchcraft/errors.py @@ -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.""" diff --git a/glitchcraft/jobs/__init__.py b/glitchcraft/jobs/__init__.py new file mode 100644 index 0000000..a26e6b6 --- /dev/null +++ b/glitchcraft/jobs/__init__.py @@ -0,0 +1 @@ +"""Persistent bounded background video jobs.""" diff --git a/glitchcraft/jobs/contracts.py b/glitchcraft/jobs/contracts.py new file mode 100644 index 0000000..03b33bf --- /dev/null +++ b/glitchcraft/jobs/contracts.py @@ -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") diff --git a/glitchcraft/jobs/manager.py b/glitchcraft/jobs/manager.py new file mode 100644 index 0000000..89e6c0c --- /dev/null +++ b/glitchcraft/jobs/manager.py @@ -0,0 +1,214 @@ +"""Bounded worker pool backed by persistent repository job records.""" + +from __future__ import annotations + +import logging +from collections import deque +from contextlib import suppress +from pathlib import Path +from threading import Condition, Thread + +from glitchcraft.errors import ProcessingCanceled, QueueCapacityError +from glitchcraft.media.ffmpeg import finalize_video +from glitchcraft.media.probe import probe_video +from glitchcraft.media.video import process_video +from glitchcraft.storage.contracts import VideoJobState +from glitchcraft.storage.repository import MediaAssetRepository + +logger = logging.getLogger(__name__) + + +class VideoJobManager: + def __init__( + self, + repository: MediaAssetRepository, + *, + capacity: int = 8, + concurrency: int = 1, + ) -> None: + self.repository = repository + self.capacity = capacity + self.concurrency = concurrency + self._condition = Condition() + self._queue: deque[str] = deque() + self._queued: set[str] = set() + self._active: set[str] = set() + self._workers: list[Thread] = [] + self._stopping = False + + def start(self) -> None: + with self._condition: + if self._workers: + return + self._stopping = False + for index in range(self.concurrency): + worker = Thread( + target=self._worker, + name=f"glitchcraft-video-{index + 1}", + daemon=True, + ) + worker.start() + self._workers.append(worker) + for job in self.repository.recover_video_jobs(): + if job.state == VideoJobState.QUEUED: + self.enqueue(job.id) + + def enqueue(self, job_id: str) -> None: + with self._condition: + if job_id in self._queued: + return + if len(self._queue) >= self.capacity: + raise QueueCapacityError("The video processing queue is full.") + self._queue.append(job_id) + self._queued.add(job_id) + self._condition.notify() + + def cancel(self, job_id: str) -> None: + self.repository.request_video_job_cancellation(job_id) + with self._condition: + with suppress(ValueError): + self._queue.remove(job_id) + self._queued.discard(job_id) + + def shutdown(self, *, timeout: float = 5) -> None: + with self._condition: + self._stopping = True + self._condition.notify_all() + workers = list(self._workers) + for worker in workers: + worker.join(timeout) + with self._condition: + self._workers.clear() + + def status(self) -> dict[str, int]: + with self._condition: + return { + "capacity": self.capacity, + "queued": len(self._queue), + "concurrency": self.concurrency, + "workers": len(self._workers), + "active": len(self._active), + } + + def _worker(self) -> None: + while True: + with self._condition: + while not self._queue and not self._stopping: + self._condition.wait() + if self._stopping: + return + job_id = self._queue.popleft() + self._queued.discard(job_id) + self._active.add(job_id) + try: + self._run(job_id) + finally: + with self._condition: + self._active.discard(job_id) + + def _run(self, job_id: str) -> None: + intermediate: Path | None = None + staged_output: Path | None = None + try: + job = self.repository.get_video_job(job_id) + if job.state != VideoJobState.QUEUED: + return + job = self.repository.update_video_job( + job_id, + state=VideoJobState.PREPARING, + stage="preparing", + increment_attempt=not job.recovered_after_restart, + ) + with self.repository.lease_video_source(job.source_id) as (source, source_path): + intermediate = self.repository.new_video_job_temporary_path(job_id, ".mp4") + staged_output = self.repository.new_video_job_temporary_path(job_id, ".mp4") + + def canceled() -> bool: + return self.repository.get_video_job(job_id).cancellation_requested + + self.repository.update_video_job( + job_id, + state=VideoJobState.PROCESSING, + progress=5, + stage="processing", + ) + + def progress(done: int, total: int) -> None: + percent = min(89, max(5, int(done * 84 / total) + 5)) if total else 5 + current = self.repository.get_video_job(job_id) + if percent >= current.progress + 10: + self.repository.update_video_job(job_id, progress=percent) + + process_video( + source_path, + intermediate, + job.recipe, + progress_hook=progress, + cancellation_check=canceled, + ) + self.repository.update_video_job( + job_id, + state=VideoJobState.MUXING, + progress=90, + stage="muxing", + ) + finalize_video( + intermediate, + source_path, + staged_output, + preserve_audio=job.audio_mode.value == "preserve" and source.has_audio, + cancellation_check=canceled, + ) + metadata = probe_video(staged_output) + expected_audio = job.audio_mode.value == "preserve" and source.has_audio + if ( + metadata.video_codec != "h264" + or metadata.pixel_format != "yuv420p" + or metadata.has_audio != expected_audio + or (metadata.has_audio and metadata.audio_codec != "aac") + ): + raise RuntimeError("The finalized video does not match the output profile.") + self.repository.complete_video_job( + job_id=job_id, + staged_path=staged_output, + file_name=f"glitchcraft-{job_id}.mp4", + width=metadata.width, + height=metadata.height, + duration_seconds=metadata.duration_seconds, + frame_rate=metadata.frame_rate, + has_audio=metadata.has_audio, + file_size=staged_output.stat().st_size, + ) + staged_output = None + except ProcessingCanceled: + current = self.repository.get_video_job(job_id) + if not current.state.terminal: + self.repository.update_video_job( + job_id, + state=VideoJobState.CANCELED, + stage="canceled", + cancellation_requested=True, + ) + except Exception: + logger.exception("Persistent video job %s failed", job_id) + try: + current = self.repository.get_video_job(job_id) + if not current.state.terminal: + self.repository.update_video_job( + job_id, + state=VideoJobState.FAILED, + stage="failed", + error_code="video_processing_failed", + error_message="Video processing failed.", + ) + except Exception: + logger.exception("Could not persist failure for video job %s", job_id) + finally: + job_folder = intermediate.parent if intermediate is not None else None + if intermediate is not None: + intermediate.unlink(missing_ok=True) + if staged_output is not None: + staged_output.unlink(missing_ok=True) + if job_folder is not None: + with suppress(OSError): + job_folder.rmdir() diff --git a/glitchcraft/media/ffmpeg.py b/glitchcraft/media/ffmpeg.py index ccaec17..5d0c778 100644 --- a/glitchcraft/media/ffmpeg.py +++ b/glitchcraft/media/ffmpeg.py @@ -1,9 +1,64 @@ """FFmpeg invocation isolated from route and effect code.""" import subprocess +import tempfile +import time +from collections.abc import Callable from pathlib import Path -from glitchcraft.errors import ExternalToolError +from glitchcraft.errors import ExternalToolError, ProcessingCanceled + +DIAGNOSTIC_LIMIT = 4096 + + +def finalize_video( + intermediate_path: Path, + source_path: Path, + output_path: Path, + *, + preserve_audio: bool, + cancellation_check: Callable[[], bool], +) -> None: + """Create the browser-safe H.264/AAC MP4 while allowing prompt cancellation.""" + + command = ["ffmpeg", "-y", "-i", str(intermediate_path)] + if preserve_audio: + command.extend(["-i", str(source_path), "-map", "0:v:0", "-map", "1:a:0?"]) + command.extend(["-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "23"]) + if preserve_audio: + command.extend(["-c:a", "aac", "-b:a", "192k", "-shortest"]) + else: + command.append("-an") + command.extend(["-movflags", "+faststart", str(output_path)]) + with tempfile.TemporaryFile() as diagnostics: + try: + process = subprocess.Popen( + command, + stdout=subprocess.DEVNULL, + stderr=diagnostics, + ) + except OSError as exc: + raise ExternalToolError("FFmpeg could not be started.") from exc + try: + while process.poll() is None: + if cancellation_check(): + process.terminate() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + raise ProcessingCanceled("Video finalization was canceled.") + time.sleep(0.05) + if process.returncode: + diagnostics.seek(0, 2) + diagnostics.seek(max(0, diagnostics.tell() - DIAGNOSTIC_LIMIT)) + diagnostics.read(DIAGNOSTIC_LIMIT) + raise ExternalToolError("Video finalization failed.") + finally: + if process.poll() is None: + process.kill() + if process.returncode: + output_path.unlink(missing_ok=True) def reencode_for_browser(input_path: Path) -> None: diff --git a/glitchcraft/media/probe.py b/glitchcraft/media/probe.py new file mode 100644 index 0000000..199b04c --- /dev/null +++ b/glitchcraft/media/probe.py @@ -0,0 +1,108 @@ +"""Strict FFprobe adapter for persisted video metadata.""" + +from __future__ import annotations + +import json +import subprocess +from fractions import Fraction +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator + +from glitchcraft.errors import ExternalToolError, MediaReadError + + +class VideoMetadata(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + container: str + width: int = Field(ge=1) + height: int = Field(ge=1) + duration_seconds: float = Field(gt=0) + frame_rate: str + frame_count: int | None = Field(default=None, ge=1) + video_codec: str = Field(min_length=1) + pixel_format: str | None = None + has_audio: bool + audio_codec: str | None = None + rotation: int = 0 + + @field_validator("rotation") + @classmethod + def reject_rotation(cls, value: int) -> int: + if value % 360: + raise ValueError("rotated video streams are not supported") + return 0 + + +def _rate(value: object) -> str: + try: + rate = Fraction(str(value)) + except (ValueError, ZeroDivisionError) as exc: + raise MediaReadError("The video frame rate is invalid.") from exc + if rate <= 0 or float(rate) > 240: + raise MediaReadError("The video frame rate is unsupported.") + return f"{rate.numerator}/{rate.denominator}" + + +def probe_video(path: Path, *, timeout: float = 15) -> VideoMetadata: + command = [ + "ffprobe", + "-v", + "error", + "-show_streams", + "-show_format", + "-of", + "json", + str(path), + ] + try: + result = subprocess.run( + command, check=True, capture_output=True, text=True, timeout=timeout + ) + except FileNotFoundError as exc: + raise ExternalToolError("FFprobe is not installed or is not on PATH.") from exc + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as exc: + raise MediaReadError("The uploaded video could not be inspected.") from exc + try: + payload: dict[str, Any] = json.loads(result.stdout) + streams = payload["streams"] + video = next(stream for stream in streams if stream.get("codec_type") == "video") + audio = next((stream for stream in streams if stream.get("codec_type") == "audio"), None) + tags = video.get("tags") or {} + side_data = video.get("side_data_list") or [] + rotation = int( + tags.get( + "rotate", + next( + (item["rotation"] for item in side_data if item.get("rotation") is not None), + 0, + ), + ) + ) + format_name = str(payload["format"]["format_name"]).split(",")[0] + aliases = { + "matroska": "mkv", + "webm": "mkv", + "quicktime": "mov", + } + container = aliases.get(format_name, format_name) + duration = float(video.get("duration") or payload["format"]["duration"]) + frame_count_value = video.get("nb_frames") + value = { + "container": container, + "width": int(video["width"]), + "height": int(video["height"]), + "duration_seconds": duration, + "frame_rate": _rate(video.get("avg_frame_rate") or video["r_frame_rate"]), + "frame_count": int(frame_count_value) if frame_count_value else None, + "video_codec": str(video["codec_name"]), + "pixel_format": video.get("pix_fmt"), + "has_audio": audio is not None, + "audio_codec": str(audio["codec_name"]) if audio else None, + "rotation": rotation, + } + return VideoMetadata.model_validate(value) + except (KeyError, StopIteration, TypeError, ValueError, ValidationError) as exc: + raise MediaReadError("The video metadata is incomplete or unsupported.") from exc diff --git a/glitchcraft/media/video.py b/glitchcraft/media/video.py index 9de8008..78e9205 100644 --- a/glitchcraft/media/video.py +++ b/glitchcraft/media/video.py @@ -10,25 +10,47 @@ from glitchcraft.contracts.effects import Recipe from glitchcraft.effects.engine import apply_effect_stack -from glitchcraft.errors import MediaReadError, MediaWriteError +from glitchcraft.errors import MediaReadError, MediaWriteError, ProcessingCanceled from glitchcraft.media.color import bgr_to_rgb, rgb_to_bgr from glitchcraft.media.image_io import save_image_rgb ProgressHook = Callable[[int, int], None] +CancellationCheck = Callable[[], bool] def create_video_preview(input_path: Path, preview_path: Path, recipe: Recipe) -> None: + create_video_preview_at(input_path, preview_path, recipe, timestamp_seconds=0) + + +def create_video_preview_at( + input_path: Path, + preview_path: Path, + recipe: Recipe, + *, + timestamp_seconds: float, +) -> int: capture = cv2.VideoCapture(str(input_path)) try: if not capture.isOpened(): raise MediaReadError("The video could not be opened.") + if timestamp_seconds and hasattr(capture, "set"): + capture.set(cv2.CAP_PROP_POS_MSEC, timestamp_seconds * 1000) readable, frame_bgr = capture.read() if not readable or frame_bgr is None: raise MediaReadError("A preview frame could not be decoded.") + frame_index = 0 + if timestamp_seconds: + try: + frame_index = max(0, int(capture.get(cv2.CAP_PROP_POS_FRAMES)) - 1) + except (KeyError, TypeError, ValueError): + frame_index = max(0, int(timestamp_seconds * capture.get(cv2.CAP_PROP_FPS))) processed = apply_effect_stack( - bgr_to_rgb(cast(NDArray[np.uint8], frame_bgr)), recipe, frame_index=0 + bgr_to_rgb(cast(NDArray[np.uint8], frame_bgr)), + recipe, + frame_index=frame_index, ) save_image_rgb(processed, preview_path) + return frame_index finally: capture.release() @@ -38,7 +60,10 @@ def process_video( output_path: Path, recipe: Recipe, progress_hook: ProgressHook | None = None, + cancellation_check: CancellationCheck | None = None, ) -> None: + if cancellation_check is not None and cancellation_check(): + raise ProcessingCanceled("Video processing was canceled.") capture = cv2.VideoCapture(str(input_path)) writer: cv2.VideoWriter | None = None try: @@ -60,6 +85,8 @@ def process_video( raise MediaWriteError("The output video could not be created.") frame_index = 0 while True: + if cancellation_check is not None and cancellation_check(): + raise ProcessingCanceled("Video processing was canceled.") readable, frame_bgr = capture.read() if not readable: break diff --git a/glitchcraft/service_contract.py b/glitchcraft/service_contract.py index b90d862..69eb87f 100644 --- a/glitchcraft/service_contract.py +++ b/glitchcraft/service_contract.py @@ -16,6 +16,12 @@ "inline-image-preview", "image-export", "persistent-image-library", + "persistent-video-library", + "bounded-video-jobs", + "video-job-cancellation", + "timestamp-video-preview", + "audio-preserving-video-export", + "http-range-video-streaming", ) SUPPORTED_VIDEO_EXTENSIONS = ("avi", "mkv", "mov", "mp4") @@ -52,7 +58,7 @@ def effect_metadata() -> list[dict[str, Any]]: def capability_details(*, storage_available: bool) -> list[dict[str, Any]]: - video_ready = ffmpeg_available() + video_ready = ffmpeg_available() and ffprobe_available() and storage_available return [ {"slug": "image-effects", "exists": True, "available": True, "optional": False}, { @@ -86,7 +92,48 @@ def capability_details(*, storage_available: bool) -> list[dict[str, Any]]: "available": storage_available, "optional": False, }, - {"slug": "video-preview", "exists": True, "available": True, "optional": True}, + { + "slug": "persistent-video-library", + "exists": True, + "available": video_ready, + "optional": True, + }, + { + "slug": "bounded-video-jobs", + "exists": True, + "available": video_ready, + "optional": True, + }, + { + "slug": "video-job-cancellation", + "exists": True, + "available": video_ready, + "optional": True, + }, + { + "slug": "timestamp-video-preview", + "exists": True, + "available": video_ready, + "optional": True, + }, + { + "slug": "audio-preserving-video-export", + "exists": True, + "available": video_ready, + "optional": True, + }, + { + "slug": "http-range-video-streaming", + "exists": True, + "available": storage_available, + "optional": True, + }, + { + "slug": "video-preview", + "exists": True, + "available": video_ready, + "optional": True, + }, { "slug": "video-full-processing", "exists": True, diff --git a/glitchcraft/storage/contracts.py b/glitchcraft/storage/contracts.py index 2911aec..aa0ac94 100644 --- a/glitchcraft/storage/contracts.py +++ b/glitchcraft/storage/contracts.py @@ -1,15 +1,16 @@ -"""Strict schema-version-1 contracts for persistent image storage.""" +"""Strict persistent-media manifest contracts and job transitions.""" from __future__ import annotations from datetime import datetime, timedelta +from enum import StrEnum +from math import isfinite from pathlib import PurePosixPath from typing import Annotated, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from glitchcraft.contracts.effects import Recipe -from glitchcraft.version import MANIFEST_SCHEMA_VERSION PositiveDimension = Annotated[int, Field(ge=1, le=1_000_000)] OpaqueId = Annotated[str, Field(min_length=16, max_length=256, pattern=r"^[A-Za-z0-9_-]+$")] @@ -19,6 +20,12 @@ "BMP": ("image/bmp", {".bmp"}), "TIFF": ("image/tiff", {".tif", ".tiff"}), } +VIDEO_CONTAINERS = { + "mp4": ("video/mp4", ".mp4"), + "mov": ("video/quicktime", ".mov"), + "mkv": ("video/x-matroska", ".mkv"), + "avi": ("video/x-msvideo", ".avi"), +} class StorageModel(BaseModel): @@ -47,6 +54,28 @@ def validate_managed_path(value: str) -> str: return value +def validate_utc(value: datetime) -> datetime: + if value.tzinfo is None or value.utcoffset() != timedelta(0): + raise ValueError("timestamps must use UTC") + return value + + +def validate_rate(value: str) -> str: + parts = value.split("/") + if len(parts) != 2: + raise ValueError("frame rates must use numerator/denominator form") + try: + numerator, denominator = (int(part) for part in parts) + except ValueError as exc: + raise ValueError("frame rates must contain integers") from exc + if numerator <= 0 or denominator <= 0: + raise ValueError("frame rates must be positive") + rate = numerator / denominator + if not isfinite(rate) or rate > 240: + raise ValueError("frame rate is outside the supported range") + return f"{numerator}/{denominator}" + + class ImageSourceRecord(StorageModel): id: OpaqueId kind: Literal["image"] = "image" @@ -70,9 +99,7 @@ def validate_path(cls, value: str) -> str: @field_validator("created_at") @classmethod def validate_timestamp(cls, value: datetime) -> datetime: - if value.tzinfo is None or value.utcoffset() != timedelta(0): - raise ValueError("createdAt must use UTC") - return value + return validate_utc(value) @model_validator(mode="after") def validate_format_metadata(self) -> ImageSourceRecord: @@ -84,6 +111,64 @@ def validate_format_metadata(self) -> ImageSourceRecord: return self +class VideoSourceRecord(StorageModel): + id: OpaqueId + kind: Literal["video"] = "video" + original_name: Annotated[str, Field(min_length=1, max_length=255)] + stored_name: Annotated[str, Field(min_length=1, max_length=512)] + container: Literal["mp4", "mov", "mkv", "avi"] + mime_type: Literal["video/mp4", "video/quicktime", "video/x-matroska", "video/x-msvideo"] + width: PositiveDimension + height: PositiveDimension + duration_seconds: Annotated[float, Field(gt=0, le=24 * 60 * 60)] + frame_rate: str + frame_count: Annotated[int, Field(ge=1)] | None = None + video_codec: Annotated[str, Field(min_length=1, max_length=64)] + has_audio: bool + audio_codec: Annotated[str, Field(min_length=1, max_length=64)] | None = None + file_size: Annotated[int, Field(ge=1)] + seed: Annotated[int, Field(ge=0, le=(1 << 63) - 1)] + pixel_format: Annotated[str, Field(min_length=1, max_length=64)] | None = None + rotation: Literal[0] = 0 + created_at: datetime + + @field_validator("stored_name") + @classmethod + def validate_path(cls, value: str) -> str: + value = validate_managed_path(value) + if not value.startswith("sources/videos/"): + raise ValueError("video source paths must be inside sources/videos") + return value + + @field_validator("created_at") + @classmethod + def validate_timestamp(cls, value: datetime) -> datetime: + return validate_utc(value) + + @field_validator("duration_seconds") + @classmethod + def validate_duration(cls, value: float) -> float: + if not isfinite(value): + raise ValueError("duration must be finite") + return value + + @field_validator("frame_rate") + @classmethod + def validate_frame_rate(cls, value: str) -> str: + return validate_rate(value) + + @model_validator(mode="after") + def validate_video_metadata(self) -> VideoSourceRecord: + expected_mime, suffix = VIDEO_CONTAINERS[self.container] + if self.mime_type != expected_mime: + raise ValueError("video container and MIME type are inconsistent") + if PurePosixPath(self.stored_name).suffix.lower() != suffix: + raise ValueError("video container and stored extension are inconsistent") + if self.has_audio != (self.audio_codec is not None): + raise ValueError("audio presence and codec are inconsistent") + return self + + class ImageOutputRecord(StorageModel): id: OpaqueId kind: Literal["image"] = "image" @@ -107,9 +192,7 @@ def validate_path(cls, value: str) -> str: @field_validator("created_at") @classmethod def validate_timestamp(cls, value: datetime) -> datetime: - if value.tzinfo is None or value.utcoffset() != timedelta(0): - raise ValueError("createdAt must use UTC") - return value + return validate_utc(value) @model_validator(mode="after") def validate_output_extension(self) -> ImageOutputRecord: @@ -122,30 +205,214 @@ def seed(self) -> int: return self.recipe.seed -class ManifestDocument(StorageModel): - schema_version: Annotated[int, Field(ge=MANIFEST_SCHEMA_VERSION, le=MANIFEST_SCHEMA_VERSION)] +class AudioMode(StrEnum): + PRESERVE = "preserve" + REMOVE = "remove" + + +class VideoOutputRecord(StorageModel): + id: OpaqueId + kind: Literal["video"] = "video" + source_id: OpaqueId + job_id: OpaqueId + file_name: Annotated[str, Field(min_length=1, max_length=255)] + stored_name: Annotated[str, Field(min_length=1, max_length=512)] + mime_type: Literal["video/mp4"] = "video/mp4" + width: PositiveDimension + height: PositiveDimension + duration_seconds: Annotated[float, Field(gt=0, le=24 * 60 * 60)] + frame_rate: str + video_codec: Literal["h264"] = "h264" + pixel_format: Literal["yuv420p"] = "yuv420p" + audio_mode: AudioMode + has_audio: bool + audio_codec: Literal["aac"] | None = None + file_size: Annotated[int, Field(ge=1)] + recipe: Recipe + created_at: datetime + + @field_validator("stored_name") + @classmethod + def validate_path(cls, value: str) -> str: + value = validate_managed_path(value) + if not value.startswith("outputs/videos/") or not value.endswith(".mp4"): + raise ValueError("video outputs must be MP4 files inside outputs/videos") + return value + + @field_validator("created_at") + @classmethod + def validate_timestamp(cls, value: datetime) -> datetime: + return validate_utc(value) + + @field_validator("duration_seconds") + @classmethod + def validate_duration(cls, value: float) -> float: + if not isfinite(value): + raise ValueError("duration must be finite") + return value + + @field_validator("frame_rate") + @classmethod + def validate_frame_rate(cls, value: str) -> str: + return validate_rate(value) + + @model_validator(mode="after") + def validate_audio(self) -> VideoOutputRecord: + if self.has_audio != (self.audio_codec is not None): + raise ValueError("output audio presence and codec are inconsistent") + if self.audio_mode == AudioMode.REMOVE and self.has_audio: + raise ValueError("remove-audio outputs cannot contain audio") + return self + + @property + def seed(self) -> int: + return self.recipe.seed + + +class VideoJobState(StrEnum): + QUEUED = "queued" + PREPARING = "preparing" + PROCESSING = "processing" + MUXING = "muxing" + COMPLETED = "completed" + FAILED = "failed" + CANCELED = "canceled" + + @property + def terminal(self) -> bool: + return self in {self.COMPLETED, self.FAILED, self.CANCELED} + + +JOB_TRANSITIONS: dict[VideoJobState, frozenset[VideoJobState]] = { + VideoJobState.QUEUED: frozenset( + {VideoJobState.PREPARING, VideoJobState.CANCELED, VideoJobState.FAILED} + ), + VideoJobState.PREPARING: frozenset( + {VideoJobState.PROCESSING, VideoJobState.CANCELED, VideoJobState.FAILED} + ), + VideoJobState.PROCESSING: frozenset( + {VideoJobState.MUXING, VideoJobState.CANCELED, VideoJobState.FAILED} + ), + VideoJobState.MUXING: frozenset( + {VideoJobState.COMPLETED, VideoJobState.CANCELED, VideoJobState.FAILED} + ), + VideoJobState.COMPLETED: frozenset(), + VideoJobState.FAILED: frozenset(), + VideoJobState.CANCELED: frozenset(), +} + + +def validate_job_transition(current: VideoJobState, target: VideoJobState) -> None: + if target not in JOB_TRANSITIONS[current]: + raise ValueError(f"invalid video job transition: {current} -> {target}") + + +class VideoJobRecord(StorageModel): + id: OpaqueId + source_id: OpaqueId + output_id: OpaqueId | None = None + recipe: Recipe + audio_mode: AudioMode = AudioMode.PRESERVE + state: VideoJobState = VideoJobState.QUEUED + progress: Annotated[int, Field(ge=0, le=100)] = 0 + stage: Annotated[str, Field(min_length=1, max_length=64)] = "queued" + created_at: datetime + updated_at: datetime + started_at: datetime | None = None + completed_at: datetime | None = None + attempt: Annotated[int, Field(ge=0, le=100)] = 0 + recovered_after_restart: bool = False + cancellation_requested: bool = False + error_code: Annotated[str, Field(min_length=1, max_length=64)] | None = None + error_message: Annotated[str, Field(min_length=1, max_length=256)] | None = None + + @field_validator("created_at", "updated_at", "started_at", "completed_at") + @classmethod + def validate_timestamp(cls, value: datetime | None) -> datetime | None: + return None if value is None else validate_utc(value) + + @model_validator(mode="after") + def validate_state_fields(self) -> VideoJobRecord: + if self.state == VideoJobState.COMPLETED and ( + self.output_id is None or self.progress != 100 or self.completed_at is None + ): + raise ValueError("completed jobs require an output, 100 progress, and completion time") + if self.state in {VideoJobState.FAILED, VideoJobState.CANCELED} and ( + self.output_id is not None or self.completed_at is None + ): + raise ValueError("failed and canceled jobs require terminal time and no output") + if self.state == VideoJobState.FAILED and ( + self.error_code is None or self.error_message is None + ): + raise ValueError("failed jobs require a controlled error") + if self.state != VideoJobState.FAILED and ( + self.error_code is not None or self.error_message is not None + ): + raise ValueError("only failed jobs may contain errors") + return self + + +SourceRecord = Annotated[ImageSourceRecord | VideoSourceRecord, Field(discriminator="kind")] +OutputRecord = Annotated[ImageOutputRecord | VideoOutputRecord, Field(discriminator="kind")] + + +class ManifestDocumentV1(StorageModel): + schema_version: Literal[1] = 1 sources: dict[str, ImageSourceRecord] = Field(default_factory=dict) outputs: dict[str, ImageOutputRecord] = Field(default_factory=dict) @model_validator(mode="after") - def validate_record_keys(self) -> ManifestDocument: + def validate_record_keys(self) -> ManifestDocumentV1: + if any(key != record.id for key, record in self.sources.items()): + raise ValueError("source record keys must match record IDs") + if any(key != record.id for key, record in self.outputs.items()): + raise ValueError("output record keys must match record IDs") + return self + + +class ManifestDocumentV2(StorageModel): + schema_version: Literal[2] = 2 + sources: dict[str, SourceRecord] = Field(default_factory=dict) + outputs: dict[str, OutputRecord] = Field(default_factory=dict) + jobs: dict[str, VideoJobRecord] = Field(default_factory=dict) + + @model_validator(mode="after") + def validate_record_keys(self) -> ManifestDocumentV2: if any(key != record.id for key, record in self.sources.items()): raise ValueError("source record keys must match record IDs") if any(key != record.id for key, record in self.outputs.items()): raise ValueError("output record keys must match record IDs") + if any(key != record.id for key, record in self.jobs.items()): + raise ValueError("job record keys must match record IDs") return self +ManifestDocument = ManifestDocumentV2 + + class ReconciliationReport(StorageModel): - state: Literal["clean", "changed", "recovered", "unavailable"] + state: Literal["clean", "changed", "recovered", "migrated", "unavailable"] recovered_from_backup: bool = False + migrated_from_schema_version: int | None = None missing_sources: int = 0 missing_outputs: int = 0 - orphan_sources: int = 0 - orphan_outputs: int = 0 + orphan_image_sources: int = 0 + orphan_video_sources: int = 0 + orphan_image_outputs: int = 0 + orphan_video_outputs: int = 0 + recovered_jobs: int = 0 + failed_jobs: int = 0 manifest_changed: bool = False occurred_at: datetime + @property + def orphan_sources(self) -> int: + return self.orphan_image_sources + self.orphan_video_sources + + @property + def orphan_outputs(self) -> int: + return self.orphan_image_outputs + self.orphan_video_outputs + class CleanupRequest(StorageModel): dry_run: bool = True diff --git a/glitchcraft/storage/manifest.py b/glitchcraft/storage/manifest.py index 0a24708..138ff26 100644 --- a/glitchcraft/storage/manifest.py +++ b/glitchcraft/storage/manifest.py @@ -5,11 +5,17 @@ import json import os import tempfile +from dataclasses import dataclass from pathlib import Path +from typing import Any from pydantic import ValidationError -from glitchcraft.storage.contracts import ManifestDocument +from glitchcraft.storage.contracts import ( + ManifestDocument, + ManifestDocumentV1, + ManifestDocumentV2, +) from glitchcraft.storage.errors import ( ManifestReadError, ManifestValidationError, @@ -22,16 +28,54 @@ def serialize_manifest(manifest: ManifestDocument) -> bytes: return (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode() -def load_manifest(path: Path) -> ManifestDocument: +@dataclass(frozen=True) +class LoadedManifest: + """A validated current manifest plus safe migration metadata.""" + + document: ManifestDocumentV2 + source_schema_version: int + raw_bytes: bytes + + @property + def migrated(self) -> bool: + return self.source_schema_version != self.document.schema_version + + +def load_manifest_versioned(path: Path) -> LoadedManifest: try: raw = path.read_bytes() except OSError as exc: raise ManifestReadError("The storage manifest could not be read.") from exc try: - value = json.loads(raw) - return ManifestDocument.model_validate(value) + value: Any = json.loads(raw) except (json.JSONDecodeError, UnicodeDecodeError, ValidationError) as exc: raise ManifestValidationError("The storage manifest is invalid.") from exc + if not isinstance(value, dict): + raise ManifestValidationError("The storage manifest is invalid.") + schema_version = value.get("schemaVersion") + try: + if schema_version == 2: + document = ManifestDocumentV2.model_validate(value) + elif schema_version == 1: + legacy = ManifestDocumentV1.model_validate(value) + document = ManifestDocumentV2( + sources=legacy.sources, + outputs=legacy.outputs, + jobs={}, + ) + else: + raise ManifestValidationError("The manifest schema version is unsupported.") + except ValidationError as exc: + raise ManifestValidationError("The storage manifest is invalid.") from exc + return LoadedManifest( + document=document, + source_schema_version=int(schema_version), + raw_bytes=raw, + ) + + +def load_manifest(path: Path) -> ManifestDocument: + return load_manifest_versioned(path).document class AtomicManifestWriter: diff --git a/glitchcraft/storage/repository.py b/glitchcraft/storage/repository.py index c9f2fc5..d399933 100644 --- a/glitchcraft/storage/repository.py +++ b/glitchcraft/storage/repository.py @@ -1,4 +1,4 @@ -"""Thread-safe, single-process repository for persistent image assets.""" +"""Thread-safe, single-process repository for persistent media assets and jobs.""" from __future__ import annotations @@ -24,6 +24,11 @@ ImageSourceRecord, ManifestDocument, ReconciliationReport, + VideoJobRecord, + VideoJobState, + VideoOutputRecord, + VideoSourceRecord, + validate_job_transition, ) from glitchcraft.storage.errors import ( AssetConflictError, @@ -34,7 +39,11 @@ ManifestWriteError, StorageUnavailableError, ) -from glitchcraft.storage.manifest import AtomicManifestWriter, load_manifest +from glitchcraft.storage.manifest import ( + AtomicManifestWriter, + LoadedManifest, + load_manifest_versioned, +) from glitchcraft.version import MANIFEST_SCHEMA_VERSION logger = logging.getLogger(__name__) @@ -44,7 +53,7 @@ def _utc_now() -> datetime: return datetime.now(UTC) -class ImageAssetRepository: +class MediaAssetRepository: """Own managed paths, persistence, leases, reconciliation, and cleanup. Writes are safe between threads in one application process. Multiple writer @@ -58,6 +67,7 @@ def __init__( temporary_folder: Path, *, temporary_maximum_age: float = 86400, + video_job_maximum_attempts: int = 2, ) -> None: self.data_root = data_root.resolve() self.manifest_path = manifest_path.resolve() @@ -70,14 +80,21 @@ def __init__( raise ManagedPathError( "Managed storage locations must remain inside the data root." ) from exc - self.source_folder = self.data_root / "sources" / "images" - self.output_folder = self.data_root / "outputs" / "images" + self.image_source_folder = self.data_root / "sources" / "images" + self.video_source_folder = self.data_root / "sources" / "videos" + self.image_output_folder = self.data_root / "outputs" / "images" + self.video_output_folder = self.data_root / "outputs" / "videos" + self.video_job_temporary_folder = self.temporary_folder / "video-jobs" + # Compatibility attributes retained for the established image API. + self.source_folder = self.image_source_folder + self.output_folder = self.image_output_folder self.recovery_folder = self.data_root / "recovery" self.temporary_maximum_age = temporary_maximum_age + self.video_job_maximum_attempts = video_job_maximum_attempts self._lock = RLock() self._leases: dict[tuple[str, str], int] = {} self._manifest = ManifestDocument( - schema_version=MANIFEST_SCHEMA_VERSION, sources={}, outputs={} + schema_version=MANIFEST_SCHEMA_VERSION, sources={}, outputs={}, jobs={} ) self._available = True self._warning: str | None = None @@ -108,6 +125,9 @@ def _initialize(self) -> None: self.data_root, self.source_folder, self.output_folder, + self.video_source_folder, + self.video_output_folder, + self.video_job_temporary_folder, self.temporary_folder, self.recovery_folder, ): @@ -116,22 +136,31 @@ def _initialize(self) -> None: recovered = False if self.manifest_path.exists(): try: - manifest = load_manifest(self.manifest_path) + loaded = load_manifest_versioned(self.manifest_path) + manifest = self._finish_migration(loaded, self.manifest_path) except (ManifestReadError, ManifestValidationError) as primary_error: manifest = self._recover_backup(primary_error) recovered = True + except ManifestWriteError as exc: + self._mark_unavailable(exc) + return elif self.backup_path.exists(): try: - manifest = load_manifest(self.backup_path) + loaded = load_manifest_versioned(self.backup_path) + manifest = self._finish_migration(loaded, self.backup_path) self._writer.write(manifest, preserve_backup=True) recovered = True - self._warning = "The primary manifest was restored from backup." + self._warning = ( + "The primary manifest was restored from backup and migrated." + if loaded.migrated + else "The primary manifest was restored from backup." + ) except (ManifestReadError, ManifestValidationError, ManifestWriteError) as exc: self._mark_unavailable(exc) return else: manifest = ManifestDocument( - schema_version=MANIFEST_SCHEMA_VERSION, sources={}, outputs={} + schema_version=MANIFEST_SCHEMA_VERSION, sources={}, outputs={}, jobs={} ) try: self._writer.write(manifest) @@ -142,14 +171,33 @@ def _initialize(self) -> None: self._manifest = manifest self._reconcile(recovered=recovered) + def _finish_migration(self, loaded: LoadedManifest, source_path: Path) -> ManifestDocument: + if not loaded.migrated: + return loaded.document + stamp = _utc_now().strftime("%Y%m%dT%H%M%S%fZ") + recovery_path = self.recovery_folder / f"manifest-v1-{stamp}.json" + try: + recovery_path.write_bytes(loaded.raw_bytes) + if source_path == self.manifest_path: + self._writer.write(loaded.document) + self._warning = "The storage manifest was safely migrated from schema version 1." + except OSError as exc: + raise ManifestWriteError("The pre-migration manifest could not be preserved.") from exc + return loaded.document + def _recover_backup(self, primary_error: Exception) -> ManifestDocument: if not self.backup_path.exists(): self._mark_unavailable(primary_error) return self._manifest try: - manifest = load_manifest(self.backup_path) + loaded = load_manifest_versioned(self.backup_path) + manifest = self._finish_migration(loaded, self.backup_path) self._writer.write(manifest, preserve_backup=True) - self._warning = "The primary manifest was restored from backup." + self._warning = ( + "The primary manifest was restored from backup and migrated." + if loaded.migrated + else "The primary manifest was restored from backup." + ) return manifest except (ManifestReadError, ManifestValidationError, ManifestWriteError) as exc: self._mark_unavailable(exc) @@ -166,34 +214,85 @@ def _mark_unavailable(self, exc: Exception) -> None: def _require_available(self) -> None: if not self._available: - raise StorageUnavailableError("Persistent image storage is unavailable.") + raise StorageUnavailableError("Persistent media storage is unavailable.") def _reconcile(self, *, recovered: bool) -> None: if not self._available: return sources = dict(self._manifest.sources) outputs = dict(self._manifest.outputs) + jobs = dict(self._manifest.jobs) missing_sources = [ asset_id for asset_id, record in sources.items() - if not self._resolve(record.stored_name, "sources/images").is_file() + if not self._resolve(record.stored_name, f"sources/{record.kind}s").is_file() ] missing_outputs = [ asset_id for asset_id, record in outputs.items() - if not self._resolve(record.stored_name, "outputs/images").is_file() + if not self._resolve(record.stored_name, f"outputs/{record.kind}s").is_file() ] for asset_id in missing_sources: sources.pop(asset_id) for asset_id in missing_outputs: outputs.pop(asset_id) - changed = bool(missing_sources or missing_outputs) + failed_jobs = 0 + recovered_jobs = 0 + now = _utc_now() + for job_id, job in list(jobs.items()): + if job.state.terminal and job.state != VideoJobState.COMPLETED: + continue + matching_output = next( + ( + output + for output in outputs.values() + if isinstance(output, VideoOutputRecord) and output.job_id == job_id + ), + None, + ) + if matching_output is not None and job.state != VideoJobState.COMPLETED: + jobs[job_id] = VideoJobRecord.model_validate( + job.model_copy( + update={ + "state": VideoJobState.COMPLETED, + "output_id": matching_output.id, + "progress": 100, + "stage": "completed", + "updated_at": now, + "completed_at": now, + "error_code": None, + "error_message": None, + } + ).model_dump() + ) + recovered_jobs += 1 + elif job.source_id not in sources or ( + job.state == VideoJobState.COMPLETED + and (job.output_id is None or job.output_id in missing_outputs) + ): + jobs[job_id] = VideoJobRecord.model_validate( + job.model_copy( + update={ + "state": VideoJobState.FAILED, + "output_id": None, + "stage": "failed", + "updated_at": now, + "completed_at": now, + "error_code": "reconciliation_missing_media", + "error_message": "Required persistent media is unavailable.", + } + ).model_dump() + ) + failed_jobs += 1 + + changed = bool(missing_sources or missing_outputs or failed_jobs or recovered_jobs) if changed: next_manifest = ManifestDocument( schema_version=MANIFEST_SCHEMA_VERSION, sources=sources, outputs=outputs, + jobs=jobs, ) try: self._writer.write(next_manifest) @@ -203,14 +302,24 @@ def _reconcile(self, *, recovered: bool) -> None: return orphan_sources, orphan_outputs = self._orphan_paths() - state = "recovered" if recovered else ("changed" if changed else "clean") + migrated = self._warning is not None and "migrated" in self._warning + state = ( + "migrated" + if migrated + else ("recovered" if recovered else ("changed" if changed else "clean")) + ) self._last_report = ReconciliationReport( state=state, recovered_from_backup=recovered, missing_sources=len(missing_sources), missing_outputs=len(missing_outputs), - orphan_sources=len(orphan_sources), - orphan_outputs=len(orphan_outputs), + migrated_from_schema_version=1 if migrated else None, + orphan_image_sources=len(orphan_sources["image"]), + orphan_video_sources=len(orphan_sources["video"]), + orphan_image_outputs=len(orphan_outputs["image"]), + orphan_video_outputs=len(orphan_outputs["video"]), + recovered_jobs=recovered_jobs, + failed_jobs=failed_jobs, manifest_changed=changed, occurred_at=_utc_now(), ) @@ -238,6 +347,21 @@ def new_temporary_path(self, suffix: str) -> Path: os.close(descriptor) return Path(name) + def new_video_job_temporary_path(self, job_id: str, suffix: str) -> Path: + with self._lock: + self._require_available() + if not job_id or any( + character not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-" + for character in job_id + ): + raise ManagedPathError("The video job temporary path is invalid.") + folder = self.video_job_temporary_folder / job_id + folder.mkdir(parents=True, exist_ok=True) + safe_suffix = suffix if suffix.startswith(".") and suffix[1:].isalnum() else ".tmp" + descriptor, name = tempfile.mkstemp(prefix="job-", suffix=safe_suffix, dir=folder) + os.close(descriptor) + return Path(name) + def create_source( self, *, @@ -298,6 +422,8 @@ def create_output( self._require_available() if source_id not in self._manifest.sources: raise AssetNotFoundError("Unknown image source.") + if not isinstance(self._manifest.sources[source_id], ImageSourceRecord): + raise AssetNotFoundError("Unknown image source.") asset_id = self._new_id() stored_name = f"outputs/images/{asset_id}.png" final_path = self._resolve(stored_name, "outputs/images") @@ -333,7 +459,10 @@ def get_source(self, asset_id: str) -> ImageSourceRecord: with self._lock: self._require_available() record = self._manifest.sources.get(asset_id) - if record is None or not self._resolve(record.stored_name, "sources/images").is_file(): + if ( + not isinstance(record, ImageSourceRecord) + or not self._resolve(record.stored_name, "sources/images").is_file() + ): raise AssetNotFoundError("Unknown image source.") return record.model_copy(deep=True) @@ -341,7 +470,10 @@ def get_output(self, asset_id: str) -> ImageOutputRecord: with self._lock: self._require_available() record = self._manifest.outputs.get(asset_id) - if record is None or not self._resolve(record.stored_name, "outputs/images").is_file(): + if ( + not isinstance(record, ImageOutputRecord) + or not self._resolve(record.stored_name, "outputs/images").is_file() + ): raise AssetNotFoundError("Unknown image output.") return record.model_copy(deep=True) @@ -349,7 +481,11 @@ def list_sources(self) -> list[ImageSourceRecord]: with self._lock: self._require_available() return sorted( - (record.model_copy(deep=True) for record in self._manifest.sources.values()), + ( + record.model_copy(deep=True) + for record in self._manifest.sources.values() + if isinstance(record, ImageSourceRecord) + ), key=lambda record: record.created_at, reverse=True, ) @@ -358,20 +494,28 @@ def list_outputs(self) -> list[ImageOutputRecord]: with self._lock: self._require_available() return sorted( - (record.model_copy(deep=True) for record in self._manifest.outputs.values()), + ( + record.model_copy(deep=True) + for record in self._manifest.outputs.values() + if isinstance(record, ImageOutputRecord) + ), key=lambda record: record.created_at, reverse=True, ) def output_count(self, source_id: str) -> int: with self._lock: - return sum(record.source_id == source_id for record in self._manifest.outputs.values()) + return sum( + isinstance(record, ImageOutputRecord) and record.source_id == source_id + for record in self._manifest.outputs.values() + ) def source_available(self, source_id: str) -> bool: with self._lock: record = self._manifest.sources.get(source_id) return ( - record is not None and self._resolve(record.stored_name, "sources/images").is_file() + isinstance(record, ImageSourceRecord) + and self._resolve(record.stored_name, "sources/images").is_file() ) @contextmanager @@ -405,7 +549,7 @@ def delete_output(self, asset_id: str) -> None: with self._lock: self._require_available() record = self._manifest.outputs.get(asset_id) - if record is None: + if not isinstance(record, ImageOutputRecord): raise AssetNotFoundError("Unknown image output.") self._ensure_not_leased("output", asset_id) self._delete_records([], [record]) @@ -414,10 +558,12 @@ def delete_source(self, asset_id: str, *, cascade: bool) -> int: with self._lock: self._require_available() source = self._manifest.sources.get(asset_id) - if source is None: + if not isinstance(source, ImageSourceRecord): raise AssetNotFoundError("Unknown image source.") outputs = [ - record for record in self._manifest.outputs.values() if record.source_id == asset_id + record + for record in self._manifest.outputs.values() + if isinstance(record, ImageOutputRecord) and record.source_id == asset_id ] if outputs and not cascade: raise AssetConflictError("The image source still has exported outputs.") @@ -451,6 +597,7 @@ def _delete_records( schema_version=MANIFEST_SCHEMA_VERSION, sources=next_sources, outputs=next_outputs, + jobs=self._manifest.jobs, ) self._writer.write(next_manifest) except (OSError, ManifestWriteError) as exc: @@ -471,11 +618,480 @@ def _ensure_not_leased(self, kind: str, asset_id: str) -> None: if self._leases.get((kind, asset_id), 0): raise AssetConflictError("The image asset is currently in use.") + def create_video_source( + self, + *, + staged_path: Path, + extension: str, + original_name: str, + container: str, + mime_type: str, + width: int, + height: int, + duration_seconds: float, + frame_rate: str, + frame_count: int | None, + video_codec: str, + has_audio: bool, + audio_codec: str | None, + file_size: int, + seed: int, + pixel_format: str | None, + ) -> VideoSourceRecord: + with self._lock: + self._require_available() + asset_id = self._new_id() + stored_name = f"sources/videos/{asset_id}.{extension.lower()}" + final_path = self._resolve(stored_name, "sources/videos") + record = VideoSourceRecord( + id=asset_id, + original_name=original_name, + stored_name=stored_name, + container=container, + mime_type=mime_type, + width=width, + height=height, + duration_seconds=duration_seconds, + frame_rate=frame_rate, + frame_count=frame_count, + video_codec=video_codec, + has_audio=has_audio, + audio_codec=audio_codec, + file_size=file_size, + seed=seed, + pixel_format=pixel_format, + created_at=_utc_now(), + ) + try: + os.replace(staged_path, final_path) + next_manifest = self._validated_manifest( + sources={**self._manifest.sources, asset_id: record} + ) + self._writer.write(next_manifest) + except (OSError, ValidationError, ManifestWriteError) as exc: + final_path.unlink(missing_ok=True) + if isinstance(exc, ManifestWriteError): + raise + raise ManifestWriteError("The video source could not be saved.") from exc + self._manifest = next_manifest + return record.model_copy(deep=True) + + def get_video_source(self, asset_id: str) -> VideoSourceRecord: + with self._lock: + self._require_available() + record = self._manifest.sources.get(asset_id) + if ( + not isinstance(record, VideoSourceRecord) + or not self._resolve(record.stored_name, "sources/videos").is_file() + ): + raise AssetNotFoundError("Unknown video source.") + return record.model_copy(deep=True) + + def list_video_sources(self) -> list[VideoSourceRecord]: + with self._lock: + self._require_available() + return sorted( + ( + record.model_copy(deep=True) + for record in self._manifest.sources.values() + if isinstance(record, VideoSourceRecord) + ), + key=lambda record: record.created_at, + reverse=True, + ) + + @contextmanager + def lease_video_source(self, asset_id: str) -> Iterator[tuple[VideoSourceRecord, Path]]: + with self._lock: + record = self.get_video_source(asset_id) + key = ("video-source", asset_id) + self._leases[key] = self._leases.get(key, 0) + 1 + path = self._resolve(record.stored_name, "sources/videos") + try: + yield record, path + finally: + with self._lock: + self._leases[key] = max(0, self._leases.get(key, 1) - 1) + + def create_video_job( + self, *, source_id: str, recipe: Recipe, audio_mode: str + ) -> VideoJobRecord: + with self._lock: + self.get_video_source(source_id) + job_id = self._new_id() + now = _utc_now() + record = VideoJobRecord( + id=job_id, + source_id=source_id, + recipe=recipe, + audio_mode=audio_mode, + created_at=now, + updated_at=now, + ) + next_manifest = self._validated_manifest(jobs={**self._manifest.jobs, job_id: record}) + self._writer.write(next_manifest) + self._manifest = next_manifest + return record.model_copy(deep=True) + + def get_video_job(self, job_id: str) -> VideoJobRecord: + with self._lock: + self._require_available() + record = self._manifest.jobs.get(job_id) + if record is None: + raise AssetNotFoundError("Unknown video job.") + return record.model_copy(deep=True) + + def list_video_jobs(self) -> list[VideoJobRecord]: + with self._lock: + self._require_available() + return sorted( + (record.model_copy(deep=True) for record in self._manifest.jobs.values()), + key=lambda record: record.created_at, + reverse=True, + ) + + def update_video_job( + self, + job_id: str, + *, + state: VideoJobState | None = None, + progress: int | None = None, + stage: str | None = None, + cancellation_requested: bool | None = None, + error_code: str | None = None, + error_message: str | None = None, + increment_attempt: bool = False, + recovered_after_restart: bool | None = None, + ) -> VideoJobRecord: + with self._lock: + current = self.get_video_job(job_id) + if state is not None and state != current.state: + validate_job_transition(current.state, state) + now = _utc_now() + changes: dict[str, object] = {"updated_at": now} + if state is not None: + changes["state"] = state + if state == VideoJobState.PREPARING and current.started_at is None: + changes["started_at"] = now + if state.terminal: + changes["completed_at"] = now + if progress is not None: + changes["progress"] = progress + if stage is not None: + changes["stage"] = stage + if cancellation_requested is not None: + changes["cancellation_requested"] = cancellation_requested + if error_code is not None: + changes["error_code"] = error_code + if error_message is not None: + changes["error_message"] = error_message + if increment_attempt: + changes["attempt"] = current.attempt + 1 + if recovered_after_restart is not None: + changes["recovered_after_restart"] = recovered_after_restart + record = VideoJobRecord.model_validate(current.model_copy(update=changes).model_dump()) + next_manifest = self._validated_manifest(jobs={**self._manifest.jobs, job_id: record}) + self._writer.write(next_manifest) + self._manifest = next_manifest + return record.model_copy(deep=True) + + def request_video_job_cancellation(self, job_id: str) -> VideoJobRecord: + with self._lock: + current = self.get_video_job(job_id) + if current.state.terminal: + return current + if current.state == VideoJobState.QUEUED: + return self.update_video_job( + job_id, + state=VideoJobState.CANCELED, + progress=current.progress, + stage="canceled", + cancellation_requested=True, + ) + return self.update_video_job(job_id, cancellation_requested=True) + + def delete_video_job(self, job_id: str) -> None: + with self._lock: + job = self.get_video_job(job_id) + if not job.state.terminal: + raise AssetConflictError("Only terminal video jobs can be deleted.") + next_jobs = dict(self._manifest.jobs) + next_jobs.pop(job_id) + next_manifest = self._validated_manifest(jobs=next_jobs) + self._writer.write(next_manifest) + self._manifest = next_manifest + + def complete_video_job( + self, + *, + job_id: str, + staged_path: Path, + file_name: str, + width: int, + height: int, + duration_seconds: float, + frame_rate: str, + has_audio: bool, + file_size: int, + ) -> VideoOutputRecord: + with self._lock: + job = self.get_video_job(job_id) + if job.state != VideoJobState.MUXING: + raise AssetConflictError("The video job is not ready to complete.") + output_id = self._new_id() + stored_name = f"outputs/videos/{output_id}.mp4" + final_path = self._resolve(stored_name, "outputs/videos") + output = VideoOutputRecord( + id=output_id, + source_id=job.source_id, + job_id=job.id, + file_name=file_name, + stored_name=stored_name, + width=width, + height=height, + duration_seconds=duration_seconds, + frame_rate=frame_rate, + audio_mode=job.audio_mode, + has_audio=has_audio, + audio_codec="aac" if has_audio else None, + file_size=file_size, + recipe=job.recipe, + created_at=_utc_now(), + ) + completed = VideoJobRecord.model_validate( + job.model_copy( + update={ + "state": VideoJobState.COMPLETED, + "progress": 100, + "stage": "completed", + "output_id": output_id, + "updated_at": _utc_now(), + "completed_at": _utc_now(), + } + ).model_dump() + ) + try: + os.replace(staged_path, final_path) + next_manifest = self._validated_manifest( + outputs={**self._manifest.outputs, output_id: output}, + jobs={**self._manifest.jobs, job_id: completed}, + ) + self._writer.write(next_manifest) + except (OSError, ValidationError, ManifestWriteError) as exc: + final_path.unlink(missing_ok=True) + if isinstance(exc, ManifestWriteError): + raise + raise ManifestWriteError("The video output could not be committed.") from exc + self._manifest = next_manifest + return output.model_copy(deep=True) + + def get_video_output(self, asset_id: str) -> VideoOutputRecord: + with self._lock: + record = self._manifest.outputs.get(asset_id) + if ( + not isinstance(record, VideoOutputRecord) + or not self._resolve(record.stored_name, "outputs/videos").is_file() + ): + raise AssetNotFoundError("Unknown video output.") + return record.model_copy(deep=True) + + def list_video_outputs(self) -> list[VideoOutputRecord]: + with self._lock: + self._require_available() + return sorted( + ( + record.model_copy(deep=True) + for record in self._manifest.outputs.values() + if isinstance(record, VideoOutputRecord) + ), + key=lambda record: record.created_at, + reverse=True, + ) + + @contextmanager + def lease_video_output(self, asset_id: str) -> Iterator[tuple[VideoOutputRecord, Path]]: + with self._lock: + record = self.get_video_output(asset_id) + key = ("video-output", asset_id) + self._leases[key] = self._leases.get(key, 0) + 1 + path = self._resolve(record.stored_name, "outputs/videos") + try: + yield record, path + finally: + with self._lock: + self._leases[key] = max(0, self._leases.get(key, 1) - 1) + + def delete_video_output(self, asset_id: str) -> None: + with self._lock: + record = self.get_video_output(asset_id) + self._ensure_not_leased("video-output", asset_id) + self._delete_video_records([], [record], []) + + def delete_video_source(self, asset_id: str, *, cascade: bool) -> tuple[int, int]: + with self._lock: + source = self.get_video_source(asset_id) + jobs = [job for job in self._manifest.jobs.values() if job.source_id == asset_id] + if any(not job.state.terminal for job in jobs): + raise AssetConflictError("The video source has an active processing job.") + outputs = [ + record + for record in self._manifest.outputs.values() + if isinstance(record, VideoOutputRecord) and record.source_id == asset_id + ] + if (outputs or jobs) and not cascade: + raise AssetConflictError("The video source still has outputs or job history.") + self._ensure_not_leased("video-source", asset_id) + for output in outputs: + self._ensure_not_leased("video-output", output.id) + self._delete_video_records([source], outputs, jobs) + return len(outputs), len(jobs) + + def _delete_video_records( + self, + sources: list[VideoSourceRecord], + outputs: list[VideoOutputRecord], + jobs: list[VideoJobRecord], + ) -> None: + moved: list[tuple[Path, Path]] = [] + try: + media_records: list[VideoSourceRecord | VideoOutputRecord] = [ + *sources, + *outputs, + ] + for record in media_records: + prefix = ( + f"{'sources' if isinstance(record, VideoSourceRecord) else 'outputs'}/videos" + ) + original = self._resolve(record.stored_name, prefix) + if original.exists(): + quarantine = self.temporary_folder / f"delete-{secrets.token_hex(16)}" + os.replace(original, quarantine) + moved.append((original, quarantine)) + next_sources = dict(self._manifest.sources) + next_outputs = dict(self._manifest.outputs) + next_jobs = dict(self._manifest.jobs) + for source_record in sources: + next_sources.pop(source_record.id, None) + for output_record in outputs: + next_outputs.pop(output_record.id, None) + for job_record in jobs: + next_jobs.pop(job_record.id, None) + next_manifest = ManifestDocument( + sources=next_sources, + outputs=next_outputs, + jobs=next_jobs, + ) + self._writer.write(next_manifest) + except (OSError, ManifestWriteError) as exc: + for original, quarantine in reversed(moved): + if quarantine.exists(): + os.replace(quarantine, original) + if isinstance(exc, ManifestWriteError): + raise + raise ManifestWriteError("The video asset deletion could not be saved.") from exc + self._manifest = next_manifest + for _, quarantine in moved: + quarantine.unlink(missing_ok=True) + + def recover_video_jobs(self) -> list[VideoJobRecord]: + """Requeue interrupted work, bounded by the persisted attempt limit.""" + + recovered: list[VideoJobRecord] = [] + for job in self.list_video_jobs(): + if job.state.terminal: + continue + self._remove_video_job_temporary_files(job.id) + if job.cancellation_requested: + recovered.append( + self.update_video_job( + job.id, + state=VideoJobState.CANCELED, + stage="canceled", + ) + ) + continue + if job.attempt >= self.video_job_maximum_attempts: + recovered.append( + self.update_video_job( + job.id, + state=VideoJobState.FAILED, + stage="failed", + error_code="restart_attempts_exhausted", + error_message="The job exceeded its restart recovery limit.", + ) + ) + continue + if job.state != VideoJobState.QUEUED: + # Recovery is the sole controlled backward transition. + with self._lock: + reset = VideoJobRecord.model_validate( + job.model_copy( + update={ + "state": VideoJobState.QUEUED, + "stage": "queued", + "progress": 0, + "updated_at": _utc_now(), + "attempt": job.attempt + 1, + "recovered_after_restart": True, + } + ).model_dump() + ) + next_manifest = self._validated_manifest( + jobs={**self._manifest.jobs, job.id: reset} + ) + self._writer.write(next_manifest) + self._manifest = next_manifest + recovered.append(reset) + else: + recovered.append(job) + return recovered + + def _remove_video_job_temporary_files(self, job_id: str) -> None: + folder = self.video_job_temporary_folder / job_id + try: + if folder.is_dir(): + for path in folder.iterdir(): + if path.is_file(): + path.unlink() + folder.rmdir() + except OSError: + logger.exception("Could not remove interrupted video job temporary files") + + def _validated_manifest(self, **updates: object) -> ManifestDocument: + return ManifestDocument.model_validate( + self._manifest.model_copy(update=updates).model_dump() + ) + def status(self) -> dict[str, object]: with self._lock: - source_bytes = self._referenced_bytes(self._manifest.sources.values()) - output_bytes = self._referenced_bytes(self._manifest.outputs.values()) + image_sources = [ + record + for record in self._manifest.sources.values() + if isinstance(record, ImageSourceRecord) + ] + video_sources = [ + record + for record in self._manifest.sources.values() + if isinstance(record, VideoSourceRecord) + ] + image_outputs = [ + record + for record in self._manifest.outputs.values() + if isinstance(record, ImageOutputRecord) + ] + video_outputs = [ + record + for record in self._manifest.outputs.values() + if isinstance(record, VideoOutputRecord) + ] + image_source_bytes = self._referenced_bytes(image_sources) + video_source_bytes = self._referenced_bytes(video_sources) + image_output_bytes = self._referenced_bytes(image_outputs) + video_output_bytes = self._referenced_bytes(video_outputs) + source_bytes = image_source_bytes + video_source_bytes + output_bytes = image_output_bytes + video_output_bytes temporary_bytes = self._folder_bytes(self.temporary_folder) + temporary_job_bytes = self._folder_bytes(self.video_job_temporary_folder) orphan_sources, orphan_outputs = self._orphan_paths() try: free_bytes: int | None = shutil.disk_usage(self.data_root).free @@ -488,8 +1104,40 @@ def status(self) -> dict[str, object]: "outputCount": len(self._manifest.outputs), "sourceBytes": source_bytes, "outputBytes": output_bytes, + "imageSourceBytes": image_source_bytes, + "videoSourceBytes": video_source_bytes, + "imageOutputBytes": image_output_bytes, + "videoOutputBytes": video_output_bytes, "temporaryBytes": temporary_bytes, - "orphanFileCount": len(orphan_sources) + len(orphan_outputs), + "temporaryJobBytes": temporary_job_bytes, + "orphanFileCount": sum(map(len, orphan_sources.values())) + + sum(map(len, orphan_outputs.values())), + "imageSourceCount": sum( + isinstance(record, ImageSourceRecord) + for record in self._manifest.sources.values() + ), + "videoSourceCount": sum( + isinstance(record, VideoSourceRecord) + for record in self._manifest.sources.values() + ), + "imageOutputCount": sum( + isinstance(record, ImageOutputRecord) + for record in self._manifest.outputs.values() + ), + "videoOutputCount": sum( + isinstance(record, VideoOutputRecord) + for record in self._manifest.outputs.values() + ), + "videoJobCount": len(self._manifest.jobs), + "activeVideoJobCount": sum( + not record.state.terminal for record in self._manifest.jobs.values() + ), + "videoJobsByState": { + state.value: sum( + record.state == state for record in self._manifest.jobs.values() + ) + for state in VideoJobState + }, "missingRecordCount": ( self._last_report.missing_sources + self._last_report.missing_outputs ), @@ -517,16 +1165,28 @@ def cleanup(self, options: CleanupRequest) -> CleanupResult: with self._lock: self._require_available() now = _utc_now().timestamp() + active_job_folders = { + (self.video_job_temporary_folder / job.id).resolve() + for job in self._manifest.jobs.values() + if not job.state.terminal + } temporary = [ path - for path in self.temporary_folder.iterdir() - if path.is_file() and now - path.stat().st_mtime >= self.temporary_maximum_age + for path in self.temporary_folder.rglob("*") + if path.is_file() + and not any(root in path.resolve().parents for root in active_job_folders) + and now - path.stat().st_mtime >= self.temporary_maximum_age ] orphan_sources, orphan_outputs = self._orphan_paths() orphan_cutoff = options.orphan_minimum_age_hours * 3600 orphans = [ path - for path in (*orphan_sources, *orphan_outputs) + for path in ( + *orphan_sources["image"], + *orphan_sources["video"], + *orphan_outputs["image"], + *orphan_outputs["video"], + ) if now - path.stat().st_mtime >= orphan_cutoff ] selected_temporary = temporary if options.remove_temporary else [] @@ -548,31 +1208,50 @@ def _refresh_orphan_report(self) -> None: orphan_sources, orphan_outputs = self._orphan_paths() self._last_report = self._last_report.model_copy( update={ - "orphan_sources": len(orphan_sources), - "orphan_outputs": len(orphan_outputs), + "orphan_image_sources": len(orphan_sources["image"]), + "orphan_video_sources": len(orphan_sources["video"]), + "orphan_image_outputs": len(orphan_outputs["image"]), + "orphan_video_outputs": len(orphan_outputs["video"]), } ) - def _orphan_paths(self) -> tuple[list[Path], list[Path]]: + def _orphan_paths(self) -> tuple[dict[str, list[Path]], dict[str, list[Path]]]: referenced_sources = { - self._resolve(record.stored_name, "sources/images") - for record in self._manifest.sources.values() + kind: { + self._resolve(record.stored_name, f"sources/{kind}s") + for record in self._manifest.sources.values() + if record.kind == kind + } + for kind in ("image", "video") } referenced_outputs = { - self._resolve(record.stored_name, "outputs/images") - for record in self._manifest.outputs.values() + kind: { + self._resolve(record.stored_name, f"outputs/{kind}s") + for record in self._manifest.outputs.values() + if record.kind == kind + } + for kind in ("image", "video") } - source_orphans = [ - path - for path in self.source_folder.iterdir() - if path.is_file() and path.resolve() not in referenced_sources - ] - output_orphans = [ - path - for path in self.output_folder.iterdir() - if path.is_file() and path.resolve() not in referenced_outputs - ] - return source_orphans, output_orphans + source_folders = {"image": self.image_source_folder, "video": self.video_source_folder} + output_folders = {"image": self.image_output_folder, "video": self.video_output_folder} + return ( + { + kind: [ + path + for path in folder.iterdir() + if path.is_file() and path.resolve() not in referenced_sources[kind] + ] + for kind, folder in source_folders.items() + }, + { + kind: [ + path + for path in folder.iterdir() + if path.is_file() and path.resolve() not in referenced_outputs[kind] + ] + for kind, folder in output_folders.items() + }, + ) def _resolve(self, stored_name: str, expected_prefix: str) -> Path: if not stored_name.startswith(f"{expected_prefix}/"): @@ -584,12 +1263,21 @@ def _resolve(self, stored_name: str, expected_prefix: str) -> Path: raise ManagedPathError("The managed asset path is invalid.") from exc return candidate - def _referenced_bytes(self, records: Iterable[ImageSourceRecord | ImageOutputRecord]) -> int: + def _referenced_bytes( + self, + records: Iterable[ + ImageSourceRecord | VideoSourceRecord | ImageOutputRecord | VideoOutputRecord + ], + ) -> int: total = 0 for record in records: path = self._resolve( record.stored_name, - "sources/images" if isinstance(record, ImageSourceRecord) else "outputs/images", + ( + f"sources/{record.kind}s" + if isinstance(record, ImageSourceRecord | VideoSourceRecord) + else f"outputs/{record.kind}s" + ), ) if path.is_file(): total += path.stat().st_size @@ -597,8 +1285,12 @@ def _referenced_bytes(self, records: Iterable[ImageSourceRecord | ImageOutputRec @staticmethod def _folder_bytes(folder: Path) -> int: - return sum(path.stat().st_size for path in folder.iterdir() if path.is_file()) + return sum(path.stat().st_size for path in folder.rglob("*") if path.is_file()) @staticmethod def _new_id() -> str: return secrets.token_urlsafe(24) + + +# Compatibility import for applications built against the image-only repository. +ImageAssetRepository = MediaAssetRepository diff --git a/glitchcraft/version.py b/glitchcraft/version.py index 932e945..202dd9f 100644 --- a/glitchcraft/version.py +++ b/glitchcraft/version.py @@ -3,7 +3,8 @@ APP_ID = "glitchcraft" APP_NAME = "GlitchCraft" APP_DESCRIPTOR = "Local visual-effects workspace" -APP_VERSION = "0.1.0" -MANIFEST_SCHEMA_VERSION = 1 +APP_VERSION = "0.2.0" +MANIFEST_SCHEMA_VERSION = 2 RECIPE_SCHEMA_VERSION = 1 -STORAGE_SCHEMA_VERSION = 1 +STORAGE_SCHEMA_VERSION = 2 +VIDEO_JOB_SCHEMA_VERSION = 1 diff --git a/glitchcraft/web/routes.py b/glitchcraft/web/routes.py index 2579d77..d1408b4 100644 --- a/glitchcraft/web/routes.py +++ b/glitchcraft/web/routes.py @@ -29,7 +29,9 @@ from glitchcraft.contracts.legacy import FullVideoRequest, LegacyParameters, UploadMode from glitchcraft.effects.engine import apply_effect_stack from glitchcraft.effects.registry import EFFECT_REGISTRY -from glitchcraft.errors import GlitchCraftError +from glitchcraft.errors import ExternalToolError, GlitchCraftError, QueueCapacityError +from glitchcraft.jobs.contracts import VideoJobRequest, VideoPreviewRequest +from glitchcraft.jobs.manager import VideoJobManager from glitchcraft.media.ffmpeg import reencode_for_browser from glitchcraft.media.image_io import ( SUPPORTED_IMAGE_FORMATS, @@ -38,7 +40,8 @@ load_image_rgb, save_image_rgb, ) -from glitchcraft.media.video import create_video_preview, process_video +from glitchcraft.media.probe import probe_video +from glitchcraft.media.video import create_video_preview, create_video_preview_at, process_video from glitchcraft.service_contract import ( CAPABILITY_SLUGS, SUPPORTED_VIDEO_EXTENSIONS, @@ -52,6 +55,10 @@ CleanupRequest, ImageOutputRecord, ImageSourceRecord, + VideoJobRecord, + VideoJobState, + VideoOutputRecord, + VideoSourceRecord, ) from glitchcraft.storage.errors import ( AssetConflictError, @@ -59,7 +66,7 @@ ManifestWriteError, StorageUnavailableError, ) -from glitchcraft.storage.repository import ImageAssetRepository +from glitchcraft.storage.repository import MediaAssetRepository from glitchcraft.tasks import TaskStore from glitchcraft.version import ( APP_DESCRIPTOR, @@ -68,7 +75,9 @@ APP_VERSION, RECIPE_SCHEMA_VERSION, STORAGE_SCHEMA_VERSION, + VIDEO_JOB_SCHEMA_VERSION, ) +from glitchcraft.web.streaming import stream_path logger = logging.getLogger(__name__) bp = Blueprint("glitchcraft", __name__) @@ -145,8 +154,12 @@ def _task_store() -> TaskStore: return cast(TaskStore, current_app.extensions["task_store"]) -def _repository() -> ImageAssetRepository: - return cast(ImageAssetRepository, current_app.extensions["image_repository"]) +def _repository() -> MediaAssetRepository: + return cast(MediaAssetRepository, current_app.extensions["media_repository"]) + + +def _video_manager() -> VideoJobManager: + return cast(VideoJobManager, current_app.extensions["video_job_manager"]) def _json_not_found(asset: str) -> tuple[Response, int]: @@ -177,7 +190,7 @@ def _storage_error(exc: Exception) -> tuple[Response, int]: if isinstance(exc, AssetConflictError): return jsonify(status="error", message=str(exc)), 409 logger.error("Managed storage request failed: %s", type(exc).__name__) - return jsonify(status="error", message="Persistent image storage is unavailable."), 503 + return jsonify(status="error", message="Persistent media storage is unavailable."), 503 def _reject_query_parameters(allowed: set[str] | None = None) -> None: @@ -218,6 +231,98 @@ def _output_json(output: ImageOutputRecord) -> dict[str, Any]: } +def _video_source_json(source: VideoSourceRecord) -> dict[str, Any]: + jobs = [job for job in _repository().list_video_jobs() if job.source_id == source.id] + outputs = [ + output for output in _repository().list_video_outputs() if output.source_id == source.id + ] + return { + "sourceId": source.id, + "originalName": source.original_name, + "container": source.container, + "mimeType": source.mime_type, + "width": source.width, + "height": source.height, + "durationSeconds": source.duration_seconds, + "frameRate": source.frame_rate, + "frameCount": source.frame_count, + "videoCodec": source.video_codec, + "pixelFormat": source.pixel_format, + "hasAudio": source.has_audio, + "audioCodec": source.audio_codec, + "fileSize": source.file_size, + "seed": source.seed, + "createdAt": source.created_at.isoformat().replace("+00:00", "Z"), + "originalUrl": url_for("glitchcraft.serve_video_source", source_id=source.id), + "downloadUrl": url_for("glitchcraft.download_video_source", source_id=source.id), + "previewUrl": url_for("glitchcraft.preview_video_source", source_id=source.id), + "jobsUrl": url_for("glitchcraft.create_video_job", source_id=source.id), + "outputCount": len(outputs), + "jobCount": len(jobs), + } + + +def _video_output_json(output: VideoOutputRecord) -> dict[str, Any]: + return { + "outputId": output.id, + "sourceId": output.source_id, + "jobId": output.job_id, + "fileName": output.file_name, + "mimeType": output.mime_type, + "width": output.width, + "height": output.height, + "durationSeconds": output.duration_seconds, + "frameRate": output.frame_rate, + "videoCodec": output.video_codec, + "pixelFormat": output.pixel_format, + "audioMode": output.audio_mode, + "hasAudio": output.has_audio, + "audioCodec": output.audio_codec, + "fileSize": output.file_size, + "createdAt": output.created_at.isoformat().replace("+00:00", "Z"), + "streamUrl": url_for("glitchcraft.serve_video_output", output_id=output.id), + "downloadUrl": url_for("glitchcraft.download_video_output", output_id=output.id), + "recipe": output.recipe.model_dump(mode="json", by_alias=True), + } + + +def _video_job_json(job: VideoJobRecord) -> dict[str, Any]: + value: dict[str, Any] = { + "jobId": job.id, + "sourceId": job.source_id, + "state": job.state, + "progress": job.progress, + "stage": job.stage, + "attempt": job.attempt, + "audioMode": job.audio_mode, + "seed": job.recipe.seed, + "recipe": job.recipe.model_dump(mode="json", by_alias=True), + "recoveredAfterRestart": job.recovered_after_restart, + "cancellationRequested": job.cancellation_requested, + "createdAt": job.created_at.isoformat().replace("+00:00", "Z"), + "updatedAt": job.updated_at.isoformat().replace("+00:00", "Z"), + "startedAt": ( + job.started_at.isoformat().replace("+00:00", "Z") if job.started_at else None + ), + "completedAt": ( + job.completed_at.isoformat().replace("+00:00", "Z") if job.completed_at else None + ), + "statusUrl": url_for("glitchcraft.get_video_job", job_id=job.id), + "cancelUrl": url_for("glitchcraft.cancel_video_job", job_id=job.id), + } + if job.output_id: + value["outputId"] = job.output_id + try: + _repository().get_video_output(job.output_id) + value["outputAvailable"] = True + value["outputUrl"] = url_for("glitchcraft.serve_video_output", output_id=job.output_id) + except AssetNotFoundError: + value["outputAvailable"] = False + if job.error_code: + value["error"] = {"code": job.error_code, "message": job.error_message} + return value + + def _video_worker( store: TaskStore, task_id: str, @@ -276,6 +381,7 @@ def metadata() -> Response | tuple[Response, int]: manifestSchemaVersion=1, recipeSchemaVersion=RECIPE_SCHEMA_VERSION, storageSchemaVersion=STORAGE_SCHEMA_VERSION, + videoJobSchemaVersion=VIDEO_JOB_SCHEMA_VERSION, runtime={ "webAddress": address, "apiAddress": address, @@ -286,9 +392,24 @@ def metadata() -> Response | tuple[Response, int]: supportedVideoExtensions=list(SUPPORTED_VIDEO_EXTENSIONS), effectTypes=[effect_type.value for effect_type in EFFECT_REGISTRY], library={ - "sources": len(repository.list_sources()) if repository.available else 0, - "outputs": len(repository.list_outputs()) if repository.available else 0, + "imageSources": len(repository.list_sources()) if repository.available else 0, + "imageOutputs": len(repository.list_outputs()) if repository.available else 0, + "videoSources": (len(repository.list_video_sources()) if repository.available else 0), + "videoJobs": len(repository.list_video_jobs()) if repository.available else 0, + "videoOutputs": (len(repository.list_video_outputs()) if repository.available else 0), + }, + videoJobs={ + **_video_manager().status(), + "states": [state.value for state in VideoJobState], + }, + standardVideoOutput={ + "container": "mp4", + "mimeType": "video/mp4", + "videoCodec": "h264", + "pixelFormat": "yuv420p", + "audioCodec": "aac", }, + audioModes=["preserve", "remove"], links={ "manifest": url_for("glitchcraft.app_manifest"), "health": url_for("glitchcraft.health"), @@ -318,7 +439,11 @@ def readiness() -> tuple[Response, int]: "storage": {"state": "unavailable"}, "imageProcessing": {"state": "ok"}, "videoProcessing": { - "state": "available" if ffmpeg_available() else "unavailable" + "state": ( + "available" + if ffmpeg_available() and ffprobe_available() + else "unavailable" + ) }, }, ), @@ -327,14 +452,24 @@ def readiness() -> tuple[Response, int]: writable = repository.is_writable() ffmpeg_ready = ffmpeg_available() + ffprobe_ready = ffprobe_available() + manager_status = _video_manager().status() + manager_ready = manager_status["workers"] > 0 or bool(current_app.config.get("TESTING")) + queue_available = manager_status["queued"] < manager_status["capacity"] report = repository.last_report - degraded_storage = report.state in {"changed", "recovered"} or ( + degraded_storage = report.state in {"changed", "recovered", "migrated"} or ( report.orphan_sources + report.orphan_outputs > 0 ) if not writable: state = "not_ready" status = 503 - elif degraded_storage or not ffmpeg_ready: + elif ( + degraded_storage + or not ffmpeg_ready + or not ffprobe_ready + or not manager_ready + or not queue_available + ): state = "degraded" status = 200 else: @@ -355,12 +490,19 @@ def readiness() -> tuple[Response, int]: }, "imageProcessing": {"state": "ok"}, "videoProcessing": { - "state": "available" if ffmpeg_ready else "unavailable", - "message": None if ffmpeg_ready else "FFmpeg is unavailable.", + "state": "available" if ffmpeg_ready and ffprobe_ready else "unavailable", + "message": ( + None if ffmpeg_ready and ffprobe_ready else "FFmpeg and FFprobe are required." + ), }, "ffprobe": { "state": "available" if ffprobe_available() else "unavailable", - "required": False, + "required": True, + }, + "videoJobs": { + **manager_status, + "state": "available" if manager_ready else "unavailable", + "queueAvailable": queue_available, }, "reconciliation": { "state": report.state, @@ -678,6 +820,327 @@ def delete_image_source(source_id: str) -> Response | tuple[Response, int]: return _storage_error(exc) +@bp.post("/api/video-sources") +def create_video_source() -> tuple[Response, int]: + upload = request.files.get("video") + if upload is None or not upload.filename: + return jsonify(status="error", message="A video file is required."), 400 + original_name = secure_filename(upload.filename) + extension = _extension(original_name) + mime_types = { + "mp4": "video/mp4", + "mov": "video/quicktime", + "mkv": "video/x-matroska", + "avi": "video/x-msvideo", + } + if not original_name or extension not in mime_types: + return jsonify(status="error", message="Unsupported video file type."), 400 + staged_path: Path | None = None + try: + staged_path = _repository().new_temporary_path(f".{extension}") + upload.save(staged_path) + if staged_path.stat().st_size <= 0: + raise ValueError("The uploaded video is empty.") + metadata = probe_video(staged_path) + compatible_containers = {"mp4", "mov"} if extension in {"mp4", "mov"} else {extension} + if metadata.container not in compatible_containers: + raise ValueError("The declared extension does not match the probed container.") + if metadata.duration_seconds > float(current_app.config["MAX_VIDEO_DURATION_SECONDS"]): + raise ValueError("The video duration exceeds the configured limit.") + if metadata.width * metadata.height > int(current_app.config["MAX_VIDEO_PIXELS"]): + raise ValueError("The video dimensions exceed the configured limit.") + raw_seed = request.form.get("seed") + seed = secrets.randbelow(MAX_SEED + 1) if raw_seed is None else int(raw_seed) + if seed < 0 or seed > MAX_SEED: + raise ValueError("The root seed is outside the supported range.") + source = _repository().create_video_source( + staged_path=staged_path, + extension=extension, + original_name=original_name, + container=extension, + mime_type=mime_types[extension], + width=metadata.width, + height=metadata.height, + duration_seconds=metadata.duration_seconds, + frame_rate=metadata.frame_rate, + frame_count=metadata.frame_count, + video_codec=metadata.video_codec, + has_audio=metadata.has_audio, + audio_codec=metadata.audio_codec, + file_size=staged_path.stat().st_size, + seed=seed, + pixel_format=metadata.pixel_format, + ) + staged_path = None + return jsonify(_video_source_json(source)), 201 + except (ValidationError, ValueError) as exc: + return _validation_error(exc) + except ExternalToolError: + logger.exception("Video admission tool is unavailable") + return jsonify(status="error", message="Video admission is unavailable."), 503 + except GlitchCraftError: + logger.exception("Video source inspection failed") + return jsonify(status="error", message="The video could not be inspected."), 422 + except (StorageUnavailableError, ManifestWriteError) as exc: + return _storage_error(exc) + finally: + if staged_path is not None: + staged_path.unlink(missing_ok=True) + + +@bp.get("/api/video-sources") +def list_video_sources() -> Response | tuple[Response, int]: + try: + _reject_query_parameters() + return jsonify( + sources=[_video_source_json(item) for item in _repository().list_video_sources()] + ) + except (ValueError, StorageUnavailableError) as exc: + if isinstance(exc, StorageUnavailableError): + return _storage_error(exc) + return _validation_error(exc) + + +@bp.get("/api/video-sources/") +def get_video_source(source_id: str) -> Response | tuple[Response, int]: + try: + _reject_query_parameters() + return jsonify(_video_source_json(_repository().get_video_source(source_id))) + except ValueError as exc: + return _validation_error(exc) + except (AssetNotFoundError, StorageUnavailableError) as exc: + return _storage_error(exc) + + +@bp.route("/api/video-sources//original", methods=["GET", "HEAD"]) +def serve_video_source(source_id: str) -> Response | tuple[Response, int]: + try: + _reject_query_parameters() + except ValueError as exc: + return _validation_error(exc) + lease = _repository().lease_video_source(source_id) + try: + source, path = lease.__enter__() + response = stream_path(path, mime_type=source.mime_type, download_name=source.original_name) + response.call_on_close(lambda: lease.__exit__(None, None, None)) + return response + except (AssetNotFoundError, StorageUnavailableError) as exc: + lease.__exit__(type(exc), exc, exc.__traceback__) + return _storage_error(exc) + + +@bp.route("/api/video-sources//download", methods=["GET", "HEAD"]) +def download_video_source(source_id: str) -> Response | tuple[Response, int]: + response = serve_video_source(source_id) + if isinstance(response, tuple): + return response + source = _repository().get_video_source(source_id) + response.headers["Content-Disposition"] = f'attachment; filename="{source.original_name}"' + return response + + +@bp.post("/api/video-sources//preview") +def preview_video_source(source_id: str) -> Response | tuple[Response, int]: + preview_path: Path | None = None + try: + payload = VideoPreviewRequest.model_validate(request.get_json(silent=True)) + source = _repository().get_video_source(source_id) + if payload.timestamp_seconds >= source.duration_seconds: + raise ValueError("The preview timestamp must be inside the video.") + preview_path = _repository().new_temporary_path(".png") + with _repository().lease_video_source(source_id) as (_, path): + frame_index = create_video_preview_at( + path, + preview_path, + payload.recipe, + timestamp_seconds=payload.timestamp_seconds, + ) + response = Response(preview_path.read_bytes(), mimetype="image/png") + response.headers["X-GlitchCraft-Frame-Index"] = str(frame_index) + response.headers["X-GlitchCraft-Timestamp-Seconds"] = str(payload.timestamp_seconds) + return _no_store(response) + except (ValidationError, ValueError) as exc: + return _validation_error(exc) + except (AssetNotFoundError, StorageUnavailableError) as exc: + return _storage_error(exc) + except GlitchCraftError: + return jsonify(status="error", message="The preview frame could not be created."), 422 + finally: + if preview_path is not None: + preview_path.unlink(missing_ok=True) + + +@bp.post("/api/video-sources//jobs") +def create_video_job(source_id: str) -> tuple[Response, int]: + try: + if not ffmpeg_available() or not ffprobe_available(): + raise ExternalToolError("Video processing tools are unavailable.") + payload = VideoJobRequest.model_validate(request.get_json(silent=True)) + job = _repository().create_video_job( + source_id=source_id, + recipe=payload.recipe, + audio_mode=payload.audio_mode.value, + ) + try: + _video_manager().enqueue(job.id) + except QueueCapacityError: + _repository().update_video_job( + job.id, + state=VideoJobState.FAILED, + stage="failed", + error_code="queue_full", + error_message="The video processing queue is full.", + ) + raise + return jsonify(_video_job_json(job)), 202 + except (ValidationError, ValueError) as exc: + return _validation_error(exc) + except QueueCapacityError as exc: + return jsonify(status="error", message=str(exc)), 429 + except ExternalToolError as exc: + return jsonify(status="error", message=str(exc)), 503 + except (AssetNotFoundError, StorageUnavailableError, ManifestWriteError) as exc: + return _storage_error(exc) + + +@bp.get("/api/video-jobs/") +def get_video_job(job_id: str) -> Response | tuple[Response, int]: + try: + return jsonify(_video_job_json(_repository().get_video_job(job_id))) + except (AssetNotFoundError, StorageUnavailableError) as exc: + return _storage_error(exc) + + +@bp.get("/api/video-jobs") +def list_video_jobs() -> Response | tuple[Response, int]: + try: + _reject_query_parameters({"state"}) + raw_state = request.args.get("state") + state = VideoJobState(raw_state) if raw_state is not None else None + jobs = _repository().list_video_jobs() + if state is not None: + jobs = [job for job in jobs if job.state == state] + return jsonify(jobs=[_video_job_json(job) for job in jobs]) + except ValueError as exc: + return _validation_error(exc) + except StorageUnavailableError as exc: + return _storage_error(exc) + + +@bp.post("/api/video-jobs//cancel") +def cancel_video_job(job_id: str) -> Response | tuple[Response, int]: + try: + current = _repository().get_video_job(job_id) + if current.state.terminal: + return jsonify(status="error", message="The video job is already terminal."), 409 + _video_manager().cancel(job_id) + return jsonify(_video_job_json(_repository().get_video_job(job_id))), 202 + except (AssetNotFoundError, StorageUnavailableError, ManifestWriteError) as exc: + return _storage_error(exc) + + +@bp.delete("/api/video-jobs/") +def delete_video_job(job_id: str) -> Response | tuple[Response, int]: + try: + _repository().delete_video_job(job_id) + return jsonify(status="deleted", jobId=job_id) + except ( + AssetNotFoundError, + AssetConflictError, + StorageUnavailableError, + ManifestWriteError, + ) as exc: + return _storage_error(exc) + + +@bp.get("/api/video-outputs//metadata") +def get_video_output_metadata(output_id: str) -> Response | tuple[Response, int]: + try: + return jsonify(_video_output_json(_repository().get_video_output(output_id))) + except (AssetNotFoundError, StorageUnavailableError) as exc: + return _storage_error(exc) + + +@bp.get("/api/video-outputs") +def list_video_outputs() -> Response | tuple[Response, int]: + try: + _reject_query_parameters() + return jsonify( + outputs=[_video_output_json(item) for item in _repository().list_video_outputs()] + ) + except ValueError as exc: + return _validation_error(exc) + except StorageUnavailableError as exc: + return _storage_error(exc) + + +@bp.delete("/api/video-outputs/") +def delete_video_output(output_id: str) -> Response | tuple[Response, int]: + try: + _repository().delete_video_output(output_id) + return jsonify(status="deleted", outputId=output_id) + except ( + AssetNotFoundError, + AssetConflictError, + StorageUnavailableError, + ManifestWriteError, + ) as exc: + return _storage_error(exc) + + +@bp.delete("/api/video-sources/") +def delete_video_source(source_id: str) -> Response | tuple[Response, int]: + try: + _reject_query_parameters({"cascade"}) + cascade = request.args.get("cascade", "false") + if cascade not in {"true", "false"}: + raise ValueError("cascade must be true or false") + outputs, jobs = _repository().delete_video_source(source_id, cascade=cascade == "true") + return jsonify( + status="deleted", + sourceId=source_id, + deletedOutputs=outputs, + deletedJobs=jobs, + ) + except ValueError as exc: + return _validation_error(exc) + except ( + AssetNotFoundError, + AssetConflictError, + StorageUnavailableError, + ManifestWriteError, + ) as exc: + return _storage_error(exc) + + +@bp.route("/api/video-outputs/", methods=["GET", "HEAD"]) +def serve_video_output(output_id: str) -> Response | tuple[Response, int]: + try: + _reject_query_parameters() + except ValueError as exc: + return _validation_error(exc) + lease = _repository().lease_video_output(output_id) + try: + output, path = lease.__enter__() + response = stream_path(path, mime_type=output.mime_type, download_name=output.file_name) + response.call_on_close(lambda: lease.__exit__(None, None, None)) + return response + except (AssetNotFoundError, StorageUnavailableError) as exc: + lease.__exit__(type(exc), exc, exc.__traceback__) + return _storage_error(exc) + + +@bp.route("/api/video-outputs//download", methods=["GET", "HEAD"]) +def download_video_output(output_id: str) -> Response | tuple[Response, int]: + response = serve_video_output(output_id) + if isinstance(response, tuple): + return response + output = _repository().get_video_output(output_id) + response.headers["Content-Disposition"] = f'attachment; filename="{output.file_name}"' + return response + + +# Deprecated compatibility surface. The active browser workflow uses opaque-ID APIs. @bp.post("/upload_preview") def upload_preview() -> tuple[Response, int]: file = request.files.get("input_file") diff --git a/glitchcraft/web/streaming.py b/glitchcraft/web/streaming.py new file mode 100644 index 0000000..2ba1d56 --- /dev/null +++ b/glitchcraft/web/streaming.py @@ -0,0 +1,62 @@ +"""Bounded single-range HTTP streaming helpers.""" + +from __future__ import annotations + +from collections.abc import Iterator +from pathlib import Path + +from flask import Response, request + + +def _parse_range(value: str, size: int) -> tuple[int, int]: + if not value.startswith("bytes=") or "," in value: + raise ValueError("Only one byte range is supported.") + start_text, separator, end_text = value[6:].partition("-") + if not separator or (not start_text and not end_text): + raise ValueError("Invalid byte range.") + if not start_text: + length = int(end_text) + if length <= 0: + raise ValueError("Invalid suffix range.") + start = max(0, size - length) + return start, size - 1 + start = int(start_text) + end = int(end_text) if end_text else size - 1 + if start < 0 or start >= size or end < start: + raise ValueError("Unsatisfiable byte range.") + return start, min(end, size - 1) + + +def stream_path(path: Path, *, mime_type: str, download_name: str) -> Response: + size = path.stat().st_size + range_value = request.headers.get("Range") + start, end, status = 0, size - 1, 200 + if range_value: + try: + start, end = _parse_range(range_value, size) + status = 206 + except (ValueError, TypeError): + response = Response(status=416) + response.headers["Content-Range"] = f"bytes */{size}" + response.headers["Accept-Ranges"] = "bytes" + return response + length = end - start + 1 + + def chunks() -> Iterator[bytes]: + with path.open("rb") as stream: + stream.seek(start) + remaining = length + while remaining: + chunk = stream.read(min(64 * 1024, remaining)) + if not chunk: + break + remaining -= len(chunk) + yield chunk + + response = Response(None if request.method == "HEAD" else chunks(), status, mimetype=mime_type) + response.headers["Accept-Ranges"] = "bytes" + response.headers["Content-Length"] = str(length) + response.headers["Content-Disposition"] = f'inline; filename="{download_name}"' + if status == 206: + response.headers["Content-Range"] = f"bytes {start}-{end}/{size}" + return response diff --git a/package.json b/package.json index 649fd78..010becf 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "glitchcraft-browser-tests", "private": true, "scripts": { + "test": "playwright test", "test:browser": "playwright test" }, "devDependencies": { diff --git a/pyproject.toml b/pyproject.toml index 9b30ae1..09aa43d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "glitchcraft" -version = "0.1.0" +version = "0.2.0" description = "A local workspace for reproducible glitch and signal effects." readme = "README.md" requires-python = ">=3.11" diff --git a/static/app-manifest.json b/static/app-manifest.json index 7922bd0..d737339 100644 --- a/static/app-manifest.json +++ b/static/app-manifest.json @@ -6,7 +6,13 @@ "ordered-effect-recipes", "inline-image-preview", "image-export", - "persistent-image-library" + "persistent-image-library", + "persistent-video-library", + "bounded-video-jobs", + "video-job-cancellation", + "timestamp-video-preview", + "audio-preserving-video-export", + "http-range-video-streaming" ], "defaults": { "apiAddress": "http://127.0.0.1:5000", @@ -22,5 +28,5 @@ "id": "glitchcraft", "name": "GlitchCraft", "schemaVersion": 1, - "version": "0.1.0" + "version": "0.2.0" } diff --git a/static/app.js b/static/app.js index 1d8d718..a86688c 100644 --- a/static/app.js +++ b/static/app.js @@ -30,7 +30,10 @@ debounceTimer: null, exporting: false, error: null, - videoPreview: null, + videoSource: null, + videoJobId: null, + videoPollTimer: null, + videoPreviewUrl: null, }; // Kept inspectable while this transitional interface remains in one script. @@ -188,11 +191,12 @@ } function schedulePreview() { - if (!imageState.sourceId || currentMode() !== "image") { - return; - } window.clearTimeout(imageState.debounceTimer); - imageState.debounceTimer = window.setTimeout(requestPreview, 175); + if (currentMode() === "image" && imageState.sourceId) { + imageState.debounceTimer = window.setTimeout(requestPreview, 175); + } else if (currentMode() === "video" && imageState.videoSource) { + imageState.debounceTimer = window.setTimeout(requestVideoPreview, 175); + } } async function uploadImageSource() { @@ -326,95 +330,143 @@ `${form.elements.pixel_size.value} px`; } - function submitVideoPreview() { - if (!window.jQuery) { - window.alert("Video controls require the remaining legacy jQuery dependency."); + async function submitVideoPreview() { + const file = fileInput.files[0]; + if (!file) { + setNotice("Choose a video before continuing.", "error"); 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."); - }, - }); + submitButton.disabled = true; + uploadStatus.textContent = "Inspecting and storing video…"; + const body = new FormData(); + body.append("video", file); + try { + const response = await fetch("/api/video-sources", {method: "POST", body}); + if (!response.ok) { + throw new Error(await errorMessage(response, "The video could not be uploaded.")); + } + const source = await response.json(); + imageState.videoSource = source; + imageState.seed = source.seed; + document.querySelector("#video-source-metadata").textContent = + `${source.originalName} · ${source.width} × ${source.height} · ` + + `${source.durationSeconds.toFixed(2)}s · ${source.videoCodec}` + + (source.hasAudio ? ` · audio: ${source.audioCodec}` : " · no audio"); + const timeInput = document.querySelector("#video-preview-time"); + timeInput.max = String(Math.max(0, source.durationSeconds - 0.001)); + document.querySelector("#preview-section").hidden = false; + await requestVideoPreview(); + setNotice(""); + } catch (error) { + setNotice(error.message, "error"); + } finally { + submitButton.disabled = false; + uploadStatus.textContent = ""; + } } - function processFullVideo() { - const data = imageState.videoPreview; - if (!data || !window.jQuery) { + async function requestVideoPreview() { + const source = imageState.videoSource; + if (!source) { 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."); - }, - }); + try { + const response = await fetch( + `/api/video-sources/${encodeURIComponent(source.sourceId)}/preview`, + { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + recipe: buildRecipe(), + timestampSeconds: Number(document.querySelector("#video-preview-time").value), + }), + }, + ); + if (!response.ok) { + throw new Error(await errorMessage(response, "Video preview failed.")); + } + const blob = await response.blob(); + if (imageState.videoPreviewUrl) { + URL.revokeObjectURL(imageState.videoPreviewUrl); + } + imageState.videoPreviewUrl = URL.createObjectURL(blob); + document.querySelector("#preview-image").src = imageState.videoPreviewUrl; + } catch (error) { + setNotice(error.message, "error"); + } } - 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); + async function processFullVideo() { + if (!imageState.videoSource) { + return; + } + const processButton = document.querySelector("#process-full"); + const cancelButton = document.querySelector("#cancel-processing"); + processButton.disabled = true; + cancelButton.disabled = false; + try { + const response = await fetch( + `/api/video-sources/${encodeURIComponent(imageState.videoSource.sourceId)}/jobs`, + { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + recipe: buildRecipe(), + audioMode: document.querySelector("#audio-mode").value, + }), + }, + ); + if (!response.ok) { + throw new Error(await errorMessage(response, "Video processing could not start.")); + } + const job = await response.json(); + imageState.videoJobId = job.jobId; + progressPanel.hidden = false; + progressBar.value = 0; + pollVideoProgress(); + } catch (error) { + processButton.disabled = false; + cancelButton.disabled = true; + setNotice(error.message, "error"); + } + } + + async function pollVideoProgress() { + if (!imageState.videoJobId) { + return; + } + try { + const response = await fetch( + `/api/video-jobs/${encodeURIComponent(imageState.videoJobId)}`, + ); + if (!response.ok) { + throw new Error(await errorMessage(response, "Job status is unavailable.")); + } + const job = await response.json(); + progressBar.value = job.progress; + document.querySelector("#video-progress-stage").textContent = + `${job.stage} · ${job.progress}%`; + if (job.state === "completed") { + progressPanel.hidden = true; + document.querySelector("#process-full").disabled = false; + document.querySelector("#cancel-processing").disabled = true; + document.querySelector("#processed-video").src = job.outputUrl; + document.querySelector("#download-link").href = `${job.outputUrl}/download`; + document.querySelector("#video-result").hidden = false; + return; + } + if (job.state === "failed" || job.state === "canceled") { + progressPanel.hidden = true; + document.querySelector("#process-full").disabled = false; + document.querySelector("#cancel-processing").disabled = true; + setNotice(job.error?.message || `Video job ${job.state}.`, "error"); + return; + } + imageState.videoPollTimer = window.setTimeout(pollVideoProgress, 750); + } catch (error) { + progressPanel.hidden = true; + setNotice(error.message, "error"); + } } form.addEventListener("submit", (event) => { @@ -454,9 +506,18 @@ exportButton.addEventListener("click", exportImage); newImageButton.addEventListener("click", resetImageWorkspace); document.querySelector("#process-full").addEventListener("click", processFullVideo); + document.querySelector("#video-preview-time").addEventListener("input", schedulePreview); + document.querySelector("#cancel-processing").addEventListener("click", async () => { + if (imageState.videoJobId) { + await fetch(`/api/video-jobs/${encodeURIComponent(imageState.videoJobId)}/cancel`, { + method: "POST", + }); + pollVideoProgress(); + } + }); document.querySelector("#cancel-preview").addEventListener("click", () => { document.querySelector("#preview-section").hidden = true; - imageState.videoPreview = null; + imageState.videoSource = null; fileInput.value = ""; fileInput.focus(); }); diff --git a/templates/index.html b/templates/index.html index b51b4d8..e27e0da 100644 --- a/templates/index.html +++ b/templates/index.html @@ -5,7 +5,6 @@ GlitchCraft — Local visual effects - @@ -220,13 +219,23 @@

Processed

Video preview

-

Review the first processed frame

+

Review a processed frame

+ +
+
+ +
Processed preview frame from the uploaded video
+ +
@@ -246,7 +255,8 @@

Processed video

diff --git a/tests/browser/image-workflow.spec.js b/tests/browser/image-workflow.spec.js index c3f6097..6353d4b 100644 --- a/tests/browser/image-workflow.spec.js +++ b/tests/browser/image-workflow.spec.js @@ -143,3 +143,165 @@ test("invalid image errors stay inline", async ({page}) => { await expect(error).toContainText("could not be decoded"); await expect(page.locator("#image-workspace")).toBeHidden(); }); + +test("persistent video UI uploads once, previews a timestamp, and polls its job", async ({ + page, +}) => { + let previewTimestamp = null; + let polls = 0; + await page.route("**/api/video-sources", async (route) => { + await route.fulfill({ + status: 201, + contentType: "application/json", + body: JSON.stringify({ + sourceId: "video-source-1234", + originalName: "signal.mp4", + width: 640, + height: 360, + durationSeconds: 4, + videoCodec: "h264", + hasAudio: true, + audioCodec: "aac", + seed: 17, + }), + }); + }); + await page.route("**/api/video-sources/*/preview", async (route) => { + previewTimestamp = route.request().postDataJSON().timestampSeconds; + await route.fulfill({status: 200, contentType: "image/png", body: png}); + }); + await page.route("**/api/video-sources/*/jobs", async (route) => { + const payload = route.request().postDataJSON(); + expect(payload.audioMode).toBe("preserve"); + expect(payload.recipe.seed).toBe(17); + await route.fulfill({ + status: 202, + contentType: "application/json", + body: JSON.stringify({jobId: "video-job-1234"}), + }); + }); + await page.route("**/api/video-jobs/video-job-1234", async (route) => { + polls += 1; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + polls === 1 + ? {state: "processing", stage: "processing", progress: 40} + : { + state: "completed", + stage: "completed", + progress: 100, + outputUrl: "/api/video-outputs/video-output-1234", + }, + ), + }); + }); + await page.route("**/api/video-outputs/video-output-1234", (route) => + route.fulfill({status: 200, contentType: "video/mp4", body: Buffer.from("video")}), + ); + + await page.getByLabel("Video").check(); + await page.setInputFiles("#input_file", { + name: "signal.mp4", + mimeType: "video/mp4", + buffer: Buffer.from("synthetic video"), + }); + await page.getByRole("button", {name: "Create video preview"}).click(); + await expect(page.locator("#preview-section")).toBeVisible(); + await expect(page.locator("#preview-image")).toHaveAttribute("src", /^blob:/); + await expect(page.locator("#video-source-metadata")).toContainText("640 × 360"); + + await page.locator("#video-preview-time").fill("1.5"); + await expect.poll(() => previewTimestamp).toBe(1.5); + await page.getByRole("button", {name: "Process full video"}).click(); + await expect(page.locator("#progress-indicator")).toBeVisible(); + await expect(page.locator("#video-result")).toBeVisible(); + await expect(page.locator("#processed-video")).toHaveAttribute( + "src", + "/api/video-outputs/video-output-1234", + ); + await expect(page.getByRole("link", {name: "Download video"})).toHaveAttribute( + "href", + "/api/video-outputs/video-output-1234/download", + ); +}); + +test("persistent video processing can be canceled accessibly", async ({page}) => { + let canceled = false; + await page.route("**/api/video-sources", (route) => + route.fulfill({ + status: 201, + contentType: "application/json", + body: JSON.stringify({ + sourceId: "cancel-source", + originalName: "cancel.mp4", + width: 320, + height: 180, + durationSeconds: 2, + videoCodec: "h264", + hasAudio: false, + audioCodec: null, + seed: 23, + }), + }), + ); + await page.route("**/api/video-sources/*/preview", (route) => + route.fulfill({status: 200, contentType: "image/png", body: png}), + ); + await page.route("**/api/video-sources/*/jobs", (route) => + route.fulfill({ + status: 202, + contentType: "application/json", + body: JSON.stringify({jobId: "cancel-job"}), + }), + ); + await page.route("**/api/video-jobs/cancel-job/cancel", async (route) => { + canceled = true; + await route.fulfill({ + status: 202, + contentType: "application/json", + body: JSON.stringify({state: "canceled", stage: "canceled", progress: 30}), + }); + }); + await page.route("**/api/video-jobs/cancel-job", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + state: canceled ? "canceled" : "processing", + stage: canceled ? "canceled" : "processing", + progress: 30, + }), + }), + ); + + await page.getByLabel("Video").check(); + await page.setInputFiles("#input_file", { + name: "cancel.mp4", + mimeType: "video/mp4", + buffer: Buffer.from("synthetic video"), + }); + await page.getByRole("button", {name: "Create video preview"}).click(); + await page.getByRole("button", {name: "Process full video"}).click(); + const cancel = page.getByRole("button", {name: "Cancel processing"}); + await expect(cancel).toBeVisible(); + await cancel.focus(); + await page.keyboard.press("Enter"); + await expect.poll(() => canceled).toBe(true); + await expect(page.locator("#progress-indicator")).toBeHidden(); + await expect(page.locator("#image-notice")).toContainText("Video job canceled"); + + await page.setViewportSize({width: 320, height: 800}); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth > document.documentElement.clientWidth, + ), + ).toBe(false); + const results = await new AxeBuilder({page}).analyze(); + expect( + results.violations.filter((violation) => + ["serious", "critical"].includes(violation.impact), + ), + ).toEqual([]); +}); diff --git a/tests/test_routes.py b/tests/test_routes.py index d847cc4..fdfe6bd 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -133,6 +133,28 @@ def test_full_processing_rejects_bad_json(client, payload) -> None: assert response.json["status"] == "error" +def test_full_processing_rejects_invalid_output_extension(client, app) -> None: + Path(app.config["UPLOAD_FOLDER"], "source.mp4").write_bytes(b"video") + payload = valid_video_json() | {"output_file": "result.txt"} + response = client.post("/process_video_async", json=payload) + assert response.status_code == 400 + assert response.json["status"] == "error" + + +def test_storage_cleanup_defaults_and_staged_image_validation(client, monkeypatch) -> None: + assert client.post("/api/storage/cleanup").status_code == 200 + monkeypatch.setattr( + "glitchcraft.web.routes.inspect_image", + lambda *_args, **_kwargs: (_ for _ in ()).throw(ValueError("invalid image")), + ) + response = client.post( + "/api/image-sources", + data={"image": (png_file(), "test.png")}, + content_type="multipart/form-data", + ) + assert response.status_code == 400 + + class ImmediateThread: def __init__(self, target, args, **_kwargs): self.target = target @@ -190,6 +212,7 @@ def test_video_full_and_range_responses(client, app) -> None: assert partial.data == b"2345" assert partial.headers["Content-Range"] == "bytes 2-5/10" assert client.get("/video/result.mp4", headers={"Range": "items=1-2"}).status_code == 416 + assert client.get("/video/result.mp4", headers={"Range": "bytes=20-30"}).status_code == 416 def test_task_store_returns_copies() -> None: diff --git a/tests/test_service_contract.py b/tests/test_service_contract.py index 22799ff..d628176 100644 --- a/tests/test_service_contract.py +++ b/tests/test_service_contract.py @@ -62,7 +62,7 @@ def test_metadata_is_request_derived_redacted_and_truthful(client, app) -> None: "apiAddress": "http://127.0.0.1:5000", "localOnly": True, } - assert payload["storageSchemaVersion"] == 1 + assert payload["storageSchemaVersion"] == 2 assert payload["recipeSchemaVersion"] == 1 assert set(payload["effectTypes"]) assert str(app.config["DATA_ROOT"]) not in serialized @@ -130,7 +130,9 @@ def test_capabilities_are_structured_and_do_not_overclaim(client) -> None: payload = response.json slugs = {item["slug"] for item in payload["capabilities"]} assert set(CAPABILITY_SLUGS).issubset(slugs) - assert "persistent-video-library" not in json.dumps(payload) + assert "persistent-video-library" in json.dumps(payload) + assert "video-job-cancellation" in slugs + assert "bounded-video-jobs" in slugs assert "audio-preservation" not in json.dumps(payload) assert payload["outputFormats"] == [{"format": "PNG", "mimeType": "image/png"}] diff --git a/tests/test_storage_manifest.py b/tests/test_storage_manifest.py index b9b3a65..28799ac 100644 --- a/tests/test_storage_manifest.py +++ b/tests/test_storage_manifest.py @@ -49,7 +49,7 @@ def valid_output() -> dict: def test_manifest_models_accept_valid_document_and_reject_unknown_fields() -> None: document = ManifestDocument.model_validate( { - "schemaVersion": 1, + "schemaVersion": 2, "sources": {"abcdefghijklmnop": valid_source()}, "outputs": {"qrstuvwxyzABCDEF": valid_output()}, } @@ -82,10 +82,10 @@ def test_managed_paths_reject_absolute_traversal_and_wrong_boundaries( def test_manifest_rejects_schema_keys_and_embedded_recipe_errors() -> None: with pytest.raises(ValidationError): - ManifestDocument.model_validate({"schemaVersion": 2}) + ManifestDocument.model_validate({"schemaVersion": 3}) with pytest.raises(ValidationError): ManifestDocument.model_validate( - {"schemaVersion": 1, "sources": {"different": valid_source()}} + {"schemaVersion": 2, "sources": {"different": valid_source()}} ) output = valid_output() output["recipe"] = {"schemaVersion": 1, "seed": -1, "effects": []} @@ -116,7 +116,7 @@ def test_manifest_rejects_naive_dates_and_non_normal_paths() -> None: ImageSourceRecord.model_validate(source) with pytest.raises(ValidationError): ManifestDocument.model_validate( - {"schemaVersion": 1, "outputs": {"different": valid_output()}} + {"schemaVersion": 2, "outputs": {"different": valid_output()}} ) @@ -142,7 +142,7 @@ def test_asset_metadata_requires_utc_and_consistent_formats() -> None: def test_atomic_writer_creates_stable_primary_and_backup(tmp_path: Path) -> None: path = tmp_path / "manifest.json" writer = AtomicManifestWriter(path, tmp_path / "manifest.json.bak") - empty = ManifestDocument(schema_version=1) + empty = ManifestDocument(schema_version=2) writer.write(empty) first = path.read_bytes() assert load_manifest(path) == empty @@ -150,7 +150,7 @@ def test_atomic_writer_creates_stable_primary_and_backup(tmp_path: Path) -> None changed = ManifestDocument.model_validate( { - "schemaVersion": 1, + "schemaVersion": 2, "sources": {"abcdefghijklmnop": valid_source()}, } ) @@ -162,7 +162,7 @@ def test_atomic_writer_creates_stable_primary_and_backup(tmp_path: Path) -> None def test_failed_atomic_replace_keeps_previous_manifest(tmp_path: Path, monkeypatch) -> None: path = tmp_path / "manifest.json" writer = AtomicManifestWriter(path, tmp_path / "manifest.json.bak") - empty = ManifestDocument(schema_version=1) + empty = ManifestDocument(schema_version=2) writer.write(empty) previous = path.read_bytes() original_replace = writer._replace @@ -177,7 +177,7 @@ def fail_primary(source: Path, destination: Path) -> None: writer.write( ManifestDocument.model_validate( { - "schemaVersion": 1, + "schemaVersion": 2, "sources": {"abcdefghijklmnop": valid_source()}, } ) @@ -196,7 +196,7 @@ def test_manifest_read_and_temporary_write_failures_are_typed(tmp_path: Path, mo lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("interrupted")), ) with pytest.raises(ManifestWriteError): - writer.write(ManifestDocument(schema_version=1)) + writer.write(ManifestDocument(schema_version=2)) assert not list(tmp_path.glob(".manifest-*.tmp")) @@ -214,7 +214,7 @@ def test_backup_recovery_and_both_invalid_are_non_destructive(tmp_path: Path) -> recovered = ImageAssetRepository(tmp_path / "data", primary, tmp_path / "data" / "temporary") assert recovered.available assert recovered.last_report.recovered_from_backup - assert load_manifest(primary).schema_version == 1 + assert load_manifest(primary).schema_version == 2 assert list(recovered.recovery_folder.glob("reconciliation-*.json")) primary.write_text("{still-broken", encoding="utf-8") diff --git a/tests/test_video_workflow.py b/tests/test_video_workflow.py new file mode 100644 index 0000000..8f8a3a7 --- /dev/null +++ b/tests/test_video_workflow.py @@ -0,0 +1,1187 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +from io import BytesIO +from pathlib import Path +from typing import Any + +import numpy as np +import pytest +from flask import Flask +from pydantic import ValidationError + +from glitchcraft.contracts.effects import Recipe +from glitchcraft.errors import ( + ExternalToolError, + MediaReadError, + ProcessingCanceled, + QueueCapacityError, +) +from glitchcraft.jobs.contracts import VideoJobRequest, VideoPreviewRequest +from glitchcraft.jobs.manager import VideoJobManager +from glitchcraft.media.ffmpeg import finalize_video, reencode_for_browser +from glitchcraft.media.probe import VideoMetadata, probe_video +from glitchcraft.media.video import create_video_preview_at, process_video +from glitchcraft.storage.contracts import ( + AudioMode, + ManifestDocument, + VideoJobRecord, + VideoJobState, + VideoOutputRecord, + VideoSourceRecord, + validate_job_transition, +) +from glitchcraft.storage.errors import ( + AssetConflictError, + AssetNotFoundError, + ManifestWriteError, +) +from glitchcraft.storage.manifest import load_manifest_versioned +from glitchcraft.storage.repository import MediaAssetRepository +from glitchcraft.web.streaming import stream_path + + +def metadata(*, audio: bool = True) -> VideoMetadata: + return VideoMetadata( + container="mp4", + width=16, + height=12, + duration_seconds=2.5, + frame_rate="30/1", + frame_count=75, + video_codec="h264", + pixel_format="yuv420p", + has_audio=audio, + audio_codec="aac" if audio else None, + ) + + +def repository(tmp_path: Path) -> MediaAssetRepository: + return MediaAssetRepository( + tmp_path / "data", + tmp_path / "data" / "manifest.json", + tmp_path / "data" / "temporary", + ) + + +def add_video(repo: MediaAssetRepository, tmp_path: Path): + staged = repo.new_temporary_path(".mp4") + staged.write_bytes(b"source-video") + info = metadata() + return repo.create_video_source( + staged_path=staged, + extension="mp4", + original_name="clip.mp4", + container="mp4", + mime_type="video/mp4", + width=info.width, + height=info.height, + duration_seconds=info.duration_seconds, + frame_rate=info.frame_rate, + frame_count=info.frame_count, + video_codec=info.video_codec, + has_audio=info.has_audio, + audio_codec=info.audio_codec, + file_size=len(b"source-video"), + seed=7, + pixel_format=info.pixel_format, + ) + + +def test_video_contracts_are_strict_and_transitions_are_explicit() -> None: + source = VideoSourceRecord( + id="abcdefghijklmnop", + originalName="clip.mp4", + storedName="sources/videos/abcdefghijklmnop.mp4", + container="mp4", + mimeType="video/mp4", + width=16, + height=12, + durationSeconds=2.5, + frameRate="30000/1001", + frameCount=75, + videoCodec="h264", + hasAudio=True, + audioCodec="aac", + fileSize=10, + seed=1, + pixelFormat="yuv420p", + createdAt="2026-07-26T03:00:00Z", + ) + assert source.frame_rate == "30000/1001" + with pytest.raises(ValidationError): + source.model_copy(update={"audio_codec": None}).model_validate( + source.model_copy(update={"audio_codec": None}).model_dump() + ) + with pytest.raises(ValidationError): + VideoSourceRecord.model_validate( + source.model_dump() | {"stored_name": "sources/videos/../clip.mp4"} + ) + with pytest.raises(ValidationError): + VideoSourceRecord.model_validate(source.model_dump() | {"frame_rate": "0/1"}) + for changes in ( + {"frame_rate": "30"}, + {"frame_rate": "fast/1"}, + {"frame_rate": "300/1"}, + {"duration_seconds": float("inf")}, + {"stored_name": "sources/images/clip.mp4"}, + {"mime_type": "video/quicktime"}, + {"stored_name": "sources/videos/clip.mov"}, + ): + with pytest.raises(ValidationError): + VideoSourceRecord.model_validate(source.model_dump() | changes) + with pytest.raises(ValueError): + validate_job_transition(VideoJobState.QUEUED, VideoJobState.COMPLETED) + validate_job_transition(VideoJobState.QUEUED, VideoJobState.PREPARING) + assert VideoJobState.COMPLETED.terminal + assert not VideoJobState.PROCESSING.terminal + + request = VideoJobRequest.model_validate( + {"recipe": {"schemaVersion": 1, "seed": 2, "effects": []}} + ) + assert request.audio_mode == AudioMode.PRESERVE + with pytest.raises(ValidationError): + VideoPreviewRequest.model_validate( + { + "recipe": {"schemaVersion": 1, "seed": 2, "effects": []}, + "timestampSeconds": float("inf"), + } + ) + + +def test_video_output_job_and_manifest_consistency_contracts() -> None: + base_output = { + "id": "qrstuvwxyzABCDEF", + "kind": "video", + "sourceId": "abcdefghijklmnop", + "jobId": "jobidentifier1234", + "fileName": "result.mp4", + "storedName": "outputs/videos/qrstuvwxyzABCDEF.mp4", + "mimeType": "video/mp4", + "width": 16, + "height": 12, + "durationSeconds": 2.5, + "frameRate": "30/1", + "videoCodec": "h264", + "pixelFormat": "yuv420p", + "audioMode": "remove", + "hasAudio": False, + "audioCodec": None, + "fileSize": 10, + "recipe": {"schemaVersion": 1, "seed": 7, "effects": []}, + "createdAt": "2026-07-26T03:00:00Z", + } + output = VideoOutputRecord.model_validate(base_output) + assert output.seed == 7 + for changes in ( + {"storedName": "outputs/images/result.mp4"}, + {"durationSeconds": float("inf")}, + {"hasAudio": True}, + {"audioMode": "remove", "hasAudio": True, "audioCodec": "aac"}, + ): + with pytest.raises(ValidationError): + VideoOutputRecord.model_validate(base_output | changes) + + now = "2026-07-26T03:00:00Z" + base_job = { + "id": "jobidentifier1234", + "sourceId": "abcdefghijklmnop", + "recipe": {"schemaVersion": 1, "seed": 7, "effects": []}, + "state": "queued", + "progress": 0, + "stage": "queued", + "createdAt": now, + "updatedAt": now, + } + for changes in ( + {"state": "completed", "progress": 99, "completedAt": now}, + {"state": "canceled", "outputId": "qrstuvwxyzABCDEF", "completedAt": now}, + {"state": "failed", "completedAt": now}, + {"errorCode": "wrong", "errorMessage": "wrong"}, + ): + with pytest.raises(ValidationError): + VideoJobRecord.model_validate(base_job | changes) + + with pytest.raises(ValidationError): + ManifestDocument.model_validate( + { + "schemaVersion": 2, + "sources": {}, + "outputs": {}, + "jobs": {"wrong": base_job}, + } + ) + + +def test_manifest_v1_migration_is_validated_and_non_destructive(tmp_path: Path) -> None: + path = tmp_path / "manifest.json" + path.write_text( + json.dumps({"schemaVersion": 1, "sources": {}, "outputs": {}}), + encoding="utf-8", + ) + loaded = load_manifest_versioned(path) + assert loaded.migrated + assert loaded.source_schema_version == 1 + assert loaded.document.schema_version == 2 + + data = tmp_path / "managed" + data.mkdir() + (data / "manifest.json").write_bytes(path.read_bytes()) + repo = MediaAssetRepository(data, data / "manifest.json", data / "temporary") + assert repo.last_report.state == "migrated" + assert repo.last_report.migrated_from_schema_version == 1 + assert list(repo.recovery_folder.glob("manifest-v1-*.json")) + assert json.loads((data / "manifest.json").read_text())["schemaVersion"] == 2 + + +def test_video_repository_lifecycle_and_restart_recovery(tmp_path: Path) -> None: + repo = repository(tmp_path) + source = add_video(repo, tmp_path) + assert repo.get_video_source(source.id) == source + assert repo.list_video_sources() == [source] + with repo.lease_video_source(source.id) as (leased, path): + assert leased.id == source.id + assert path.read_bytes() == b"source-video" + + recipe = Recipe(seed=source.seed) + job = repo.create_video_job(source_id=source.id, recipe=recipe, audio_mode=AudioMode.PRESERVE) + assert repo.get_video_job(job.id).state == VideoJobState.QUEUED + preparing = repo.update_video_job( + job.id, + state=VideoJobState.PREPARING, + stage="preparing", + increment_attempt=True, + ) + assert preparing.started_at is not None + repo.update_video_job(job.id, state=VideoJobState.PROCESSING, stage="processing") + repo.update_video_job(job.id, state=VideoJobState.MUXING, progress=95, stage="muxing") + staged = repo.new_temporary_path(".mp4") + staged.write_bytes(b"finished-video") + output = repo.complete_video_job( + job_id=job.id, + staged_path=staged, + file_name="finished.mp4", + width=16, + height=12, + duration_seconds=2.5, + frame_rate="30/1", + has_audio=True, + file_size=len(b"finished-video"), + ) + assert isinstance(repo.get_video_output(output.id), VideoOutputRecord) + assert repo.list_video_outputs() == [output] + assert repo.get_video_job(job.id).state == VideoJobState.COMPLETED + with repo.lease_video_output(output.id) as (_, path): + assert path.read_bytes() == b"finished-video" + + queued = repo.create_video_job(source_id=source.id, recipe=recipe, audio_mode=AudioMode.REMOVE) + canceled = repo.request_video_job_cancellation(queued.id) + assert canceled.state == VideoJobState.CANCELED + assert repo.request_video_job_cancellation(queued.id) == canceled + with pytest.raises(AssetNotFoundError): + repo.get_video_job("missing-video-job") + with pytest.raises(AssetConflictError): + repo.complete_video_job( + job_id=queued.id, + staged_path=repo.new_temporary_path(".mp4"), + file_name="bad.mp4", + width=1, + height=1, + duration_seconds=1, + frame_rate="1/1", + has_audio=False, + file_size=1, + ) + status = repo.status() + assert status["videoSourceCount"] == 1 + assert status["videoOutputCount"] == 1 + assert status["videoJobCount"] == 2 + + interrupted = repo.create_video_job( + source_id=source.id, recipe=recipe, audio_mode=AudioMode.REMOVE + ) + repo.update_video_job(interrupted.id, state=VideoJobState.PREPARING, stage="preparing") + restarted = MediaAssetRepository( + repo.data_root, + repo.manifest_path, + repo.temporary_folder, + ) + recovered = {item.id: item for item in restarted.recover_video_jobs()} + assert recovered[interrupted.id].state == VideoJobState.QUEUED + assert recovered[interrupted.id].recovered_after_restart + + with pytest.raises(AssetConflictError): + restarted.delete_video_source(source.id, cascade=False) + restarted.request_video_job_cancellation(interrupted.id) + restarted.delete_video_output(output.id) + with pytest.raises(AssetNotFoundError): + restarted.get_video_output(output.id) + deleted_outputs, deleted_jobs = restarted.delete_video_source(source.id, cascade=True) + assert deleted_outputs == 0 + assert deleted_jobs == 3 + + +def test_probe_adapter_success_and_controlled_failures( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + payload: dict[str, Any] = { + "streams": [ + { + "codec_type": "video", + "codec_name": "h264", + "width": 16, + "height": 12, + "duration": "2.5", + "avg_frame_rate": "30000/1001", + "nb_frames": "75", + "pix_fmt": "yuv420p", + "tags": {}, + }, + {"codec_type": "audio", "codec_name": "aac"}, + ], + "format": {"format_name": "mov,mp4", "duration": "2.5"}, + } + monkeypatch.setattr( + "glitchcraft.media.probe.subprocess.run", + lambda *_args, **_kwargs: subprocess.CompletedProcess([], 0, json.dumps(payload), ""), + ) + result = probe_video(tmp_path / "clip.mp4") + assert result.has_audio and result.frame_rate == "30000/1001" + + payload["streams"][0]["tags"] = {"rotate": "90"} + with pytest.raises(MediaReadError): + probe_video(tmp_path / "rotated.mp4") + payload["streams"][0]["tags"] = {} + payload["streams"][0]["avg_frame_rate"] = "0/0" + with pytest.raises(MediaReadError): + probe_video(tmp_path / "invalid-rate.mp4") + monkeypatch.setattr( + "glitchcraft.media.probe.subprocess.run", + lambda *_args, **_kwargs: (_ for _ in ()).throw(FileNotFoundError()), + ) + with pytest.raises(ExternalToolError): + probe_video(tmp_path / "clip.mp4") + monkeypatch.setattr( + "glitchcraft.media.probe.subprocess.run", + lambda *_args, **_kwargs: (_ for _ in ()).throw(subprocess.TimeoutExpired("ffprobe", 1)), + ) + with pytest.raises(MediaReadError): + probe_video(tmp_path / "clip.mp4") + + +class FakeProcess: + def __init__(self, returncode: int = 0) -> None: + self.returncode: int | None = None + self.final_returncode = returncode + self.polls = 0 + self.terminated = False + + def poll(self) -> int | None: + self.polls += 1 + if self.polls > 1 and not self.terminated: + self.returncode = self.final_returncode + return self.returncode + + def terminate(self) -> None: + self.terminated = True + self.returncode = -1 + + def kill(self) -> None: + self.returncode = -9 + + def wait(self, timeout: float) -> int: + assert timeout == 3 + return self.returncode or 0 + + +class SlowTerminateProcess(FakeProcess): + def wait(self, timeout: float) -> int: + raise subprocess.TimeoutExpired("ffmpeg", timeout) + + +def test_finalizer_success_failure_and_cancellation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + intermediate = tmp_path / "intermediate.mp4" + source = tmp_path / "source.mp4" + output = tmp_path / "output.mp4" + intermediate.write_bytes(b"x") + source.write_bytes(b"x") + monkeypatch.setattr("glitchcraft.media.ffmpeg.time.sleep", lambda _value: None) + monkeypatch.setattr( + "glitchcraft.media.ffmpeg.subprocess.Popen", + lambda *_args, **_kwargs: FakeProcess(), + ) + finalize_video( + intermediate, + source, + output, + preserve_audio=True, + cancellation_check=lambda: False, + ) + monkeypatch.setattr( + "glitchcraft.media.ffmpeg.subprocess.Popen", + lambda *_args, **_kwargs: FakeProcess(1), + ) + with pytest.raises(ExternalToolError): + finalize_video( + intermediate, + source, + output, + preserve_audio=False, + cancellation_check=lambda: False, + ) + monkeypatch.setattr( + "glitchcraft.media.ffmpeg.subprocess.Popen", + lambda *_args, **_kwargs: FakeProcess(), + ) + with pytest.raises(ProcessingCanceled): + finalize_video( + intermediate, + source, + output, + preserve_audio=False, + cancellation_check=lambda: True, + ) + monkeypatch.setattr( + "glitchcraft.media.ffmpeg.subprocess.Popen", + lambda *_args, **_kwargs: SlowTerminateProcess(), + ) + with pytest.raises(ProcessingCanceled): + finalize_video( + intermediate, + source, + output, + preserve_audio=False, + cancellation_check=lambda: True, + ) + monkeypatch.setattr( + "glitchcraft.media.ffmpeg.subprocess.Popen", + lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("missing")), + ) + with pytest.raises(ExternalToolError): + finalize_video( + intermediate, + source, + output, + preserve_audio=False, + cancellation_check=lambda: False, + ) + + +def test_legacy_reencoder_success_and_controlled_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = tmp_path / "source.mp4" + source.write_bytes(b"source") + temporary = tmp_path / "source_h264.mp4" + + def succeed(*_args, **_kwargs): + temporary.write_bytes(b"encoded") + return subprocess.CompletedProcess([], 0) + + monkeypatch.setattr("glitchcraft.media.ffmpeg.subprocess.run", succeed) + reencode_for_browser(source) + assert source.read_bytes() == b"encoded" + monkeypatch.setattr( + "glitchcraft.media.ffmpeg.subprocess.run", + lambda *_args, **_kwargs: (_ for _ in ()).throw( + subprocess.CalledProcessError(1, ["ffmpeg"]) + ), + ) + with pytest.raises(ExternalToolError): + reencode_for_browser(source) + assert not temporary.exists() + + +class TimestampCapture: + def __init__(self) -> None: + self.position = 0.0 + + def isOpened(self) -> bool: + return True + + def set(self, _property: int, value: float) -> bool: + self.position = value + return True + + def read(self): + return True, np.zeros((2, 3, 3), dtype=np.uint8) + + def get(self, property_id: int) -> float: + if property_id == 1: + return 19 + return 10 + + def release(self) -> None: + pass + + +def test_timestamp_preview_seeks_and_uses_actual_frame( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + capture = TimestampCapture() + monkeypatch.setattr("glitchcraft.media.video.cv2.VideoCapture", lambda _path: capture) + output = tmp_path / "preview.png" + frame_index = create_video_preview_at( + tmp_path / "source.mp4", + output, + Recipe(seed=1), + timestamp_seconds=1.5, + ) + assert capture.position == 1500 + assert frame_index == 18 + assert output.is_file() + + +def test_manager_processes_and_cancels_persistent_jobs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repo = repository(tmp_path) + source = add_video(repo, tmp_path) + job = repo.create_video_job( + source_id=source.id, recipe=Recipe(seed=7), audio_mode=AudioMode.REMOVE + ) + + def fake_process(_source, destination, _recipe, **kwargs): + assert not kwargs["cancellation_check"]() + kwargs["progress_hook"](1, 1) + destination.write_bytes(b"intermediate") + + def fake_finalize(_intermediate, _source, destination, **_kwargs): + destination.write_bytes(b"final") + + monkeypatch.setattr("glitchcraft.jobs.manager.process_video", fake_process) + monkeypatch.setattr("glitchcraft.jobs.manager.finalize_video", fake_finalize) + monkeypatch.setattr("glitchcraft.jobs.manager.probe_video", lambda _path: metadata(audio=False)) + manager = VideoJobManager(repo, capacity=1) + manager._run(job.id) + assert repo.get_video_job(job.id).state == VideoJobState.COMPLETED + + invalid_profile = repo.create_video_job( + source_id=source.id, recipe=Recipe(seed=7), audio_mode=AudioMode.REMOVE + ) + monkeypatch.setattr( + "glitchcraft.jobs.manager.probe_video", + lambda _path: metadata(audio=True), + ) + manager._run(invalid_profile.id) + assert repo.get_video_job(invalid_profile.id).state == VideoJobState.FAILED + + queued = repo.create_video_job( + source_id=source.id, recipe=Recipe(seed=7), audio_mode=AudioMode.REMOVE + ) + manager.enqueue(queued.id) + manager.cancel(queued.id) + assert repo.get_video_job(queued.id).state == VideoJobState.CANCELED + assert manager.status()["queued"] == 0 + + +def test_frame_processing_cancels_before_opening_media(tmp_path: Path) -> None: + with pytest.raises(ProcessingCanceled): + process_video( + tmp_path / "missing.mp4", + tmp_path / "output.mp4", + Recipe(seed=1), + cancellation_check=lambda: True, + ) + + +def test_manager_queue_threads_and_failure_paths( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repo = repository(tmp_path) + source = add_video(repo, tmp_path) + first = repo.create_video_job( + source_id=source.id, recipe=Recipe(seed=7), audio_mode=AudioMode.REMOVE + ) + second = repo.create_video_job( + source_id=source.id, recipe=Recipe(seed=7), audio_mode=AudioMode.REMOVE + ) + manager = VideoJobManager(repo, capacity=1) + manager.enqueue(first.id) + manager.enqueue(first.id) + with pytest.raises(QueueCapacityError): + manager.enqueue(second.id) + manager.cancel(first.id) + manager.cancel(first.id) + + failing = repo.create_video_job( + source_id=source.id, recipe=Recipe(seed=7), audio_mode=AudioMode.REMOVE + ) + monkeypatch.setattr( + "glitchcraft.jobs.manager.process_video", + lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("decode failed")), + ) + manager._run(failing.id) + failed = repo.get_video_job(failing.id) + assert failed.state == VideoJobState.FAILED + assert failed.error_code == "video_processing_failed" + manager._run(failing.id) + + canceled = repo.create_video_job( + source_id=source.id, recipe=Recipe(seed=7), audio_mode=AudioMode.REMOVE + ) + monkeypatch.setattr( + "glitchcraft.jobs.manager.process_video", + lambda *_args, **_kwargs: (_ for _ in ()).throw(ProcessingCanceled("canceled")), + ) + manager._run(canceled.id) + assert repo.get_video_job(canceled.id).state == VideoJobState.CANCELED + + threaded = VideoJobManager(repo) + threaded.start() + threaded.start() + assert threaded.status()["workers"] == 1 + threaded.shutdown() + assert threaded.status()["workers"] == 0 + + direct = VideoJobManager(repo) + direct._queue.append("synthetic") + called: list[str] = [] + + def stop_after_run(job_id: str) -> None: + called.append(job_id) + direct._stopping = True + + monkeypatch.setattr(direct, "_run", stop_after_run) + direct._worker() + assert called == ["synthetic"] + + +def test_restart_recovery_cancellation_attempt_limit_and_queued( + tmp_path: Path, +) -> None: + repo = MediaAssetRepository( + tmp_path / "data", + tmp_path / "data" / "manifest.json", + tmp_path / "data" / "temporary", + video_job_maximum_attempts=1, + ) + source = add_video(repo, tmp_path) + queued = repo.create_video_job( + source_id=source.id, recipe=Recipe(seed=7), audio_mode=AudioMode.REMOVE + ) + assert repo.recover_video_jobs()[0].id == queued.id + + active = repo.create_video_job( + source_id=source.id, recipe=Recipe(seed=7), audio_mode=AudioMode.REMOVE + ) + repo.update_video_job( + active.id, + state=VideoJobState.PREPARING, + stage="preparing", + increment_attempt=True, + ) + recovered = repo.recover_video_jobs() + exhausted = next(item for item in recovered if item.id == active.id) + assert exhausted.state == VideoJobState.FAILED + + requested = repo.create_video_job( + source_id=source.id, recipe=Recipe(seed=7), audio_mode=AudioMode.REMOVE + ) + repo.update_video_job(requested.id, cancellation_requested=True) + recovered = repo.recover_video_jobs() + canceled = next(item for item in recovered if item.id == requested.id) + assert canceled.state == VideoJobState.CANCELED + + +def test_startup_reconciles_missing_media_and_interrupted_completion( + tmp_path: Path, +) -> None: + missing_source_repo = repository(tmp_path / "missing-source") + source = add_video(missing_source_repo, tmp_path) + queued = missing_source_repo.create_video_job( + source_id=source.id, recipe=Recipe(seed=7), audio_mode=AudioMode.REMOVE + ) + with missing_source_repo.lease_video_source(source.id) as (_, path): + source_path = path + source_path.unlink() + restarted = MediaAssetRepository( + missing_source_repo.data_root, + missing_source_repo.manifest_path, + missing_source_repo.temporary_folder, + ) + assert restarted.get_video_job(queued.id).state == VideoJobState.FAILED + assert restarted.last_report.failed_jobs == 1 + + interrupted_repo = repository(tmp_path / "interrupted") + source = add_video(interrupted_repo, tmp_path) + job = interrupted_repo.create_video_job( + source_id=source.id, recipe=Recipe(seed=7), audio_mode=AudioMode.REMOVE + ) + interrupted_repo.update_video_job(job.id, state=VideoJobState.PREPARING, stage="preparing") + interrupted_repo.update_video_job(job.id, state=VideoJobState.PROCESSING, stage="processing") + interrupted_repo.update_video_job( + job.id, state=VideoJobState.MUXING, stage="muxing", progress=90 + ) + staged = interrupted_repo.new_temporary_path(".mp4") + staged.write_bytes(b"finished") + output = interrupted_repo.complete_video_job( + job_id=job.id, + staged_path=staged, + file_name="finished.mp4", + width=16, + height=12, + duration_seconds=2.5, + frame_rate="30/1", + has_audio=False, + file_size=8, + ) + payload = json.loads(interrupted_repo.manifest_path.read_text(encoding="utf-8")) + payload["jobs"][job.id] |= { + "state": "muxing", + "stage": "muxing", + "progress": 90, + "outputId": None, + "completedAt": None, + } + interrupted_repo.manifest_path.write_text(json.dumps(payload), encoding="utf-8") + recovered = MediaAssetRepository( + interrupted_repo.data_root, + interrupted_repo.manifest_path, + interrupted_repo.temporary_folder, + ) + recovered_job = recovered.get_video_job(job.id) + assert recovered_job.state == VideoJobState.COMPLETED + assert recovered_job.output_id == output.id + assert recovered.last_report.recovered_jobs == 1 + + with recovered.lease_video_output(output.id) as (_, output_path): + persisted_output_path = output_path + persisted_output_path.unlink() + missing_output = MediaAssetRepository( + recovered.data_root, + recovered.manifest_path, + recovered.temporary_folder, + ) + assert missing_output.get_video_job(job.id).state == VideoJobState.FAILED + assert missing_output.last_report.missing_outputs == 1 + + +@pytest.mark.parametrize( + ("range_header", "status", "body", "content_range"), + [ + (None, 200, b"0123456789", None), + ("bytes=2-5", 206, b"2345", "bytes 2-5/10"), + ("bytes=7-", 206, b"789", "bytes 7-9/10"), + ("bytes=-3", 206, b"789", "bytes 7-9/10"), + ("bytes=20-30", 416, b"", "bytes */10"), + ("bytes=0-1,4-5", 416, b"", "bytes */10"), + ("bytes=-0", 416, b"", "bytes */10"), + ("bytes=-", 416, b"", "bytes */10"), + ], +) +def test_single_range_streaming( + tmp_path: Path, + range_header: str | None, + status: int, + body: bytes, + content_range: str | None, +) -> None: + path = tmp_path / "video.mp4" + path.write_bytes(b"0123456789") + app = Flask(__name__) + headers = {"Range": range_header} if range_header else {} + with app.test_request_context(headers=headers): + response = stream_path(path, mime_type="video/mp4", download_name="video.mp4") + assert response.status_code == status + assert response.get_data() == body + assert response.headers.get("Content-Range") == content_range + assert response.headers["Accept-Ranges"] == "bytes" + + +def test_head_streaming_has_headers_without_a_body(tmp_path: Path) -> None: + path = tmp_path / "video.mp4" + path.write_bytes(b"0123456789") + app = Flask(__name__) + with app.test_request_context(method="HEAD"): + response = stream_path(path, mime_type="video/mp4", download_name="video.mp4") + assert response.status_code == 200 + assert response.response == [] + assert response.headers["Content-Length"] == "10" + + +def test_video_http_upload_preview_jobs_and_range( + client, app, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("glitchcraft.web.routes.probe_video", lambda _path: metadata()) + monkeypatch.setattr("glitchcraft.web.routes.ffmpeg_available", lambda: True) + monkeypatch.setattr("glitchcraft.web.routes.ffprobe_available", lambda: True) + upload = client.post( + "/api/video-sources", + data={"video": (BytesIO(b"video"), "clip.mp4"), "seed": "42"}, + ) + assert upload.status_code == 201 + source = upload.json + assert source["seed"] == 42 + assert client.get("/api/video-sources").json["sources"][0]["sourceId"] == source["sourceId"] + assert client.get(f"/api/video-sources/{source['sourceId']}").status_code == 200 + + def preview(_source, destination, _recipe, **_kwargs): + destination.write_bytes(b"png") + return 3 + + monkeypatch.setattr("glitchcraft.web.routes.create_video_preview_at", preview) + recipe = {"schemaVersion": 1, "seed": source["seed"], "effects": []} + response = client.post( + f"/api/video-sources/{source['sourceId']}/preview", + json={"recipe": recipe, "timestampSeconds": 1}, + ) + assert response.status_code == 200 + assert response.headers["X-GlitchCraft-Frame-Index"] == "3" + + response = client.post( + f"/api/video-sources/{source['sourceId']}/jobs", + json={"recipe": recipe, "audioMode": "remove"}, + ) + assert response.status_code == 202 + job_id = response.json["jobId"] + assert client.get(f"/api/video-jobs/{job_id}").status_code == 200 + canceled = client.post(f"/api/video-jobs/{job_id}/cancel") + assert canceled.status_code == 202 + assert canceled.json["state"] == "canceled" + assert client.post(f"/api/video-jobs/{job_id}/cancel").status_code == 409 + assert client.get("/api/video-jobs?state=canceled").json["jobs"][0]["jobId"] == job_id + assert client.get("/api/video-jobs?state=unknown").status_code == 400 + assert client.delete(f"/api/video-jobs/{job_id}").status_code == 200 + + +def test_video_http_streaming_outputs_and_controlled_errors( + client, app, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("glitchcraft.web.routes.ffmpeg_available", lambda: True) + monkeypatch.setattr("glitchcraft.web.routes.ffprobe_available", lambda: True) + assert client.post("/api/video-sources").status_code == 400 + assert ( + client.post( + "/api/video-sources", + data={"video": (BytesIO(b"x"), "clip.webm")}, + ).status_code + == 400 + ) + monkeypatch.setattr( + "glitchcraft.web.routes.probe_video", + lambda _path: (_ for _ in ()).throw(MediaReadError("invalid")), + ) + assert ( + client.post( + "/api/video-sources", + data={"video": (BytesIO(b"video"), "invalid.mp4")}, + ).status_code + == 422 + ) + monkeypatch.setattr( + "glitchcraft.web.routes.probe_video", + lambda _path: (_ for _ in ()).throw(ExternalToolError("missing")), + ) + assert ( + client.post( + "/api/video-sources", + data={"video": (BytesIO(b"video"), "unavailable.mp4")}, + ).status_code + == 503 + ) + monkeypatch.setattr("glitchcraft.web.routes.probe_video", lambda _path: metadata()) + empty = client.post( + "/api/video-sources", + data={"video": (BytesIO(b""), "empty.mp4")}, + ) + assert empty.status_code == 400 + upload = client.post( + "/api/video-sources", + data={"video": (BytesIO(b"video"), "clip.mp4")}, + ) + source = upload.json + source_id = source["sourceId"] + app.config["MAX_VIDEO_DURATION_SECONDS"] = 1 + assert ( + client.post( + "/api/video-sources", + data={"video": (BytesIO(b"video"), "long.mp4")}, + ).status_code + == 400 + ) + app.config["MAX_VIDEO_DURATION_SECONDS"] = 3600 + app.config["MAX_VIDEO_PIXELS"] = 1 + assert ( + client.post( + "/api/video-sources", + data={"video": (BytesIO(b"video"), "large.mp4")}, + ).status_code + == 400 + ) + app.config["MAX_VIDEO_PIXELS"] = 3840 * 2160 + monkeypatch.setattr( + "glitchcraft.web.routes.probe_video", + lambda _path: metadata().model_copy(update={"container": "avi"}), + ) + assert ( + client.post( + "/api/video-sources", + data={"video": (BytesIO(b"video"), "mismatch.mp4")}, + ).status_code + == 400 + ) + monkeypatch.setattr("glitchcraft.web.routes.probe_video", lambda _path: metadata()) + assert ( + client.post( + "/api/video-sources", + data={"video": (BytesIO(b"video"), "seed.mp4"), "seed": "-1"}, + ).status_code + == 400 + ) + assert client.get("/api/video-sources?unknown=1").status_code == 400 + assert client.get("/api/video-sources/missing").status_code == 404 + assert client.get(f"/api/video-sources/{source_id}?unknown=1").status_code == 400 + assert client.get("/api/video-sources/missing/original").status_code == 404 + assert client.get("/api/video-sources/missing/download").status_code == 404 + assert client.get(f"/api/video-sources/{source_id}/original?unknown=1").status_code == 400 + original = client.get(f"/api/video-sources/{source_id}/original") + assert original.data == b"video" + original.close() + ranged = client.get(f"/api/video-sources/{source_id}/original", headers={"Range": "bytes=1-3"}) + assert ranged.status_code == 206 + assert ranged.data == b"ide" + ranged.close() + head = client.head(f"/api/video-sources/{source_id}/original") + assert head.headers["Content-Length"] == "5" + head.close() + source_download = client.get(f"/api/video-sources/{source_id}/download") + assert source_download.headers["Content-Disposition"].startswith("attachment") + source_download.close() + + recipe = {"schemaVersion": 1, "seed": source["seed"], "effects": []} + assert ( + client.post( + f"/api/video-sources/{source_id}/preview", + json={"recipe": recipe, "timestampSeconds": 2.5}, + ).status_code + == 400 + ) + monkeypatch.setattr( + "glitchcraft.web.routes.create_video_preview_at", + lambda *_args, **_kwargs: (_ for _ in ()).throw(MediaReadError("bad frame")), + ) + assert ( + client.post( + f"/api/video-sources/{source_id}/preview", + json={"recipe": recipe, "timestampSeconds": 1}, + ).status_code + == 422 + ) + assert ( + client.post( + "/api/video-sources/missing/preview", + json={"recipe": recipe, "timestampSeconds": 1}, + ).status_code + == 404 + ) + + repo: MediaAssetRepository = app.extensions["media_repository"] + job = repo.create_video_job( + source_id=source_id, recipe=Recipe(seed=source["seed"]), audio_mode="remove" + ) + repo.update_video_job(job.id, state=VideoJobState.PREPARING, stage="preparing") + repo.update_video_job(job.id, state=VideoJobState.PROCESSING, stage="processing") + repo.update_video_job(job.id, state=VideoJobState.MUXING, stage="muxing", progress=95) + staged = repo.new_temporary_path(".mp4") + staged.write_bytes(b"0123456789") + output = repo.complete_video_job( + job_id=job.id, + staged_path=staged, + file_name="result.mp4", + width=16, + height=12, + duration_seconds=2.5, + frame_rate="30/1", + has_audio=False, + file_size=10, + ) + assert client.get(f"/api/video-outputs/{output.id}/metadata").status_code == 200 + assert client.get("/api/video-outputs").json["outputs"][0]["outputId"] == output.id + completed_job = client.get(f"/api/video-jobs/{job.id}").json + assert completed_job["outputId"] == output.id + assert completed_job["outputUrl"].endswith(output.id) + response = client.get(f"/api/video-outputs/{output.id}", headers={"Range": "bytes=-4"}) + assert response.status_code == 206 + assert response.data == b"6789" + response.close() + download = client.get(f"/api/video-outputs/{output.id}/download") + assert download.headers["Content-Disposition"].startswith("attachment") + download.close() + assert client.get("/api/video-outputs/missing").status_code == 404 + assert client.get("/api/video-outputs/missing/metadata").status_code == 404 + assert client.get("/api/video-outputs/missing/download").status_code == 404 + assert client.get("/api/video-outputs?unknown=1").status_code == 400 + assert client.get(f"/api/video-outputs/{output.id}?unknown=1").status_code == 400 + assert client.delete("/api/video-outputs/missing").status_code == 404 + assert client.get("/api/video-jobs/missing").status_code == 404 + assert client.post("/api/video-jobs/missing/cancel").status_code == 404 + + manager = app.extensions["video_job_manager"] + monkeypatch.setattr( + manager, + "enqueue", + lambda _job_id: (_ for _ in ()).throw(QueueCapacityError("full")), + ) + response = client.post( + f"/api/video-sources/{source_id}/jobs", + json={"recipe": recipe, "audioMode": "remove"}, + ) + assert response.status_code == 429 + failed_jobs = client.get("/api/video-jobs?state=failed").json["jobs"] + assert failed_jobs[0]["error"]["code"] == "queue_full" + assert client.get("/api/video-jobs").status_code == 200 + assert ( + client.post( + f"/api/video-sources/{source_id}/jobs", + json={"recipe": {"schemaVersion": 2, "seed": 1, "effects": []}}, + ).status_code + == 400 + ) + assert ( + client.post( + "/api/video-sources/missing/jobs", + json={"recipe": recipe}, + ).status_code + == 404 + ) + active = repo.create_video_job( + source_id=source_id, recipe=Recipe(seed=source["seed"]), audio_mode="remove" + ) + assert client.delete(f"/api/video-jobs/{active.id}").status_code == 409 + assert client.get("/api/video-jobs?unknown=1").status_code == 400 + monkeypatch.setattr("glitchcraft.web.routes.ffmpeg_available", lambda: False) + assert ( + client.post( + f"/api/video-sources/{source_id}/jobs", + json={"recipe": recipe}, + ).status_code + == 503 + ) + repo.request_video_job_cancellation(active.id) + assert client.delete(f"/api/video-sources/{source_id}").status_code == 409 + assert client.delete(f"/api/video-outputs/{output.id}").status_code == 200 + unavailable_output = client.get(f"/api/video-jobs/{job.id}").json + assert unavailable_output["outputAvailable"] is False + assert client.delete(f"/api/video-sources/{source_id}?cascade=maybe").status_code == 400 + assert client.delete(f"/api/video-sources/{source_id}?cascade=true").status_code == 200 + assert client.delete("/api/video-sources/missing?cascade=true").status_code == 404 + + repo._available = False + assert client.get("/api/video-sources").status_code == 503 + assert client.get("/api/video-jobs").status_code == 503 + assert client.get("/api/video-outputs").status_code == 503 + repo._available = True + + +def test_video_delete_leases_and_manifest_rollback( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + repo = repository(tmp_path) + source = add_video(repo, tmp_path) + with repo.lease_video_source(source.id), pytest.raises(AssetConflictError): + repo.delete_video_source(source.id, cascade=True) + + job = repo.create_video_job( + source_id=source.id, recipe=Recipe(seed=7), audio_mode=AudioMode.REMOVE + ) + repo.request_video_job_cancellation(job.id) + original_write = repo._writer.write + monkeypatch.setattr( + repo._writer, + "write", + lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("disk")), + ) + with pytest.raises(ManifestWriteError): + repo.delete_video_source(source.id, cascade=True) + monkeypatch.setattr(repo._writer, "write", original_write) + assert repo.get_video_source(source.id).id == source.id + + +@pytest.mark.skipif( + shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None, + reason="FFmpeg and FFprobe are required for the real-media integration test.", +) +def test_real_media_pipeline_preserves_audio_and_creates_browser_mp4( + tmp_path: Path, +) -> None: + generated = tmp_path / "generated.mp4" + subprocess.run( + [ + "ffmpeg", + "-loglevel", + "error", + "-y", + "-f", + "lavfi", + "-i", + "testsrc=size=64x48:rate=12:duration=1", + "-f", + "lavfi", + "-i", + "sine=frequency=440:duration=1", + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + "-shortest", + str(generated), + ], + check=True, + capture_output=True, + ) + info = probe_video(generated) + repo = repository(tmp_path) + staged = repo.new_temporary_path(".mp4") + shutil.copyfile(generated, staged) + source = repo.create_video_source( + staged_path=staged, + extension="mp4", + original_name="generated.mp4", + container="mp4", + mime_type="video/mp4", + width=info.width, + height=info.height, + duration_seconds=info.duration_seconds, + frame_rate=info.frame_rate, + frame_count=info.frame_count, + video_codec=info.video_codec, + has_audio=info.has_audio, + audio_codec=info.audio_codec, + file_size=generated.stat().st_size, + seed=19, + pixel_format=info.pixel_format, + ) + job = repo.create_video_job( + source_id=source.id, + recipe=Recipe(seed=source.seed), + audio_mode=AudioMode.PRESERVE, + ) + VideoJobManager(repo)._run(job.id) + completed = repo.get_video_job(job.id) + assert completed.state == VideoJobState.COMPLETED + assert completed.output_id is not None + with repo.lease_video_output(completed.output_id) as (_, path): + final = probe_video(path) + assert final.video_codec == "h264" + assert final.pixel_format == "yuv420p" + assert final.audio_codec == "aac" + + remove_job = repo.create_video_job( + source_id=source.id, + recipe=Recipe(seed=source.seed), + audio_mode=AudioMode.REMOVE, + ) + VideoJobManager(repo)._run(remove_job.id) + removed = repo.get_video_job(remove_job.id) + assert removed.output_id is not None + with repo.lease_video_output(removed.output_id) as (_, path): + silent = probe_video(path) + assert not silent.has_audio + assert silent.audio_codec is None