diff --git a/app.py b/app.py index 93a51a0..227bdb4 100644 --- a/app.py +++ b/app.py @@ -17,4 +17,5 @@ cleanup.start() atexit.register(cleanup.stop) atexit.register(app.extensions["video_job_manager"].shutdown) + atexit.register(app.extensions["motion_preview_manager"].shutdown) app.run(debug=False) diff --git a/check.py b/check.py index cf67bd0..160fe5f 100644 --- a/check.py +++ b/check.py @@ -26,6 +26,9 @@ def repository_consistency() -> None: Path("docs/storage.md"), Path("docs/recovery.md"), Path("docs/service-contract.md"), + Path("docs/motion-preview-clips.md"), + Path("glitchcraft/contracts/motion_preview.py"), + Path("glitchcraft/jobs/motion_preview.py"), Path("glitchcraft/storage/contracts.py"), Path("glitchcraft/storage/repository.py"), Path("glitchcraft/version.py"), diff --git a/docs/architecture.md b/docs/architecture.md index 05e0bb8..151962d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,3 +78,13 @@ from selection and feeds the existing Recipe v2 `enabled` field. Metadata loads startup, resolver requests are isolated per effect, and selection causes no API traffic. Preview and schedule controllers retain their existing revision guards. A failed preview keeps the previous object URL until a successful replacement is available. + +# Runtime motion-preview boundary + +Motion-preview clips are a separate runtime-only job domain. The factory owns a +bounded `MotionPreviewClipManager`, but package import starts no worker. Its +queue, active work, terminal snapshots, TTL/LRU cache, media leases, and H.264 +files are outside the persistent repository and manifest. Recipe v2 compiles +once against the complete source timeline; verified seeking and absolute +source-frame indexes preserve equivalence with still preview and full +rendering. Shutdown cancels work and deletes every preview artifact. diff --git a/docs/interface-system.md b/docs/interface-system.md index dfb681c..d8c2549 100644 --- a/docs/interface-system.md +++ b/docs/interface-system.md @@ -62,3 +62,18 @@ Focus rings remain visible, hidden panels are inert, icon-free buttons use expli labels, reduced motion is honored, and forced-colors mode receives simplified selection and slider treatments. Screenshot and geometry review cover 1440 x 900, 1280 x 720, 1024 x 768, 768 x 1024, 390 x 844, and 360 x 800. + +## Still and motion preview roles + +The media surface exposes a semantic Still/Motion tablist. Still is selected +initially. Motion adds one bounded, aspect-preserving native video player plus +compact before/after controls, anchor/total context, explicit create/update, +contextual cancel, and progress details. + +Motion never auto-renders from sliders or navigation. A changed source, +timestamp, recipe, window, or seed marks the last ready clip stale and keeps it +playable. Selection, disclosure, and mobile navigation are not recipe changes. +Empty, queued, processing, finalizing, verifying, ready, stale, failed, +canceled, and playback-error states retain stable geometry, text labels, +visible focus, reduced-motion behavior, and a phase-throttled polite live +region. diff --git a/docs/motion-preview-clips.md b/docs/motion-preview-clips.md new file mode 100644 index 0000000..8088c7b --- /dev/null +++ b/docs/motion-preview-clips.md @@ -0,0 +1,110 @@ +# Motion-preview clips + +GlitchCraft 0.4.0 adds short, silent motion previews without turning previews +into persistent outputs or full renders. Still preview remains the default. A +user explicitly switches to Motion, chooses a before/after window, and selects +Preview motion or Update motion. + +## Contract and exact timeline + +`POST /api/video-sources/{sourceId}/preview-clips` accepts strict motion-preview +contract v1: + +- `contractVersion` must be `1`; +- `recipe` may be Recipe v1 or Recipe v2; +- `timestampSeconds` is a finite nonnegative anchor; +- `beforeSeconds` defaults to `1` and `afterSeconds` defaults to `2`; +- their combined duration is from `0.5` through `5` seconds; and +- unknown keys, client paths, output settings, and audio settings are rejected. + +The source window is derived on the full source timeline. Its inclusive start is +`floor((anchor - before) × source rate)` and its exclusive end is +`ceil((anchor + after) × source rate)`, clipped at source boundaries without +shifting the other edge. At least two source frames must remain. + +Recipe v2 is compiled once against the complete source timeline. Frames are +decoded and effects are evaluated with absolute source-frame indexes, before +downscaling. This makes the clip agree with still preview and full processing +for activation, envelopes, seeded schedules, and named variation. Recipe v1 +also receives the absolute source-frame index. + +Seeking is verified by frame position. A decoder that lands early is advanced +one frame at a time; one that lands late is retried from bounded earlier +positions. GlitchCraft fails with `inexact_seek` if it cannot prove alignment. +OpenCV can normalize variable-frame-rate media, so frame-exact behavior is +defined against the probed rational timeline and decoded frame order, not every +container presentation timestamp. + +## Bounded output profile + +Preview intermediates use source frame rate and contain only the selected +frames. The final MP4 is H.264, `yuv420p`, fast-start, and has no audio. Output +is never upscaled, preserves aspect ratio, uses even dimensions, fits within +960 × 720, and is capped at 30 fps. Verification rejects empty, oversized, +wrong-codec, wrong-pixel-format, audio-bearing, over-rate, over-dimension, or +materially wrong-duration results. + +The API exposes status separately from media: + +- `GET /api/motion-preview-clips/{clipId}` returns redacted state and telemetry; +- `POST /api/motion-preview-clips/{clipId}/cancel` requests cancellation; +- `DELETE /api/motion-preview-clips/{clipId}` cancels and forgets it; and +- `GET` or `HEAD /api/motion-preview-clips/{clipId}/media` streams ready MP4, + including one bounded byte range. + +Status and media responses use `Cache-Control: no-store`. Public responses do +not expose filesystem paths, executable details, stderr, thread names, or cache +keys. + +## Runtime lifecycle and cache + +The lifecycle is: + +`queued → preparing → processing → finalizing → verifying → ready` + +`failed`, `canceled`, and `expired` are terminal. Cancellation is polled during +seek, every processed frame, and FFmpeg finalization. Creating the same +canonical source/recipe/window/profile request deduplicates active work and +reuses a ready entry. + +All clip state and bytes are runtime-only under +`temporary/motion-preview-clips`. They never enter `manifest.json`, never create +an output record, and are requested for deletion on application shutdown. +Unleased files are removed immediately; an active streaming lease removes its +file when the response closes, with the next startup as the final cleanup +boundary after an abrupt exit. Construction removes abandoned files from a +previous process. The cache defaults to 15-minute TTL, eight ready entries, and +512 MiB. Ready entries are LRU-evicted by count or bytes. Streaming leases defer +deletion until the response closes. Deleting a source invalidates its queued, +active, and ready previews. + +The manager defaults to one worker and a three-item waiting queue. This is a +separate bounded coordinator from persistent rendering; on a small local +machine, a preview and full render may still compete for CPU and disk. Increasing +preview concurrency is supported as an operator setting but is not an automatic +resource scheduler. + +## Interface behavior + +Motion creation is explicit—timestamp, window, seed, recipe, or source edits +only mark an existing clip stale. The previous ready clip remains playable +while an update runs or fails. Effect selection, inspector disclosure, and +responsive navigation do not mark it stale or make network requests. + +The player is muted, looping, inline, and uses native controls. Queued through +verifying phases show concise progress; Cancel is available only while active. +Ready, stale, failed, canceled, and playback-error states remain visible as text +and do not rely on color. Polling has one request and one timer, uses bounded +backoff, aborts obsolete revisions, and stops for terminal states or reset. + +Audio preview, persistent preview history, WebSockets, arbitrary clip export, +hardware encoding, presentation-timestamp remapping, shared CPU admission with +full jobs, and a draggable timeline remain deferred. + +Full renders remain authoritative. Motion previews are lower-resolution review +artifacts, do not survive restart, do not preserve audio, and never become +library outputs. Current operations are independent decoded-frame effects. +Future stateful feedback, echo, trails, or optical-flow operations would require +a separately specified preroll/state-warmup contract before a mid-source clip +could claim the same equivalence. Timeline editing and codec datamoshing remain +separate deferred domains. diff --git a/docs/product-direction.md b/docs/product-direction.md index 5b94b43..56c681a 100644 --- a/docs/product-direction.md +++ b/docs/product-direction.md @@ -30,9 +30,10 @@ scaling, and coherent named variation to those existing effects. The compiled schedule API is a stable base for a future React timeline. Stateful temporal feedback, optical flow, frame reordering, codec datamosh, -signal modeling, new effect algorithms, draggable keyframes, and preview clips -remain intentionally deferred. Temporal modulation changes independent decoded -frames and does not use prior-frame state. +signal modeling, new effect algorithms, draggable keyframes, audio previews, +and persistent preview history remain intentionally deferred. Temporal +modulation changes independent decoded frames and does not use prior-frame +state. A future orchestration dashboard may discover and check GlitchCraft, ColorCraft, and Web Video Optimizer through related contracts. It is not implemented here @@ -45,3 +46,13 @@ contracts. It is a focused control-resolution layer presented through an effects media-first preview, selected-effect inspector, and render dock in the current Flask and JavaScript workspace. A full timeline, draggable events, manual-event UI, presets, React, stateful temporal feedback, optical flow, and codec datamoshing remain future work. + +# Motion-preview milestone + +Version 0.4.0 adds explicit, short motion-preview clips as a bounded review tool. +They are silent runtime artifacts, not saved outputs, and retain the last valid +clip while settings become stale or an update fails. Stateful temporal +feedback, optical flow, frame reordering, codec datamosh, signal modeling, new +effect algorithms, draggable keyframes, persistent preview history, audio +preview, arbitrary preview export, and shared global resource scheduling remain +deferred. diff --git a/docs/service-contract.md b/docs/service-contract.md index 0f4bb14..c45b854 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -71,3 +71,18 @@ timeline editing. Stable capability slugs are `progressive-effect-controls`, `basic-effect-intensity`, `effect-burst-range`, and `effect-pattern-regeneration`. + +# Motion-preview discovery + +Application version 0.4.0 adds motion-preview contract v1 and capability slugs +`motion-preview-clips`, `cancellable-motion-previews`, and +`ephemeral-preview-cache`. `/metadata` reports strict duration, output +profile, cache, queue, and endpoint bounds. `/ready` reports the preview manager +independently; a full preview queue does not make image work globally degraded. +Tool absence makes the capability unavailable without changing its implemented +status. + +Motion-preview status and media are runtime-only and use `no-store`. Discovery +does not expose cache keys, local paths, process output, or worker identities. +See [motion-preview-clips.md](motion-preview-clips.md) for the endpoint and +lifecycle contract. diff --git a/docs/storage.md b/docs/storage.md index eb78efd..8ffa1fb 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -70,3 +70,13 @@ migrated or rewritten. Recipe v2 jobs and outputs retain timing, envelope, intensity, and variation configuration, but not compiled events or runtime RNG state. Schedule compilation after restart is deterministic and does not add per-frame persistence. + +# Runtime-only preview storage + +Motion-preview clips are not manifest storage. Their identifiers, states, cache +keys, telemetry, and media exist only in the owning process under +`temporary/motion-preview-clips`. Startup clears abandoned files; shutdown +clears current files. TTL and LRU enforce time, count, and byte limits, and +media leases prevent deletion during an active stream. A successful source +deletion invalidates associated preview work. Creation and eviction never write +the manifest, create output records, or alter persistent storage counts. diff --git a/docs/temporal-effects.md b/docs/temporal-effects.md index ca90a55..2f3a87b 100644 --- a/docs/temporal-effects.md +++ b/docs/temporal-effects.md @@ -119,7 +119,8 @@ stale responses. The previous valid still preview stays visible while updating. Image mode stays Recipe v1. This remains a transitional Flask/JavaScript interface. It has no draggable -timeline or complete manual-event editor, and preview remains a still frame. +timeline or complete manual-event editor. Still preview remains the default; +explicit bounded motion preview adds a short runtime-only review interval. Future React timeline work can consume the schedule endpoint without changing the recipe or compiler. @@ -135,3 +136,13 @@ The Basic control resolver selects existing continuous or sporadic timing, envel variation contracts without changing compilation. Exact schedules remain authoritative and unchanged for identical canonical Recipe v2 fields and seeds. Basic burst bounds are exact frames; the existing isolated RNG namespace selects deterministic event durations. + +# Motion-preview equivalence + +Motion-preview clips extend one-frame equivalence to a bounded source interval. +The window does not create a local timeline: Recipe v2 compiles once using full +source duration and total frames, and every operation receives the absolute +decoded source-frame index. Effects run at source resolution before the result +is downscaled and capped for delivery. Starting in the middle of a burst +therefore preserves event identity, envelope position, and variation values. +Recipe v1 also retains its absolute seeded frame behavior. diff --git a/docs/testing.md b/docs/testing.md index 1baa078..63ddeb0 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -127,3 +127,38 @@ On the PR development machine, 80,000 individual resolutions completed in 4.454 80,000 inferences completed in 20.133 seconds (251.658 µs per effect; 2.013 ms per batch of eight). Resolution performs no media decoding, FFmpeg work, or schedule compilation, and resolved recipes add no render-time overhead. + +# Motion-preview validation + +Run the focused Python and browser coverage with: + +```powershell +python -m pytest tests/test_motion_preview.py +npm run test:browser +npm run review:ui-screens +``` + +Motion-preview tests cover strict v1 validation, fractional frame boundaries, +verified exact seek, absolute source-frame execution, no-upscale geometry, +active/ready deduplication, queue capacity, cancellation, TTL, count/byte LRU +eviction, streaming leases, source invalidation, startup/shutdown cleanup, +no-manifest mutation, API redaction, byte ranges, capability metadata, and all +required interface states. Marked real-media coverage verifies the bounded +silent H.264 profile with FFmpeg and FFprobe when installed. + +The deterministic UI review now generates 24 captures, adding motion empty, +queued, processing, ready, stale, failed, and canceled states across desktop, +medium, tablet, and mobile layouts. Every capture is manually inspected for +containment, hierarchy, readable status, and stable previous-media retention. + +On the PR development machine, a representative no-effect three-second window +from a 1280 × 720, 30 fps source completed in 1.340 seconds; the first processed +frame was observed at 0.069 seconds. Ten thousand canonical cache-key builds +averaged 23.81 microseconds, ready lookups 18.80 microseconds, initial admission +337.10 microseconds, and a ready-cache submission 112.10 microseconds. One +actual ready-entry eviction took 218.90 microseconds. The redacted ready +snapshot serialized to 932 bytes and the test clip to 34,297 bytes. These are +representative diagnostics, not cross-platform timing guarantees. Bounds on +queue, workers, clip duration, dimensions, FPS, stderr, snapshots, terminal +history, cache entries, cache bytes, TTL, and media leases are the enforced +requirements. diff --git a/docs/video-jobs.md b/docs/video-jobs.md index 93ee923..3d3aeb3 100644 --- a/docs/video-jobs.md +++ b/docs/video-jobs.md @@ -57,3 +57,17 @@ The concise telemetry phrase continues counting enabled instances, not active effects on one scheduled frame. Event-level changes do not create milestones or manifest writes. Individual job and output metadata expose `recipeVersion` so clients can distinguish legacy-continuous work from temporal work. + +## Runtime motion-preview lifecycle + +Motion previews do not use persistent video-job records. Their independent +bounded lifecycle is `queued → preparing → processing → finalizing → verifying +→ ready`, with `failed`, `canceled`, and `expired` terminal states. The default +coordinator has one worker and three waiting slots. Active requests deduplicate +by a canonical source/recipe/window/profile key; ready requests additionally +become cache hits. + +Cancellation is cooperative during exact seek and frame processing and +terminates FFmpeg during finalization. Shutdown cancels all work and deletes all +ready outputs. This independent coordinator guarantees bounded preview work but +does not claim global CPU fairness with a simultaneous persistent render. diff --git a/docs/video-workflow.md b/docs/video-workflow.md index 9b9972c..1dc1adc 100644 --- a/docs/video-workflow.md +++ b/docs/video-workflow.md @@ -78,3 +78,16 @@ Regenerating changes the shared root seed, not the source or effect fields. Sche still-preview requests remain abortable and revision guarded; the previous valid preview and schedule remain visible while replacements arrive. Jump-to-next uses inspected events and does not change the seed or schedule. + +# Still and motion preview + +The media card has explicit Still and Motion roles. Still remains the default +and retains the existing debounced single-frame workflow. Motion uses a bounded +before/after window and starts only when the user selects Preview motion or +Update motion. It submits a strict runtime-only clip request, polls redacted +status, and plays the ready silent MP4 directly. + +Recipe, timestamp, window, seed, and source changes mark the clip stale without +implicit processing; the previous ready clip remains available. See +[motion-preview-clips.md](motion-preview-clips.md) for frame conversion, +seeking, output, cache, and lifecycle contracts. diff --git a/glitchcraft/application.py b/glitchcraft/application.py index 4a9ee16..bcc02f5 100644 --- a/glitchcraft/application.py +++ b/glitchcraft/application.py @@ -7,6 +7,7 @@ from flask import Flask from glitchcraft.jobs.manager import VideoJobManager +from glitchcraft.jobs.motion_preview import MotionPreviewClipManager from glitchcraft.storage.repository import MediaAssetRepository from glitchcraft.tasks import TaskStore from glitchcraft.version import APP_VERSION, MANIFEST_SCHEMA_VERSION @@ -44,6 +45,20 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: VIDEO_JOB_CONCURRENCY=1, VIDEO_JOB_MAX_ATTEMPTS=2, VIDEO_JOB_AUTOSTART=True, + MOTION_PREVIEW_FOLDER=os.environ.get( + "GLITCHCRAFT_MOTION_PREVIEW_FOLDER", + str(data_root / "temporary" / "motion-preview-clips"), + ), + MOTION_PREVIEW_QUEUE_CAPACITY=3, + MOTION_PREVIEW_CONCURRENCY=1, + MOTION_PREVIEW_TTL_SECONDS=900, + MOTION_PREVIEW_MAX_READY_CLIPS=8, + MOTION_PREVIEW_MAX_CACHE_BYTES=536_870_912, + MOTION_PREVIEW_MAX_DURATION_SECONDS=5, + MOTION_PREVIEW_MAX_OUTPUT_WIDTH=960, + MOTION_PREVIEW_MAX_OUTPUT_HEIGHT=720, + MOTION_PREVIEW_MAX_OUTPUT_FPS=30, + MOTION_PREVIEW_AUTOSTART=True, ) if config: app.config.update(config) @@ -52,7 +67,15 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: app.config["MANIFEST_PATH"] = str(data_root / "manifest.json") if config and "DATA_ROOT" in config and "TEMPORARY_FOLDER" not in config: app.config["TEMPORARY_FOLDER"] = str(data_root / "temporary") - for key in ("UPLOAD_FOLDER", "OUTPUT_FOLDER", "PREVIEW_FOLDER", "TEMPORARY_FOLDER"): + if config and "DATA_ROOT" in config and "MOTION_PREVIEW_FOLDER" not in config: + app.config["MOTION_PREVIEW_FOLDER"] = str(data_root / "temporary" / "motion-preview-clips") + for key in ( + "UPLOAD_FOLDER", + "OUTPUT_FOLDER", + "PREVIEW_FOLDER", + "TEMPORARY_FOLDER", + "MOTION_PREVIEW_FOLDER", + ): Path(app.config[key]).mkdir(parents=True, exist_ok=True) app.extensions["task_store"] = TaskStore() repository = MediaAssetRepository( @@ -72,5 +95,21 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: app.extensions["video_job_manager"] = manager if app.config["VIDEO_JOB_AUTOSTART"] and not app.config.get("TESTING"): manager.start() + preview_manager = MotionPreviewClipManager( + repository, + runtime_folder=Path(app.config["MOTION_PREVIEW_FOLDER"]), + capacity=int(app.config["MOTION_PREVIEW_QUEUE_CAPACITY"]), + concurrency=int(app.config["MOTION_PREVIEW_CONCURRENCY"]), + ttl_seconds=float(app.config["MOTION_PREVIEW_TTL_SECONDS"]), + maximum_ready_clips=int(app.config["MOTION_PREVIEW_MAX_READY_CLIPS"]), + maximum_cache_bytes=int(app.config["MOTION_PREVIEW_MAX_CACHE_BYTES"]), + maximum_duration_seconds=float(app.config["MOTION_PREVIEW_MAX_DURATION_SECONDS"]), + maximum_output_width=int(app.config["MOTION_PREVIEW_MAX_OUTPUT_WIDTH"]), + maximum_output_height=int(app.config["MOTION_PREVIEW_MAX_OUTPUT_HEIGHT"]), + maximum_output_fps=int(app.config["MOTION_PREVIEW_MAX_OUTPUT_FPS"]), + ) + app.extensions["motion_preview_manager"] = preview_manager + if app.config["MOTION_PREVIEW_AUTOSTART"] and not app.config.get("TESTING"): + preview_manager.start() app.register_blueprint(bp) return app diff --git a/glitchcraft/contracts/__init__.py b/glitchcraft/contracts/__init__.py index c69d383..a0c59c3 100644 --- a/glitchcraft/contracts/__init__.py +++ b/glitchcraft/contracts/__init__.py @@ -1,5 +1,19 @@ -"""Typed processing contracts.""" +"""Versioned GlitchCraft processing and request contracts.""" from glitchcraft.contracts.effects import EffectInstance, EffectType, Recipe +from glitchcraft.contracts.motion_preview import ( + MOTION_PREVIEW_CLIP_CONTRACT_VERSION, + MotionPreviewClipRequest, + MotionPreviewWindow, + normalize_motion_preview_window, +) -__all__ = ["EffectInstance", "EffectType", "Recipe"] +__all__ = [ + "MOTION_PREVIEW_CLIP_CONTRACT_VERSION", + "EffectInstance", + "EffectType", + "MotionPreviewClipRequest", + "MotionPreviewWindow", + "Recipe", + "normalize_motion_preview_window", +] diff --git a/glitchcraft/contracts/motion_preview.py b/glitchcraft/contracts/motion_preview.py new file mode 100644 index 0000000..4ca4132 --- /dev/null +++ b/glitchcraft/contracts/motion_preview.py @@ -0,0 +1,119 @@ +"""Strict contract and source-window normalization for motion-preview clips.""" + +from __future__ import annotations + +import json +import math +from typing import Annotated, ClassVar, Literal + +from pydantic import ConfigDict, Field, field_validator, model_validator + +from glitchcraft.contracts.effects import ContractModel, RecipeDocument +from glitchcraft.effects.temporal import MediaTimelineContext +from glitchcraft.version import ( + MOTION_PREVIEW_CLIP_CONTRACT_VERSION as MOTION_PREVIEW_CLIP_CONTRACT_VERSION, +) + +MOTION_PREVIEW_MIN_DURATION_SECONDS = 0.5 +MOTION_PREVIEW_MAX_DURATION_SECONDS = 5.0 + + +class MotionPreviewClipRequest(ContractModel): + """Versioned user request containing no paths, commands, or output options.""" + + contract_version: Literal[1] = Field(default=1, alias="contractVersion") + recipe: RecipeDocument + timestamp_seconds: Annotated[float, Field(alias="timestampSeconds", ge=0)] + before_seconds: Annotated[float, Field(alias="beforeSeconds", ge=0)] = 1.0 + after_seconds: Annotated[float, Field(alias="afterSeconds", ge=0)] = 2.0 + model_config: ClassVar[ConfigDict] = ConfigDict( + extra="forbid", + validate_assignment=True, + populate_by_name=True, + ) + + @field_validator("timestamp_seconds", "before_seconds", "after_seconds") + @classmethod + def validate_finite(cls, value: float) -> float: + if not math.isfinite(value): + raise ValueError("motion preview timing values must be finite") + return value + + @model_validator(mode="after") + def validate_requested_duration(self) -> MotionPreviewClipRequest: + total = self.before_seconds + self.after_seconds + if total < MOTION_PREVIEW_MIN_DURATION_SECONDS: + raise ValueError( + f"motion preview duration must be at least " + f"{MOTION_PREVIEW_MIN_DURATION_SECONDS} seconds" + ) + if total > MOTION_PREVIEW_MAX_DURATION_SECONDS: + raise ValueError( + f"motion preview duration cannot exceed " + f"{MOTION_PREVIEW_MAX_DURATION_SECONDS:g} seconds" + ) + return self + + def canonical_recipe_json(self) -> str: + """Return the stable recipe representation used by cache-key construction.""" + + return json.dumps( + self.recipe.model_dump(mode="json", by_alias=True), + sort_keys=True, + separators=(",", ":"), + ) + + +class MotionPreviewWindow(ContractModel): + """A frame-exact, full-source window with an exclusive end.""" + + source_start_frame: Annotated[int, Field(alias="sourceStartFrame", ge=0)] + source_end_frame: Annotated[int, Field(alias="sourceEndFrame", ge=1)] + anchor_frame: Annotated[int, Field(alias="anchorFrame", ge=0)] + actual_start_seconds: Annotated[float, Field(alias="actualStartSeconds", ge=0)] + actual_end_seconds: Annotated[float, Field(alias="actualEndSeconds", gt=0)] + anchor_seconds: Annotated[float, Field(alias="anchorSeconds", ge=0)] + model_config: ClassVar[ConfigDict] = ConfigDict( + extra="forbid", + frozen=True, + populate_by_name=True, + ) + + @property + def frame_count(self) -> int: + return self.source_end_frame - self.source_start_frame + + @property + def duration_seconds(self) -> float: + return self.actual_end_seconds - self.actual_start_seconds + + +def normalize_motion_preview_window( + payload: MotionPreviewClipRequest, + timeline: MediaTimelineContext, +) -> MotionPreviewWindow: + """Clip a request to the source without shifting its opposite edge.""" + + if payload.timestamp_seconds >= timeline.duration_seconds: + raise ValueError("timestampSeconds must be inside the source duration") + requested_start = max(0.0, payload.timestamp_seconds - payload.before_seconds) + requested_end = min( + timeline.duration_seconds, + payload.timestamp_seconds + payload.after_seconds, + ) + start_frame = timeline.start_frame(requested_start) + end_frame = timeline.end_frame(requested_end) + if end_frame - start_frame < 2: + raise ValueError("motion preview window must contain at least two source frames") + anchor_frame = min( + end_frame - 1, + max(start_frame, timeline.start_frame(payload.timestamp_seconds)), + ) + return MotionPreviewWindow( + source_start_frame=start_frame, + source_end_frame=end_frame, + anchor_frame=anchor_frame, + actual_start_seconds=timeline.frame_seconds(start_frame), + actual_end_seconds=min(timeline.duration_seconds, timeline.frame_seconds(end_frame)), + anchor_seconds=payload.timestamp_seconds, + ) diff --git a/glitchcraft/errors.py b/glitchcraft/errors.py index 64a26e3..a39ec5b 100644 --- a/glitchcraft/errors.py +++ b/glitchcraft/errors.py @@ -34,5 +34,9 @@ class ProcessingCanceled(GlitchCraftError): """A cooperative media operation was canceled.""" +class MotionPreviewSeekError(MediaReadError): + """A decoder could not be aligned to the requested absolute source frame.""" + + class QueueCapacityError(GlitchCraftError): """The bounded background queue cannot accept more work.""" diff --git a/glitchcraft/jobs/__init__.py b/glitchcraft/jobs/__init__.py index a26e6b6..309dfe3 100644 --- a/glitchcraft/jobs/__init__.py +++ b/glitchcraft/jobs/__init__.py @@ -1 +1,5 @@ -"""Persistent bounded background video jobs.""" +"""Persistent and runtime-only bounded job managers.""" + +from glitchcraft.jobs.motion_preview import MotionPreviewClipManager + +__all__ = ["MotionPreviewClipManager"] diff --git a/glitchcraft/jobs/motion_preview.py b/glitchcraft/jobs/motion_preview.py new file mode 100644 index 0000000..2d672dd --- /dev/null +++ b/glitchcraft/jobs/motion_preview.py @@ -0,0 +1,773 @@ +"""Bounded runtime-only manager for short motion-preview clips.""" + +from __future__ import annotations + +import hashlib +import json +import logging +import secrets +import shutil +import time +from collections import OrderedDict, deque +from collections.abc import Callable, Iterator +from contextlib import contextmanager, suppress +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from enum import StrEnum +from fractions import Fraction +from pathlib import Path +from threading import Condition, Event, Thread +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from glitchcraft.contracts.effects import RecipeDocument +from glitchcraft.contracts.motion_preview import ( + MOTION_PREVIEW_CLIP_CONTRACT_VERSION, + MotionPreviewClipRequest, + MotionPreviewWindow, +) +from glitchcraft.effects.temporal import MediaTimelineContext +from glitchcraft.errors import ProcessingCanceled, QueueCapacityError +from glitchcraft.media.ffmpeg import finalize_motion_preview +from glitchcraft.media.probe import VideoMetadata, probe_video +from glitchcraft.media.video import ClipFrameProgress, process_video_clip +from glitchcraft.storage.contracts import VideoSourceRecord +from glitchcraft.storage.repository import MediaAssetRepository + +logger = logging.getLogger(__name__) +MOTION_PREVIEW_IMPLEMENTATION_VERSION = 1 +TERMINAL_HISTORY_LIMIT = 64 + + +class MotionPreviewState(StrEnum): + QUEUED = "queued" + PREPARING = "preparing" + PROCESSING = "processing" + FINALIZING = "finalizing" + VERIFYING = "verifying" + READY = "ready" + FAILED = "failed" + CANCELED = "canceled" + EXPIRED = "expired" + + @property + def terminal(self) -> bool: + return self in { + MotionPreviewState.READY, + MotionPreviewState.FAILED, + MotionPreviewState.CANCELED, + MotionPreviewState.EXPIRED, + } + + +class MotionPreviewSnapshot(BaseModel): + """Caller-independent runtime state without paths or process diagnostics.""" + + model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True) + + preview_clip_id: str = Field(alias="previewClipId") + source_id: str = Field(alias="sourceId") + state: MotionPreviewState + phase: str + progress: int = Field(ge=0, le=100) + frames_processed: int = Field(alias="framesProcessed", ge=0) + total_frames: int = Field(alias="totalFrames", ge=2) + current_source_frame: int | None = Field(alias="currentSourceFrame", default=None) + source_start_frame: int = Field(alias="sourceStartFrame", ge=0) + source_end_frame: int = Field(alias="sourceEndFrame", ge=1) + actual_start_seconds: float = Field(alias="actualStartSeconds", ge=0) + actual_end_seconds: float = Field(alias="actualEndSeconds", gt=0) + anchor_seconds: float = Field(alias="anchorSeconds", ge=0) + current_frames_per_second: float | None = Field(alias="currentFramesPerSecond", default=None) + average_frames_per_second: float | None = Field(alias="averageFramesPerSecond", default=None) + elapsed_seconds: float = Field(alias="elapsedSeconds", ge=0) + estimated_remaining_seconds: float | None = Field( + alias="estimatedRemainingSeconds", default=None + ) + finalization_encoded_seconds: float | None = Field( + alias="finalizationEncodedSeconds", default=None + ) + cache_hit: bool = Field(alias="cacheHit") + created_at: datetime = Field(alias="createdAt") + updated_at: datetime = Field(alias="updatedAt") + media_url: str | None = Field(alias="mediaUrl", default=None) + width: int | None = None + height: int | None = None + frame_rate: str | None = Field(alias="frameRate", default=None) + duration_seconds: float | None = Field(alias="durationSeconds", default=None) + frame_count: int | None = Field(alias="frameCount", default=None) + file_size: int | None = Field(alias="fileSize", default=None) + video_codec: str | None = Field(alias="videoCodec", default=None) + pixel_format: str | None = Field(alias="pixelFormat", default=None) + has_audio: bool | None = Field(alias="hasAudio", default=None) + expires_at: datetime | None = Field(alias="expiresAt", default=None) + recipe_version: int = Field(alias="recipeVersion") + seed: int + error: dict[str, str] | None = None + + +@dataclass +class _MotionPreviewJob: + id: str + cache_key: str + source_id: str + recipe: RecipeDocument + window: MotionPreviewWindow + timeline: MediaTimelineContext + created_at: datetime + created_monotonic: float + updated_at: datetime + state: MotionPreviewState = MotionPreviewState.QUEUED + phase: str = "queued" + progress: int = 0 + frames_processed: int = 0 + current_source_frame: int | None = None + current_fps: float | None = None + average_fps: float | None = None + estimated_remaining: float | None = None + finalization_encoded_seconds: float | None = None + cache_hit: bool = False + cancel_event: Event = field(default_factory=Event) + output_path: Path | None = None + width: int | None = None + height: int | None = None + frame_rate: str | None = None + duration_seconds: float | None = None + frame_count: int | None = None + file_size: int | None = None + video_codec: str | None = None + pixel_format: str | None = None + has_audio: bool | None = None + expires_at: datetime | None = None + expires_monotonic: float | None = None + leases: int = 0 + pending_delete: bool = False + error: dict[str, str] | None = None + + +@dataclass(frozen=True) +class MotionPreviewSubmission: + snapshot: MotionPreviewSnapshot + ready_cache_hit: bool + deduplicated: bool + + +class MotionPreviewClipManager: + """Own preview jobs and media entirely outside the persistent manifest.""" + + def __init__( + self, + repository: MediaAssetRepository, + *, + runtime_folder: Path, + capacity: int = 3, + concurrency: int = 1, + ttl_seconds: float = 900, + maximum_ready_clips: int = 8, + maximum_cache_bytes: int = 536_870_912, + maximum_duration_seconds: float = 5, + maximum_output_width: int = 960, + maximum_output_height: int = 720, + maximum_output_fps: int = 30, + monotonic: Callable[[], float] = time.monotonic, + utcnow: Callable[[], datetime] | None = None, + ) -> None: + if min(capacity, concurrency, maximum_ready_clips, maximum_cache_bytes) <= 0: + raise ValueError("motion preview manager bounds must be positive") + self.repository = repository + self.runtime_folder = runtime_folder.resolve() + self.capacity = capacity + self.concurrency = concurrency + self.ttl_seconds = ttl_seconds + self.maximum_ready_clips = maximum_ready_clips + self.maximum_cache_bytes = maximum_cache_bytes + self.maximum_duration_seconds = maximum_duration_seconds + self.maximum_output_width = maximum_output_width + self.maximum_output_height = maximum_output_height + self.maximum_output_fps = maximum_output_fps + self._monotonic = monotonic + self._utcnow = utcnow or (lambda: datetime.now(UTC)) + self._condition = Condition() + self._jobs: dict[str, _MotionPreviewJob] = {} + self._by_key: dict[str, str] = {} + self._queue: deque[str] = deque() + self._active: set[str] = set() + self._ready_lru: OrderedDict[str, None] = OrderedDict() + self._cache_bytes = 0 + self._workers: list[Thread] = [] + self._stopping = False + self._accepting = True + self.runtime_folder.mkdir(parents=True, exist_ok=True) + self._startup_cleanup() + + def _startup_cleanup(self) -> None: + for child in self.runtime_folder.iterdir(): + try: + if child.is_dir(): + shutil.rmtree(child) + else: + child.unlink(missing_ok=True) + except OSError: + logger.exception("Could not remove abandoned motion-preview path %s", child.name) + + def start(self) -> None: + with self._condition: + if self._workers: + return + self._stopping = False + self._accepting = True + for index in range(self.concurrency): + worker = Thread( + target=self._worker, + name=f"glitchcraft-motion-preview-{index + 1}", + daemon=True, + ) + worker.start() + self._workers.append(worker) + + def _cache_key( + self, + source: VideoSourceRecord, + payload: MotionPreviewClipRequest, + window: MotionPreviewWindow, + ) -> str: + material = { + "contractVersion": MOTION_PREVIEW_CLIP_CONTRACT_VERSION, + "implementationVersion": MOTION_PREVIEW_IMPLEMENTATION_VERSION, + "source": { + "id": source.id, + "storedName": source.stored_name, + "fileSize": source.file_size, + "frameRate": source.frame_rate, + "frameCount": source.frame_count, + "durationSeconds": source.duration_seconds, + "width": source.width, + "height": source.height, + }, + "recipe": json.loads(payload.canonical_recipe_json()), + "sourceStartFrame": window.source_start_frame, + "sourceEndFrame": window.source_end_frame, + "profile": { + "maximumWidth": self.maximum_output_width, + "maximumHeight": self.maximum_output_height, + "maximumFps": self.maximum_output_fps, + }, + } + canonical = json.dumps(material, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(canonical).hexdigest() + + def submit( + self, + source: VideoSourceRecord, + payload: MotionPreviewClipRequest, + window: MotionPreviewWindow, + ) -> MotionPreviewSubmission: + started = self._monotonic() + key = self._cache_key(source, payload, window) + with self._condition: + self._expire_locked() + existing_id = self._by_key.get(key) + existing = self._jobs.get(existing_id) if existing_id is not None else None + if existing is not None and existing.state not in { + MotionPreviewState.FAILED, + MotionPreviewState.CANCELED, + MotionPreviewState.EXPIRED, + }: + ready_hit = existing.state == MotionPreviewState.READY + existing.cache_hit = ready_hit + if ready_hit: + self._touch_ready_locked(existing.id) + return MotionPreviewSubmission( + self._snapshot_locked(existing), + ready_cache_hit=ready_hit, + deduplicated=True, + ) + if not self._accepting or self._stopping: + raise RuntimeError("The motion preview manager is stopped.") + if len(self._queue) >= self.capacity: + raise QueueCapacityError("The motion preview queue is full.") + now = self._utcnow() + clip_id = secrets.token_urlsafe(24) + timeline = MediaTimelineContext.from_source( + frame_rate=source.frame_rate, + duration_seconds=source.duration_seconds, + total_frames=source.frame_count, + ) + job = _MotionPreviewJob( + id=clip_id, + cache_key=key, + source_id=source.id, + recipe=payload.recipe, + window=window, + timeline=timeline, + created_at=now, + created_monotonic=started, + updated_at=now, + ) + self._jobs[clip_id] = job + self._by_key[key] = clip_id + self._queue.append(clip_id) + self._trim_history_locked() + self._condition.notify() + return MotionPreviewSubmission( + self._snapshot_locked(job), + ready_cache_hit=False, + deduplicated=False, + ) + + def get(self, clip_id: str) -> MotionPreviewSnapshot: + with self._condition: + self._expire_locked() + job = self._jobs.get(clip_id) + if job is None or job.state == MotionPreviewState.EXPIRED: + raise LookupError("Unknown or expired motion preview.") + if job.state == MotionPreviewState.READY: + self._touch_ready_locked(job.id) + return self._snapshot_locked(job) + + def cancel(self, clip_id: str) -> MotionPreviewSnapshot: + with self._condition: + self._expire_locked() + job = self._jobs.get(clip_id) + if job is None: + raise LookupError("Unknown motion preview.") + if job.state.terminal: + raise ValueError("The motion preview is already terminal.") + job.cancel_event.set() + if job.state == MotionPreviewState.QUEUED: + with suppress(ValueError): + self._queue.remove(job.id) + self._transition_locked( + job, + MotionPreviewState.CANCELED, + "canceled", + job.progress, + ) + self._by_key.pop(job.cache_key, None) + self._condition.notify_all() + return self._snapshot_locked(job) + + def delete(self, clip_id: str) -> None: + with self._condition: + job = self._jobs.get(clip_id) + if job is None: + raise LookupError("Unknown motion preview.") + job.cancel_event.set() + with suppress(ValueError): + self._queue.remove(job.id) + self._remove_ready_locked(job) + self._by_key.pop(job.cache_key, None) + self._jobs.pop(job.id, None) + self._delete_output_locked(job) + self._condition.notify_all() + + def invalidate_source(self, source_id: str) -> int: + with self._condition: + jobs = [job for job in self._jobs.values() if job.source_id == source_id] + for job in jobs: + job.cancel_event.set() + with suppress(ValueError): + self._queue.remove(job.id) + self._remove_ready_locked(job) + self._by_key.pop(job.cache_key, None) + if job.id not in self._active: + self._jobs.pop(job.id, None) + self._delete_output_locked(job) + self._condition.notify_all() + return len(jobs) + + @contextmanager + def lease_media(self, clip_id: str) -> Iterator[Path]: + with self._condition: + self._expire_locked() + job = self._jobs.get(clip_id) + if ( + job is None + or job.state != MotionPreviewState.READY + or job.output_path is None + or not job.output_path.is_file() + ): + raise LookupError("Motion preview media is not ready.") + job.leases += 1 + self._touch_ready_locked(job.id) + path = job.output_path + try: + yield path + finally: + with self._condition: + job.leases = max(0, job.leases - 1) + if job.leases == 0 and job.pending_delete: + self._unlink_output(job) + self._evict_ready_locked() + + def status(self) -> dict[str, int | float | bool]: + with self._condition: + self._expire_locked() + return { + "capacity": self.capacity, + "queued": len(self._queue), + "concurrency": self.concurrency, + "workers": len(self._workers), + "active": len(self._active), + "running": bool(self._workers) and not self._stopping, + "readyCacheCount": len(self._ready_lru), + "cacheBytes": self._cache_bytes, + "cacheEntryLimit": self.maximum_ready_clips, + "cacheByteLimit": self.maximum_cache_bytes, + "ttlSeconds": self.ttl_seconds, + } + + def shutdown(self, *, timeout: float = 5) -> None: + with self._condition: + self._accepting = False + self._stopping = True + for job in self._jobs.values(): + job.cancel_event.set() + if job.state == MotionPreviewState.QUEUED: + self._transition_locked( + job, + MotionPreviewState.CANCELED, + "canceled", + job.progress, + ) + self._queue.clear() + self._condition.notify_all() + workers = list(self._workers) + deadline = self._monotonic() + max(0, timeout) + for worker in workers: + worker.join(max(0, deadline - self._monotonic())) + with self._condition: + self._workers.clear() + for job in self._jobs.values(): + self._delete_output_locked(job) + self._jobs.clear() + self._by_key.clear() + self._ready_lru.clear() + self._cache_bytes = 0 + + def _worker(self) -> None: + while True: + with self._condition: + while not self._queue and not self._stopping: + self._condition.wait() + if self._stopping: + return + clip_id = self._queue.popleft() + job = self._jobs.get(clip_id) + if job is None or job.cancel_event.is_set(): + continue + self._active.add(clip_id) + try: + self._run(job) + finally: + with self._condition: + self._active.discard(clip_id) + if job.pending_delete and job.leases == 0: + self._unlink_output(job) + + def _run(self, job: _MotionPreviewJob) -> None: + intermediate = self.runtime_folder / f"{job.id}.intermediate.mp4" + output = self.runtime_folder / f"{job.id}.mp4" + published = False + try: + self._transition(job, MotionPreviewState.PREPARING, "seeking", 1) + with self.repository.lease_video_source(job.source_id) as (_source, source_path): + + def canceled() -> bool: + return job.cancel_event.is_set() or self._stopping + + def frame_progress(snapshot: ClipFrameProgress) -> None: + with self._condition: + elapsed = max(0.000001, self._monotonic() - job.created_monotonic) + job.frames_processed = snapshot.frames_processed + job.current_source_frame = snapshot.current_source_frame + job.average_fps = snapshot.frames_processed / elapsed + job.current_fps = job.average_fps + remaining = snapshot.total_frames - snapshot.frames_processed + job.estimated_remaining = ( + remaining / job.average_fps if job.average_fps > 0 else None + ) + job.progress = min( + 84, + 5 + int(79 * snapshot.frames_processed / snapshot.total_frames), + ) + job.phase = "applying_effects" + job.state = MotionPreviewState.PROCESSING + job.updated_at = self._utcnow() + + process_video_clip( + source_path, + intermediate, + job.recipe, + timeline_context=job.timeline, + start_frame=job.window.source_start_frame, + end_frame=job.window.source_end_frame, + maximum_width=self.maximum_output_width, + maximum_height=self.maximum_output_height, + progress_hook=frame_progress, + cancellation_check=canceled, + ) + self._transition(job, MotionPreviewState.FINALIZING, "finalizing_mp4", 85) + output_rate = min( + job.timeline.frame_rate, + Fraction(self.maximum_output_fps, 1), + ) + + def finalization_progress(snapshot: Any) -> None: + with self._condition: + job.finalization_encoded_seconds = snapshot.encoded_duration_seconds + if snapshot.encoded_duration_seconds is not None: + ratio = min( + 1.0, + snapshot.encoded_duration_seconds + / max(job.window.duration_seconds, 0.001), + ) + job.progress = 85 + int(12 * ratio) + job.updated_at = self._utcnow() + + finalize_motion_preview( + intermediate, + output, + output_frame_rate=(f"{output_rate.numerator}/{output_rate.denominator}"), + cancellation_check=canceled, + progress_hook=finalization_progress, + ) + if canceled(): + raise ProcessingCanceled("Motion preview processing was canceled.") + self._transition(job, MotionPreviewState.VERIFYING, "verifying_output", 98) + metadata = probe_video(output) + self._verify_output(job, output, metadata, output_rate) + size = output.stat().st_size + with self._condition: + if canceled(): + raise ProcessingCanceled("Motion preview processing was canceled.") + job.output_path = output + job.width = metadata.width + job.height = metadata.height + job.frame_rate = metadata.frame_rate + job.duration_seconds = metadata.duration_seconds + job.frame_count = metadata.frame_count + job.file_size = size + job.video_codec = metadata.video_codec + job.pixel_format = metadata.pixel_format + job.has_audio = metadata.has_audio + job.expires_monotonic = self._monotonic() + self.ttl_seconds + job.expires_at = self._utcnow() + timedelta(seconds=self.ttl_seconds) + self._transition_locked( + job, + MotionPreviewState.READY, + "ready", + 100, + ) + self._ready_lru[job.id] = None + self._cache_bytes += size + self._evict_ready_locked() + published = True + except ProcessingCanceled: + with self._condition: + if job.id in self._jobs: + self._transition_locked( + job, + MotionPreviewState.CANCELED, + "canceled", + job.progress, + ) + self._by_key.pop(job.cache_key, None) + except Exception as exc: + logger.exception("Motion preview %s failed", job.id) + with self._condition: + if job.id in self._jobs: + job.error = { + "code": ( + "inexact_seek" + if exc.__class__.__name__ == "MotionPreviewSeekError" + else "motion_preview_failed" + ), + "message": ( + str(exc) + if exc.__class__.__name__ == "MotionPreviewSeekError" + else "Motion preview processing failed." + ), + } + self._transition_locked( + job, + MotionPreviewState.FAILED, + "failed", + job.progress, + ) + self._by_key.pop(job.cache_key, None) + finally: + intermediate.unlink(missing_ok=True) + if not published: + output.unlink(missing_ok=True) + + def _verify_output( + self, + job: _MotionPreviewJob, + output: Path, + metadata: VideoMetadata, + expected_rate: Fraction, + ) -> None: + size = output.stat().st_size + actual_rate = Fraction(metadata.frame_rate) + tolerance = 2 / float(expected_rate) + if ( + size <= 0 + or size > self.maximum_cache_bytes + or output.suffix.lower() != ".mp4" + or metadata.container not in {"mov", "mp4"} + or metadata.video_codec != "h264" + or metadata.pixel_format != "yuv420p" + or metadata.has_audio + or metadata.width > self.maximum_output_width + or metadata.height > self.maximum_output_height + or actual_rate > self.maximum_output_fps + or metadata.duration_seconds <= 0 + or abs(metadata.duration_seconds - job.window.duration_seconds) > tolerance + ): + raise RuntimeError("The motion preview does not match the bounded output profile.") + + def _transition( + self, + job: _MotionPreviewJob, + state: MotionPreviewState, + phase: str, + progress: int, + ) -> None: + with self._condition: + self._transition_locked(job, state, phase, progress) + + def _transition_locked( + self, + job: _MotionPreviewJob, + state: MotionPreviewState, + phase: str, + progress: int, + ) -> None: + job.state = state + job.phase = phase + job.progress = min(100, max(job.progress, progress)) + job.updated_at = self._utcnow() + + def _snapshot_locked(self, job: _MotionPreviewJob) -> MotionPreviewSnapshot: + elapsed = max(0.0, self._monotonic() - job.created_monotonic) + return MotionPreviewSnapshot( + preview_clip_id=job.id, + source_id=job.source_id, + state=job.state, + phase=job.phase, + progress=job.progress, + frames_processed=job.frames_processed, + total_frames=job.window.frame_count, + current_source_frame=job.current_source_frame, + source_start_frame=job.window.source_start_frame, + source_end_frame=job.window.source_end_frame, + actual_start_seconds=job.window.actual_start_seconds, + actual_end_seconds=job.window.actual_end_seconds, + anchor_seconds=job.window.anchor_seconds, + current_frames_per_second=job.current_fps, + average_frames_per_second=job.average_fps, + elapsed_seconds=elapsed, + estimated_remaining_seconds=job.estimated_remaining, + finalization_encoded_seconds=job.finalization_encoded_seconds, + cache_hit=job.cache_hit, + created_at=job.created_at, + updated_at=job.updated_at, + media_url=( + f"/api/motion-preview-clips/{job.id}/media" + if job.state == MotionPreviewState.READY + else None + ), + width=job.width, + height=job.height, + frame_rate=job.frame_rate, + duration_seconds=job.duration_seconds, + frame_count=job.frame_count, + file_size=job.file_size, + video_codec=job.video_codec, + pixel_format=job.pixel_format, + has_audio=job.has_audio, + expires_at=job.expires_at, + recipe_version=job.recipe.schema_version, + seed=job.recipe.seed, + error=job.error.copy() if job.error else None, + ) + + def _touch_ready_locked(self, clip_id: str) -> None: + if clip_id in self._ready_lru: + self._ready_lru.move_to_end(clip_id) + + def _remove_ready_locked(self, job: _MotionPreviewJob) -> None: + was_ready = job.id in self._ready_lru + self._ready_lru.pop(job.id, None) + if was_ready and job.file_size: + self._cache_bytes = max(0, self._cache_bytes - job.file_size) + + def _delete_output_locked(self, job: _MotionPreviewJob) -> None: + if job.leases: + job.pending_delete = True + else: + self._unlink_output(job) + + @staticmethod + def _unlink_output(job: _MotionPreviewJob) -> None: + if job.output_path is not None: + job.output_path.unlink(missing_ok=True) + job.output_path = None + job.pending_delete = False + + def _expire_locked(self) -> None: + now = self._monotonic() + expired = [ + job + for job in self._jobs.values() + if job.state == MotionPreviewState.READY + and job.expires_monotonic is not None + and job.expires_monotonic <= now + ] + for job in expired: + self._remove_ready_locked(job) + self._by_key.pop(job.cache_key, None) + self._transition_locked(job, MotionPreviewState.EXPIRED, "expired", 100) + self._delete_output_locked(job) + + def _evict_ready_locked(self) -> None: + while ( + len(self._ready_lru) > self.maximum_ready_clips + or self._cache_bytes > self.maximum_cache_bytes + ): + candidate: _MotionPreviewJob | None = None + for clip_id in self._ready_lru: + job = self._jobs.get(clip_id) + if job is not None and not job.leases: + candidate = job + break + if candidate is None: + return + self._remove_ready_locked(candidate) + self._by_key.pop(candidate.cache_key, None) + self._transition_locked( + candidate, + MotionPreviewState.EXPIRED, + "expired", + 100, + ) + self._delete_output_locked(candidate) + + def _trim_history_locked(self) -> None: + terminal = [ + job + for job in self._jobs.values() + if job.state + in { + MotionPreviewState.FAILED, + MotionPreviewState.CANCELED, + MotionPreviewState.EXPIRED, + } + and not job.leases + ] + terminal.sort(key=lambda item: item.updated_at) + for job in terminal[:-TERMINAL_HISTORY_LIMIT]: + self._jobs.pop(job.id, None) + self._delete_output_locked(job) diff --git a/glitchcraft/media/ffmpeg.py b/glitchcraft/media/ffmpeg.py index e7b0f4f..c6fc55b 100644 --- a/glitchcraft/media/ffmpeg.py +++ b/glitchcraft/media/ffmpeg.py @@ -203,6 +203,109 @@ def finalize_video( output_path.unlink(missing_ok=True) +def finalize_motion_preview( + intermediate_path: Path, + output_path: Path, + *, + output_frame_rate: str, + cancellation_check: Callable[[], bool], + progress_hook: Callable[[FinalizationProgressSnapshot], None] | None = None, +) -> None: + """Create a muted, bounded-rate H.264/yuv420p fast-start preview.""" + + command = [ + "ffmpeg", + "-y", + "-i", + str(intermediate_path), + "-vf", + f"fps={output_frame_rate}", + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-crf", + "25", + "-preset", + "veryfast", + "-an", + "-movflags", + "+faststart", + "-progress", + "pipe:1", + "-nostats", + str(output_path), + ] + try: + process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + bufsize=1, + ) + except OSError as exc: + raise ExternalToolError("FFmpeg could not be started.") from exc + updates: Queue[FinalizationProgressSnapshot] = Queue(maxsize=32) + diagnostics: list[str] = [] + readers: list[Thread] = [] + if process.stdout is not None: + readers.append( + Thread( + target=_read_progress, + args=(process.stdout, updates), + name="glitchcraft-motion-preview-progress", + daemon=True, + ) + ) + if process.stderr is not None: + readers.append( + Thread( + target=_drain_diagnostics, + args=(process.stderr, diagnostics), + name="glitchcraft-motion-preview-diagnostics", + daemon=True, + ) + ) + for reader in readers: + reader.start() + try: + while process.poll() is None: + while True: + try: + snapshot = updates.get_nowait() + except Empty: + break + if progress_hook is not None: + progress_hook(snapshot) + if cancellation_check(): + process.terminate() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + raise ProcessingCanceled("Motion preview finalization was canceled.") + time.sleep(0.05) + for reader in readers: + reader.join(timeout=1) + while True: + try: + snapshot = updates.get_nowait() + except Empty: + break + if progress_hook is not None: + progress_hook(snapshot) + if process.returncode: + raise ExternalToolError("Motion preview 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: temporary_path = input_path.with_name(f"{input_path.stem}_h264.mp4") command = [ diff --git a/glitchcraft/media/video.py b/glitchcraft/media/video.py index c9afe43..d33b461 100644 --- a/glitchcraft/media/video.py +++ b/glitchcraft/media/video.py @@ -1,8 +1,10 @@ """OpenCV video processing with explicit BGR/RGB boundaries.""" +import math from collections.abc import Callable +from dataclasses import dataclass from pathlib import Path -from typing import cast +from typing import Any, cast import cv2 import numpy as np @@ -11,7 +13,12 @@ from glitchcraft.contracts.effects import Recipe, RecipeDocument, RecipeV2 from glitchcraft.effects.engine import apply_effect_stack from glitchcraft.effects.temporal import CompiledRecipe, MediaTimelineContext, compile_recipe -from glitchcraft.errors import MediaReadError, MediaWriteError, ProcessingCanceled +from glitchcraft.errors import ( + MediaReadError, + MediaWriteError, + MotionPreviewSeekError, + ProcessingCanceled, +) from glitchcraft.jobs.telemetry import FrameProgressSnapshot from glitchcraft.media.color import bgr_to_rgb, rgb_to_bgr from glitchcraft.media.image_io import save_image_rgb @@ -21,6 +28,175 @@ ActivationHook = Callable[[int, tuple[tuple[str, float], ...]], None] +@dataclass(frozen=True) +class ClipFrameProgress: + """Bounded motion-preview progress using absolute source frame indexes.""" + + frames_processed: int + total_frames: int + current_source_frame: int + source_frame_rate: float + + +ClipProgressHook = Callable[[ClipFrameProgress], None] +ProcessedFrameHook = Callable[[int, NDArray[np.uint8]], None] + + +def bounded_preview_dimensions( + width: int, + height: int, + *, + maximum_width: int, + maximum_height: int, +) -> tuple[int, int]: + """Fit inside the configured box, never upscale, and return even dimensions.""" + + if min(width, height, maximum_width, maximum_height) <= 0: + raise ValueError("motion preview dimensions must be positive") + scale = min(1.0, maximum_width / width, maximum_height / height) + output_width = int(math.floor(width * scale / 2) * 2) + output_height = int(math.floor(height * scale / 2) * 2) + if output_width < 2 or output_height < 2: + raise ValueError("motion preview dimensions are too small for H.264") + return output_width, output_height + + +def seek_video_capture_exact( + capture: Any, + target_frame: int, + *, + cancellation_check: CancellationCheck | None = None, + maximum_attempts: int = 3, +) -> int: + """Align the decoder's next read to an exact frame using bounded retries.""" + + if target_frame < 0: + raise ValueError("target frame cannot be negative") + retry_offsets = (0, 12, 60) + for attempt in range(maximum_attempts): + if cancellation_check is not None and cancellation_check(): + raise ProcessingCanceled("Motion preview seeking was canceled.") + seek_target = max(0, target_frame - retry_offsets[min(attempt, 2)]) + if not capture.set(cv2.CAP_PROP_POS_FRAMES, seek_target): + continue + try: + position_value = float(capture.get(cv2.CAP_PROP_POS_FRAMES)) + except (TypeError, ValueError): + continue + if not math.isfinite(position_value): + continue + next_frame = round(position_value) + if next_frame > target_frame: + continue + while next_frame < target_frame: + if cancellation_check is not None and cancellation_check(): + raise ProcessingCanceled("Motion preview seeking was canceled.") + readable, _discarded = capture.read() + if not readable: + break + next_frame += 1 + if next_frame == target_frame: + return next_frame + raise MotionPreviewSeekError( + f"The decoder could not align exactly to source frame {target_frame}." + ) + + +def process_video_clip( + input_path: Path, + output_path: Path, + recipe: RecipeDocument, + *, + timeline_context: MediaTimelineContext, + start_frame: int, + end_frame: int, + maximum_width: int, + maximum_height: int, + progress_hook: ClipProgressHook | None = None, + processed_frame_hook: ProcessedFrameHook | None = None, + cancellation_check: CancellationCheck | None = None, +) -> tuple[int, int]: + """Process a bounded source-frame interval without creating a clip-local timeline.""" + + if start_frame < 0 or end_frame > timeline_context.total_frames or end_frame <= start_frame: + raise ValueError("motion preview frame window is invalid") + if end_frame - start_frame < 2: + raise ValueError("motion preview requires at least two frames") + if cancellation_check is not None and cancellation_check(): + raise ProcessingCanceled("Motion preview processing was canceled.") + capture = cv2.VideoCapture(str(input_path)) + writer: cv2.VideoWriter | None = None + try: + if not capture.isOpened(): + raise MediaReadError("The video could not be opened.") + width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH)) + height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT)) + output_dimensions = bounded_preview_dimensions( + width, + height, + maximum_width=maximum_width, + maximum_height=maximum_height, + ) + source_fps = float(timeline_context.frame_rate) + compiled = ( + compile_recipe(recipe, timeline_context) if isinstance(recipe, RecipeV2) else None + ) + seek_video_capture_exact( + capture, + start_frame, + cancellation_check=cancellation_check, + ) + writer = cv2.VideoWriter( + str(output_path), + cv2.VideoWriter_fourcc(*"mp4v"), # type: ignore[attr-defined] + source_fps, + output_dimensions, + ) + if not writer.isOpened(): + raise MediaWriteError("The motion preview intermediate could not be created.") + total = end_frame - start_frame + for source_frame in range(start_frame, end_frame): + if cancellation_check is not None and cancellation_check(): + raise ProcessingCanceled("Motion preview processing was canceled.") + readable, frame_bgr = capture.read() + if not readable or frame_bgr is None: + raise MediaReadError( + f"The source ended before motion preview frame {source_frame}." + ) + processed: NDArray[np.uint8] = apply_effect_stack( + bgr_to_rgb(cast(NDArray[np.uint8], frame_bgr)), + recipe, + frame_index=source_frame, + compiled_recipe=compiled, + ) + if processed_frame_hook is not None: + processed_frame_hook(source_frame, processed.copy()) + if (processed.shape[1], processed.shape[0]) != output_dimensions: + processed = cast( + NDArray[np.uint8], + cv2.resize( + processed, + output_dimensions, + interpolation=cv2.INTER_AREA, + ), + ) + writer.write(rgb_to_bgr(processed)) + if progress_hook is not None: + progress_hook( + ClipFrameProgress( + frames_processed=source_frame - start_frame + 1, + total_frames=total, + current_source_frame=source_frame, + source_frame_rate=source_fps, + ) + ) + return output_dimensions + finally: + capture.release() + if writer is not None: + writer.release() + + 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) diff --git a/glitchcraft/service_contract.py b/glitchcraft/service_contract.py index a24fe5e..7412217 100644 --- a/glitchcraft/service_contract.py +++ b/glitchcraft/service_contract.py @@ -32,6 +32,9 @@ "basic-effect-intensity", "effect-burst-range", "effect-pattern-regeneration", + "motion-preview-clips", + "cancellable-motion-previews", + "ephemeral-preview-cache", ) SUPPORTED_VIDEO_EXTENSIONS = ("avi", "mkv", "mov", "mp4") @@ -134,7 +137,10 @@ def _basic_profile_metadata(effect_type: Any) -> dict[str, Any]: def capability_details( - *, storage_available: bool, video_manager_running: bool = True + *, + storage_available: bool, + video_manager_running: bool = True, + motion_preview_manager_running: bool = True, ) -> list[dict[str, Any]]: video_ready = ffmpeg_available() and ffprobe_available() and storage_available return [ @@ -230,6 +236,19 @@ def capability_details( "available": storage_available, "optional": True, }, + *[ + { + "slug": slug, + "exists": True, + "available": video_ready and motion_preview_manager_running, + "optional": True, + } + for slug in ( + "motion-preview-clips", + "cancellable-motion-previews", + "ephemeral-preview-cache", + ) + ], { "slug": "video-preview", "exists": True, diff --git a/glitchcraft/version.py b/glitchcraft/version.py index aea8e7c..9f3d5c0 100644 --- a/glitchcraft/version.py +++ b/glitchcraft/version.py @@ -3,8 +3,9 @@ APP_ID = "glitchcraft" APP_NAME = "GlitchCraft" APP_DESCRIPTOR = "Local visual-effects workspace" -APP_VERSION = "0.3.1" +APP_VERSION = "0.4.0" MANIFEST_SCHEMA_VERSION = 2 RECIPE_SCHEMA_VERSION = 2 STORAGE_SCHEMA_VERSION = 2 VIDEO_JOB_SCHEMA_VERSION = 2 +MOTION_PREVIEW_CLIP_CONTRACT_VERSION = 1 diff --git a/glitchcraft/web/routes.py b/glitchcraft/web/routes.py index 5e462f1..0e5ff7c 100644 --- a/glitchcraft/web/routes.py +++ b/glitchcraft/web/routes.py @@ -27,6 +27,11 @@ from glitchcraft.contracts.effects import MAX_SEED, Recipe, RecipeDocument, RecipeV2 from glitchcraft.contracts.image_workflow import ImageRecipeRequest, ImageSourceOptions from glitchcraft.contracts.legacy import FullVideoRequest, LegacyParameters, UploadMode +from glitchcraft.contracts.motion_preview import ( + MOTION_PREVIEW_CLIP_CONTRACT_VERSION, + MotionPreviewClipRequest, + normalize_motion_preview_window, +) from glitchcraft.contracts.progressive import ( BASIC_CONTROL_CONTRACT_VERSION, BASIC_EFFECT_PROFILE_VERSION, @@ -40,6 +45,7 @@ from glitchcraft.errors import ExternalToolError, GlitchCraftError, QueueCapacityError from glitchcraft.jobs.contracts import EffectScheduleRequest, VideoJobRequest, VideoPreviewRequest from glitchcraft.jobs.manager import VideoJobManager +from glitchcraft.jobs.motion_preview import MotionPreviewClipManager, MotionPreviewState from glitchcraft.jobs.telemetry import FrameProgressSnapshot, VideoJobPhase from glitchcraft.media.ffmpeg import reencode_for_browser from glitchcraft.media.image_io import ( @@ -171,6 +177,13 @@ def _video_manager() -> VideoJobManager: return cast(VideoJobManager, current_app.extensions["video_job_manager"]) +def _motion_preview_manager() -> MotionPreviewClipManager: + return cast( + MotionPreviewClipManager, + current_app.extensions["motion_preview_manager"], + ) + + def _json_not_found(asset: str) -> tuple[Response, int]: return jsonify(status="error", message=f"Unknown image {asset}."), 404 @@ -265,6 +278,10 @@ def _video_source_json(source: VideoSourceRecord) -> dict[str, Any]: "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), + "motionPreviewUrl": url_for( + "glitchcraft.create_motion_preview_clip", + source_id=source.id, + ), "jobsUrl": url_for("glitchcraft.create_video_job", source_id=source.id), "outputCount": len(outputs), "jobCount": len(jobs), @@ -435,6 +452,38 @@ def metadata() -> Response | tuple[Response, int]: "advancedCustomizationAvailable": True, "intensityRange": {"minimum": 0, "maximum": 100}, }, + motionPreviewClips={ + "contractVersion": MOTION_PREVIEW_CLIP_CONTRACT_VERSION, + "minimumDurationSeconds": 0.5, + "create": "/api/video-sources/{sourceId}/preview-clips", + "status": "/api/motion-preview-clips/{previewClipId}", + "cancel": "/api/motion-preview-clips/{previewClipId}/cancel", + "delete": "/api/motion-preview-clips/{previewClipId}", + "media": "/api/motion-preview-clips/{previewClipId}/media", + "endpoints": { + "create": "/api/video-sources/{sourceId}/preview-clips", + "status": "/api/motion-preview-clips/{previewClipId}", + "cancel": "/api/motion-preview-clips/{previewClipId}/cancel", + "delete": "/api/motion-preview-clips/{previewClipId}", + "media": "/api/motion-preview-clips/{previewClipId}/media", + }, + "maximumDurationSeconds": current_app.config["MOTION_PREVIEW_MAX_DURATION_SECONDS"], + "defaultBeforeSeconds": 1, + "defaultAfterSeconds": 2, + "maximumOutputWidth": current_app.config["MOTION_PREVIEW_MAX_OUTPUT_WIDTH"], + "maximumOutputHeight": current_app.config["MOTION_PREVIEW_MAX_OUTPUT_HEIGHT"], + "maximumOutputFps": current_app.config["MOTION_PREVIEW_MAX_OUTPUT_FPS"], + "audio": "omitted", + "outputProfile": { + "maximumWidth": current_app.config["MOTION_PREVIEW_MAX_OUTPUT_WIDTH"], + "maximumHeight": current_app.config["MOTION_PREVIEW_MAX_OUTPUT_HEIGHT"], + "maximumFramesPerSecond": current_app.config["MOTION_PREVIEW_MAX_OUTPUT_FPS"], + "videoCodec": "h264", + "pixelFormat": "yuv420p", + "audio": False, + }, + **_motion_preview_manager().status(), + }, library={ "imageSources": len(repository.list_sources()) if repository.available else 0, "imageOutputs": len(repository.list_outputs()) if repository.available else 0, @@ -504,7 +553,11 @@ def readiness() -> tuple[Response, int]: ffmpeg_ready = ffmpeg_available() ffprobe_ready = ffprobe_available() manager_status = _video_manager().status() + preview_status = _motion_preview_manager().status() manager_ready = manager_status["workers"] > 0 or bool(current_app.config.get("TESTING")) + preview_manager_ready = bool(preview_status["running"]) 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", "migrated"} or ( @@ -554,6 +607,18 @@ def readiness() -> tuple[Response, int]: "state": "available" if manager_ready else "unavailable", "queueAvailable": queue_available, }, + "motionPreviewClips": { + **preview_status, + "state": ( + "available" + if preview_manager_ready and ffmpeg_ready and ffprobe_ready and writable + else "unavailable" + ), + "queueAvailable": int(preview_status["queued"]) < int(preview_status["capacity"]), + "ffmpegAvailable": ffmpeg_ready, + "ffprobeAvailable": ffprobe_ready, + "storageAvailable": writable, + }, "reconciliation": { "state": report.state, "orphanFiles": report.orphan_sources + report.orphan_outputs, @@ -566,6 +631,7 @@ def readiness() -> tuple[Response, int]: def capabilities() -> Response: repository = _repository() manager_status = _video_manager().status() + preview_status = _motion_preview_manager().status() return jsonify( schemaVersion=1, service=APP_ID, @@ -574,6 +640,9 @@ def capabilities() -> Response: video_manager_running=( manager_status["workers"] > 0 or bool(current_app.config.get("TESTING")) ), + motion_preview_manager_running=( + bool(preview_status["running"]) or bool(current_app.config.get("TESTING")) + ), ), ffmpeg={"available": ffmpeg_available()}, ffprobe={"available": ffprobe_available(), "required": False}, @@ -1115,6 +1184,103 @@ def capture_activation( preview_path.unlink(missing_ok=True) +@bp.post("/api/video-sources//preview-clips") +def create_motion_preview_clip(source_id: str) -> Response | tuple[Response, int]: + try: + _reject_query_parameters() + if not ffmpeg_available() or not ffprobe_available(): + raise ExternalToolError("Motion preview tools are unavailable.") + payload = MotionPreviewClipRequest.model_validate(request.get_json(silent=True)) + manager = _motion_preview_manager() + if payload.before_seconds + payload.after_seconds > manager.maximum_duration_seconds: + raise ValueError( + f"motion preview duration cannot exceed " + f"{manager.maximum_duration_seconds:g} seconds" + ) + source = _repository().get_video_source(source_id) + window = normalize_motion_preview_window(payload, _source_timeline(source)) + submission = manager.submit(source, payload, window) + response = jsonify(submission.snapshot.model_dump(mode="json", by_alias=True)) + return _no_store(response), 200 if submission.ready_cache_hit else 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 RuntimeError as exc: + return jsonify(status="error", message=str(exc)), 503 + except (AssetNotFoundError, StorageUnavailableError) as exc: + return _storage_error(exc) + + +@bp.get("/api/motion-preview-clips/") +def get_motion_preview_clip(preview_clip_id: str) -> Response | tuple[Response, int]: + try: + _reject_query_parameters() + snapshot = _motion_preview_manager().get(preview_clip_id) + return _no_store(jsonify(snapshot.model_dump(mode="json", by_alias=True))) + except ValueError as exc: + return _validation_error(exc) + except LookupError as exc: + return jsonify(status="error", message=str(exc)), 404 + + +@bp.post("/api/motion-preview-clips//cancel") +def cancel_motion_preview_clip(preview_clip_id: str) -> Response | tuple[Response, int]: + try: + _reject_query_parameters() + snapshot = _motion_preview_manager().cancel(preview_clip_id) + return _no_store(jsonify(snapshot.model_dump(mode="json", by_alias=True))), 202 + except LookupError as exc: + return jsonify(status="error", message=str(exc)), 404 + except ValueError as exc: + return jsonify(status="error", message=str(exc)), 409 + + +@bp.delete("/api/motion-preview-clips/") +def delete_motion_preview_clip(preview_clip_id: str) -> Response | tuple[Response, int]: + try: + _reject_query_parameters() + _motion_preview_manager().delete(preview_clip_id) + return jsonify(status="deleted", previewClipId=preview_clip_id) + except ValueError as exc: + return _validation_error(exc) + except LookupError as exc: + return jsonify(status="error", message=str(exc)), 404 + + +@bp.route( + "/api/motion-preview-clips//media", + methods=["GET", "HEAD"], +) +def serve_motion_preview_clip(preview_clip_id: str) -> Response | tuple[Response, int]: + try: + _reject_query_parameters() + snapshot = _motion_preview_manager().get(preview_clip_id) + except ValueError as exc: + return _validation_error(exc) + except LookupError as exc: + return jsonify(status="error", message=str(exc)), 404 + if snapshot.state != MotionPreviewState.READY: + return jsonify(status="error", message="Motion preview media is not ready."), 409 + lease = _motion_preview_manager().lease_media(preview_clip_id) + try: + path = lease.__enter__() + response = stream_path( + path, + mime_type="video/mp4", + download_name=f"motion-preview-{preview_clip_id}.mp4", + ) + response.headers["Cache-Control"] = "private, no-store, max-age=0" + response.headers["Pragma"] = "no-cache" + response.call_on_close(lambda: lease.__exit__(None, None, None)) + return response + except LookupError as exc: + lease.__exit__(type(exc), exc, exc.__traceback__) + return jsonify(status="error", message=str(exc)), 404 + + @bp.post("/api/video-sources//effect-schedule") def inspect_effect_schedule(source_id: str) -> Response | tuple[Response, int]: try: @@ -1291,6 +1457,7 @@ def delete_video_source(source_id: str) -> Response | tuple[Response, int]: 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") + _motion_preview_manager().invalidate_source(source_id) return jsonify( status="deleted", sourceId=source_id, diff --git a/pyproject.toml b/pyproject.toml index 48bd268..f0f2b7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "glitchcraft" -version = "0.3.1" +version = "0.4.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 e132947..de68448 100644 --- a/static/app-manifest.json +++ b/static/app-manifest.json @@ -21,7 +21,10 @@ "progressive-effect-controls", "basic-effect-intensity", "effect-burst-range", - "effect-pattern-regeneration" + "effect-pattern-regeneration", + "motion-preview-clips", + "cancellable-motion-previews", + "ephemeral-preview-cache" ], "defaults": { "apiAddress": "http://127.0.0.1:5000", @@ -38,5 +41,5 @@ "id": "glitchcraft", "name": "GlitchCraft", "schemaVersion": 1, - "version": "0.3.1" + "version": "0.4.0" } diff --git a/static/app.js b/static/app.js index d741fc3..81ad14b 100644 --- a/static/app.js +++ b/static/app.js @@ -58,6 +58,20 @@ metadataState: "loading", metadataRequestCount: 0, resolverRequestCount: 0, + motionPreviewMode: "still", + motionPreviewRevision: 0, + motionPreviewController: null, + motionStatusController: null, + motionPollTimer: null, + motionPollFailures: 0, + motionActiveClipId: null, + motionActiveSignature: null, + motionReadyClipId: null, + motionReadySignature: null, + motionLastStatus: null, + motionLastAnnouncedPhase: null, + motionCreateRequestCount: 0, + motionStatusRequestCount: 0, selectionCount: 0, performanceMarks: {}, }; @@ -1231,6 +1245,9 @@ ? "Supported images: PNG, JPEG, BMP, and TIFF." : "Supported videos: MP4, AVI, MOV, and MKV."; document.querySelector("#preview-section").hidden = imageMode; + if (imageMode) { + setMotionMode("still"); + } if (imageState.sourceId) { imageWorkspace.hidden = !imageMode; } @@ -1257,6 +1274,47 @@ document.querySelector("#retry-video-preview").hidden = state !== "error"; } + function formatMotionTimestamp(seconds) { + const safe = Math.max(0, Number(seconds) || 0); + const minutes = Math.floor(safe / 60); + const remainder = (safe % 60).toFixed(3).padStart(6, "0"); + return `${String(minutes).padStart(2, "0")}:${remainder}`; + } + + function motionWindowValues() { + return { + beforeSeconds: Number(document.querySelector("#motion-before").value), + afterSeconds: Number(document.querySelector("#motion-after").value), + }; + } + + function motionPreviewSignature() { + if (!imageState.videoSource) { + return null; + } + const windowValues = motionWindowValues(); + return JSON.stringify({ + sourceId: imageState.videoSource.sourceId, + recipe: buildRecipe(), + timestampSeconds: Number(document.querySelector("#video-preview-time").value), + ...windowValues, + }); + } + + function updateMotionWindowSummary() { + const {beforeSeconds, afterSeconds} = motionWindowValues(); + const anchor = Number(document.querySelector("#video-preview-time").value); + const total = beforeSeconds + afterSeconds; + const compact = (value) => Number(value).toLocaleString("en-US", {maximumFractionDigits: 1}); + document.querySelector("#motion-preview-window-summary").textContent = + `${compact(beforeSeconds)} second${beforeSeconds === 1 ? "" : "s"} before · ` + + `${compact(afterSeconds)} second${afterSeconds === 1 ? "" : "s"} after`; + document.querySelector("#motion-preview-anchor").textContent = + `Motion preview around ${formatMotionTimestamp(anchor)} · ${compact(total)} seconds requested`; + const action = document.querySelector("#create-motion-preview"); + action.disabled = !imageState.videoSource || !videoRecipeReady() || total > 5 || total < 0.5; + } + async function submitVideoPreview() { const file = fileInput.files[0]; if (!file) { @@ -1286,6 +1344,7 @@ throw new Error(await errorMessage(response, "The video could not be uploaded.")); } const source = await response.json(); + clearMotionPreview({deleteRemote: true}); imageState.videoSource = source; imageState.seed = source.seed; for (const output of document.querySelectorAll("[data-recipe-seed]")) { @@ -1301,6 +1360,7 @@ document.querySelector("#preview-section").hidden = false; document.querySelector("#workspace-source-status").textContent = source.originalName; document.querySelector("#render-status-badge").textContent = "Ready to render"; + updateMotionWindowSummary(); await Promise.allSettled([requestEffectSchedule(), requestVideoPreview()]); setNotice(""); } catch (error) { @@ -1383,6 +1443,415 @@ } } + function stopMotionPolling() { + window.clearTimeout(imageState.motionPollTimer); + imageState.motionPollTimer = null; + imageState.motionStatusController?.abort(); + imageState.motionStatusController = null; + } + + function setMotionMode(mode) { + imageState.motionPreviewMode = mode; + const motion = mode === "motion"; + document.querySelector("#still-preview-mode").setAttribute("aria-selected", String(!motion)); + document.querySelector("#motion-preview-mode").setAttribute("aria-selected", String(motion)); + document.querySelector("#motion-preview-controls").hidden = !motion; + const video = document.querySelector("#motion-preview-video"); + const still = document.querySelector("#preview-image"); + const ready = Boolean(imageState.motionReadyClipId && video.src); + video.hidden = !motion || !ready; + still.hidden = motion && ready; + document.querySelector("#video-preview-heading").textContent = motion + ? "Motion review" + : "Processed frame"; + if (motion) { + updateMotionWindowSummary(); + } else { + video.pause(); + } + } + + function announceMotion(phase, message) { + if (phase !== imageState.motionLastAnnouncedPhase) { + document.querySelector("#motion-preview-announcement").textContent = message; + imageState.motionLastAnnouncedPhase = phase; + } + } + + function motionStatusText(status) { + if (status.state === "queued") { + return "Queued for motion preview"; + } + if (status.state === "preparing") { + return `Preparing frames ${status.sourceStartFrame}–${status.sourceEndFrame}`; + } + if (status.state === "processing") { + const speed = Number.isFinite(status.currentFramesPerSecond) + ? ` · ${status.currentFramesPerSecond.toFixed(1)} fps` + : ""; + const eta = Number.isFinite(status.estimatedRemainingSeconds) + ? ` · about ${Math.max(1, Math.round(status.estimatedRemainingSeconds))}s remaining` + : ""; + return ( + `Rendering motion preview — frame ${status.framesProcessed} of ${status.totalFrames}` + + ` · ${status.progress}%${speed}${eta}` + ); + } + if (status.state === "finalizing") { + return "Finalizing preview MP4"; + } + if (status.state === "verifying") { + return "Verifying preview"; + } + if (status.state === "ready") { + return ( + `Motion preview ready — ${Number(status.durationSeconds).toFixed(1)} seconds · ` + + `${status.width}×${status.height} · ${status.frameRate} fps` + ); + } + if (status.state === "canceled") { + return "Motion preview canceled"; + } + if (status.state === "expired") { + return "Motion preview expired"; + } + return status.error?.message || "Motion preview failed"; + } + + function renderMotionDetails(status) { + const details = document.querySelector("#motion-preview-details"); + if (!status) { + details.hidden = true; + return; + } + details.hidden = false; + setDefinitionList(document.querySelector("#motion-preview-metadata"), [ + ["Source frames", `${status.sourceStartFrame}–${status.sourceEndFrame} (end exclusive)`], + [ + "Source window", + `${Number(status.actualStartSeconds).toFixed(3)}–${Number( + status.actualEndSeconds, + ).toFixed(3)} seconds`, + ], + ["Output", status.width ? `${status.width} × ${status.height}` : "Pending"], + ["Frame rate", status.frameRate || "Pending"], + ["Output frames", status.frameCount ?? "Pending"], + ["File size", status.fileSize ? formatBytes(status.fileSize) : "Pending"], + ["Audio", status.hasAudio === false ? "Intentionally omitted" : "Pending"], + ["Cache", status.cacheHit ? "Ready cache hit" : "Rendered for this request"], + ["Preview ID", status.previewClipId], + ["Expires", status.expiresAt || "Pending"], + ["Recipe", `v${status.recipeVersion} · seed ${status.seed}`], + ]); + } + + function renderMotionStatus(status, revision) { + if ( + revision !== imageState.motionPreviewRevision || + status.previewClipId !== imageState.motionActiveClipId + ) { + return; + } + imageState.motionLastStatus = status; + const controls = document.querySelector("#motion-preview-controls"); + const badge = document.querySelector("#motion-preview-badge"); + const progress = document.querySelector("#motion-preview-progress"); + const progressBar = document.querySelector("#motion-preview-progress-bar"); + const statusText = motionStatusText(status); + const action = document.querySelector("#create-motion-preview"); + const cancel = document.querySelector("#cancel-motion-preview"); + const active = ["queued", "preparing", "processing", "finalizing", "verifying"].includes( + status.state, + ); + controls.dataset.state = status.state; + badge.textContent = + { + queued: "Queued", + preparing: "Preparing", + processing: "Rendering", + finalizing: "Finalizing", + verifying: "Verifying", + ready: "Ready", + failed: "Failed", + canceled: "Canceled", + expired: "Expired", + }[status.state] || status.state; + progress.hidden = false; + progressBar.hidden = !active; + progressBar.value = status.progress || 0; + progressBar.setAttribute("aria-valuenow", String(status.progress || 0)); + document.querySelector("#motion-preview-status").textContent = statusText; + cancel.hidden = !active; + action.disabled = active; + announceMotion(status.phase || status.state, statusText); + renderMotionDetails(status); + + if (status.state === "ready") { + stopMotionPolling(); + imageState.motionActiveClipId = null; + imageState.motionReadyClipId = status.previewClipId; + imageState.motionReadySignature = imageState.motionActiveSignature; + const video = document.querySelector("#motion-preview-video"); + video.src = status.mediaUrl; + video.muted = true; + video.loop = true; + video.controls = true; + action.textContent = "Update motion preview"; + const currentSignature = motionPreviewSignature(); + if (currentSignature !== imageState.motionReadySignature) { + markMotionPreviewStale(false); + } else { + action.disabled = false; + if (imageState.motionPreviewMode === "motion") { + video.hidden = false; + document.querySelector("#preview-image").hidden = true; + video.play().catch(() => {}); + } + } + return; + } + if (["failed", "canceled", "expired"].includes(status.state)) { + stopMotionPolling(); + imageState.motionActiveClipId = null; + action.disabled = !imageState.videoSource; + action.textContent = imageState.motionReadyClipId + ? "Update motion preview" + : "Preview motion"; + if (status.state === "expired" && imageState.motionReadyClipId === status.previewClipId) { + imageState.motionReadyClipId = null; + imageState.motionReadySignature = null; + document.querySelector("#motion-preview-video").removeAttribute("src"); + setMotionMode(imageState.motionPreviewMode); + } + } + } + + async function pollMotionPreview(revision, clipId) { + if ( + revision !== imageState.motionPreviewRevision || + clipId !== imageState.motionActiveClipId + ) { + return; + } + imageState.motionStatusController?.abort(); + const controller = new AbortController(); + imageState.motionStatusController = controller; + imageState.motionStatusRequestCount += 1; + try { + const response = await fetch( + `/api/motion-preview-clips/${encodeURIComponent(clipId)}`, + {signal: controller.signal}, + ); + if (response.status === 404) { + renderMotionStatus( + { + ...imageState.motionLastStatus, + previewClipId: clipId, + state: "expired", + phase: "expired", + progress: 100, + }, + revision, + ); + return; + } + if (!response.ok) { + throw new Error(await errorMessage(response, "Motion preview status is unavailable.")); + } + imageState.motionPollFailures = 0; + const status = await response.json(); + renderMotionStatus(status, revision); + if ( + revision === imageState.motionPreviewRevision && + clipId === imageState.motionActiveClipId + ) { + imageState.motionPollTimer = window.setTimeout( + () => pollMotionPreview(revision, clipId), + status.state === "queued" ? 1000 : 650, + ); + } + } catch (error) { + if (error.name !== "AbortError" && revision === imageState.motionPreviewRevision) { + imageState.motionPollFailures += 1; + const delay = Math.min(5000, 900 * 2 ** Math.min(3, imageState.motionPollFailures)); + document.querySelector("#motion-preview-status").textContent = + "Waiting for a motion preview update. The last ready clip remains available."; + imageState.motionPollTimer = window.setTimeout( + () => pollMotionPreview(revision, clipId), + delay, + ); + } + } finally { + if (imageState.motionStatusController === controller) { + imageState.motionStatusController = null; + } + } + } + + function markMotionPreviewStale(announce = true) { + updateMotionWindowSummary(); + const currentSignature = motionPreviewSignature(); + const readyStale = + imageState.motionReadyClipId && + currentSignature && + currentSignature !== imageState.motionReadySignature; + const activeStale = + imageState.motionActiveClipId && + currentSignature && + currentSignature !== imageState.motionActiveSignature; + if (!readyStale && !activeStale) { + return; + } + const controls = document.querySelector("#motion-preview-controls"); + controls.dataset.state = "stale"; + document.querySelector("#motion-preview-badge").textContent = "Out of date"; + const action = document.querySelector("#create-motion-preview"); + action.textContent = "Update motion preview"; + action.disabled = !imageState.videoSource || !videoRecipeReady(); + if (announce && imageState.motionPreviewMode === "motion") { + announceMotion(`stale-${Date.now()}`, "Motion preview is out of date."); + } + } + + async function createMotionPreview() { + const source = imageState.videoSource; + if (!source || !videoRecipeReady()) { + return; + } + const signature = motionPreviewSignature(); + if (!signature) { + return; + } + const previousActive = imageState.motionActiveClipId; + stopMotionPolling(); + imageState.motionPreviewController?.abort(); + if (previousActive) { + fetch(`/api/motion-preview-clips/${encodeURIComponent(previousActive)}/cancel`, { + method: "POST", + }).catch(() => {}); + } + const controller = new AbortController(); + imageState.motionPreviewController = controller; + const revision = ++imageState.motionPreviewRevision; + imageState.motionActiveSignature = signature; + imageState.motionCreateRequestCount += 1; + const action = document.querySelector("#create-motion-preview"); + action.disabled = true; + document.querySelector("#cancel-motion-preview").hidden = false; + document.querySelector("#motion-preview-progress").hidden = false; + document.querySelector("#motion-preview-status").textContent = + "Submitting bounded motion preview…"; + try { + const {beforeSeconds, afterSeconds} = motionWindowValues(); + const response = await fetch( + `/api/video-sources/${encodeURIComponent(source.sourceId)}/preview-clips`, + { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + contractVersion: 1, + recipe: buildRecipe(), + timestampSeconds: Number(document.querySelector("#video-preview-time").value), + beforeSeconds, + afterSeconds, + }), + signal: controller.signal, + }, + ); + if (!response.ok) { + throw new Error(await errorMessage(response, "Motion preview could not be created.")); + } + const status = await response.json(); + if (revision !== imageState.motionPreviewRevision) { + return; + } + imageState.motionActiveClipId = status.previewClipId; + renderMotionStatus(status, revision); + if (!["ready", "failed", "canceled", "expired"].includes(status.state)) { + imageState.motionPollTimer = window.setTimeout( + () => pollMotionPreview(revision, status.previewClipId), + 650, + ); + } + } catch (error) { + if (error.name !== "AbortError" && revision === imageState.motionPreviewRevision) { + const fallback = { + previewClipId: imageState.motionActiveClipId || "unavailable", + state: "failed", + phase: "failed", + progress: 0, + error: {message: error.message}, + }; + imageState.motionActiveClipId = fallback.previewClipId; + renderMotionStatus(fallback, revision); + } + } finally { + if (imageState.motionPreviewController === controller) { + imageState.motionPreviewController = null; + } + } + } + + async function cancelMotionPreview() { + const clipId = imageState.motionActiveClipId; + if (!clipId) { + return; + } + document.querySelector("#cancel-motion-preview").disabled = true; + try { + const response = await fetch( + `/api/motion-preview-clips/${encodeURIComponent(clipId)}/cancel`, + {method: "POST"}, + ); + if (!response.ok && response.status !== 409) { + throw new Error(await errorMessage(response, "Motion preview could not be canceled.")); + } + const status = response.status === 409 ? null : await response.json(); + if (status) { + renderMotionStatus(status, imageState.motionPreviewRevision); + } + } finally { + document.querySelector("#cancel-motion-preview").disabled = false; + } + } + + function clearMotionPreview({deleteRemote = false} = {}) { + const clipIds = new Set( + [imageState.motionActiveClipId, imageState.motionReadyClipId].filter(Boolean), + ); + stopMotionPolling(); + imageState.motionPreviewController?.abort(); + imageState.motionPreviewController = null; + imageState.motionPreviewRevision += 1; + if (deleteRemote) { + for (const clipId of clipIds) { + fetch(`/api/motion-preview-clips/${encodeURIComponent(clipId)}`, { + method: "DELETE", + }).catch(() => {}); + } + } + Object.assign(imageState, { + motionActiveClipId: null, + motionActiveSignature: null, + motionReadyClipId: null, + motionReadySignature: null, + motionLastStatus: null, + motionLastAnnouncedPhase: null, + }); + const video = document.querySelector("#motion-preview-video"); + video.pause(); + video.removeAttribute("src"); + video.load(); + document.querySelector("#motion-preview-controls").dataset.state = "empty"; + document.querySelector("#motion-preview-badge").textContent = "Not generated"; + document.querySelector("#motion-preview-progress").hidden = true; + document.querySelector("#motion-preview-details").hidden = true; + document.querySelector("#create-motion-preview").textContent = "Preview motion"; + document.querySelector("#cancel-motion-preview").hidden = true; + setMotionMode(imageState.motionPreviewMode); + updateMotionWindowSummary(); + } + function formatScheduleTime(seconds) { const whole = Math.max(0, Math.floor(seconds)); const minutes = Math.floor(whole / 60); @@ -1488,6 +1957,7 @@ } function scheduleTemporalRefresh() { + markMotionPreviewStale(); updateTemporalVisibility(); updateBasicDurationContexts(); schedulePreview(); @@ -1513,6 +1983,7 @@ ? `Jumped to the next scheduled glitch at ${selected.startSeconds.toFixed(2)} seconds.` : `Wrapped to the first scheduled glitch at ${selected.startSeconds.toFixed(2)} seconds.`; requestVideoPreview(); + markMotionPreviewStale(); } function regeneratePatterns() { @@ -1527,6 +1998,7 @@ } document.querySelector("#pattern-status").textContent = `All stochastic effect patterns regenerated with seed ${imageState.seed}.`; + markMotionPreviewStale(); requestEffectSchedule(); requestVideoPreview(); } @@ -2004,7 +2476,30 @@ 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("#video-preview-time").addEventListener("input", () => { + schedulePreview(); + markMotionPreviewStale(); + }); + document.querySelector("#still-preview-mode").addEventListener("click", () => { + setMotionMode("still"); + }); + document.querySelector("#motion-preview-mode").addEventListener("click", () => { + setMotionMode("motion"); + }); + document.querySelector("#create-motion-preview").addEventListener("click", createMotionPreview); + document.querySelector("#cancel-motion-preview").addEventListener("click", cancelMotionPreview); + for (const control of document.querySelectorAll("#motion-before, #motion-after")) { + control.addEventListener("change", () => { + updateMotionWindowSummary(); + markMotionPreviewStale(); + }); + } + document.querySelector("#motion-preview-video").addEventListener("error", () => { + if (imageState.motionReadyClipId) { + document.querySelector("#motion-preview-status").textContent = + "The motion preview could not be played. Generate it again or use the still preview."; + } + }); document.querySelector("#jump-next-glitch").addEventListener("click", nextScheduledEvent); document.querySelector("#regenerate-patterns").addEventListener("click", regeneratePatterns); document.querySelector("#retry-effect-metadata").addEventListener("click", () => { @@ -2049,6 +2544,7 @@ document.querySelector("#cancel-preview").addEventListener("click", () => { stopVideoPolling(); releaseVideoPreview(); + clearMotionPreview({deleteRemote: true}); imageState.videoPollRevision += 1; imageState.videoJobId = null; imageState.videoSource = null; @@ -2080,12 +2576,16 @@ metrics: () => ({ metadataRequests: imageState.metadataRequestCount, resolverRequests: imageState.resolverRequestCount, + motionCreateRequests: imageState.motionCreateRequestCount, + motionStatusRequests: imageState.motionStatusRequestCount, selections: imageState.selectionCount, ...imageState.performanceMarks, }), }; window.addEventListener("pagehide", () => { stopVideoPolling(); + stopMotionPolling(); + imageState.motionPreviewController?.abort(); releaseVideoPreview(); imageState.scheduleController?.abort(); for (const controller of imageState.basicControllers.values()) { diff --git a/static/style.css b/static/style.css index 16d61d5..746ac39 100644 --- a/static/style.css +++ b/static/style.css @@ -945,6 +945,33 @@ select { font-size: 0.78rem; } +.preview-mode-switch { + display: inline-grid; + grid-template-columns: repeat(2, minmax(84px, 1fr)); + gap: 2px; + margin-bottom: var(--space-2); + padding: 3px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-recessed); +} + +.preview-mode-switch button { + min-height: 34px; + padding: 0.35rem 0.8rem; + border: 0; + border-radius: 6px; + color: var(--muted); + background: transparent; + cursor: pointer; +} + +.preview-mode-switch button[aria-selected="true"] { + color: var(--text); + background: var(--surface-selected); + box-shadow: inset 0 -2px 0 var(--accent); +} + .preview-toolbar { flex-wrap: wrap; gap: var(--space-2); @@ -987,6 +1014,19 @@ select { max-height: min(56vh, 620px); } +.video-preview-stage video { + display: block; + width: 100%; + max-width: 100%; + max-height: min(56vh, 620px); + object-fit: contain; + background: #000; +} + +.video-preview-stage video[hidden] { + display: none; +} + #preview-image:not([src]) { display: none; } @@ -1025,6 +1065,80 @@ select { margin-top: var(--space-2); } +.motion-preview-controls { + display: grid; + gap: var(--space-2); + margin-top: var(--space-2); + padding: var(--space-3); + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-recessed); +} + +.motion-preview-controls[hidden] { + display: none; +} + +.motion-preview-heading, +.motion-preview-actions, +.motion-preview-progress { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--space-2); +} + +.motion-preview-heading h3, +.motion-preview-heading p, +.motion-preview-anchor, +.motion-preview-progress p { + margin: 0; +} + +.motion-window-fields { + display: grid; + grid-template-columns: auto minmax(100px, 1fr) auto minmax(100px, 1fr); + align-items: center; + gap: var(--space-2); +} + +.motion-window-fields label { + color: var(--muted); + font-size: 0.8rem; +} + +.motion-preview-anchor, +.motion-preview-progress p { + color: var(--muted); + font-size: 0.82rem; +} + +.motion-preview-progress progress { + flex: 1 1 180px; +} + +.motion-preview-controls details { + padding-top: var(--space-2); + border-top: 1px solid var(--border); +} + +.motion-preview-controls[data-state="stale"] #motion-preview-badge { + color: var(--warning); + border-color: color-mix(in srgb, var(--warning) 36%, transparent); +} + +.motion-preview-controls[data-state="ready"] #motion-preview-badge { + color: var(--success); + border-color: color-mix(in srgb, var(--success) 32%, transparent); +} + +.motion-preview-controls[data-state="failed"] #motion-preview-badge, +.motion-preview-controls[data-state="expired"] #motion-preview-badge { + color: var(--danger); + border-color: color-mix(in srgb, var(--danger) 32%, transparent); +} + .inspector-summary { margin: 0 0 var(--space-3); color: var(--muted); @@ -1303,6 +1417,14 @@ body[data-mode="image"] .appearance-effect-controls > h3 { grid-template-columns: 1fr; } + .motion-window-fields { + grid-template-columns: auto minmax(0, 1fr); + } + + .motion-preview-actions .action { + flex: 1 1 150px; + } + .video-mobile-nav { position: sticky; top: var(--space-2); diff --git a/templates/index.html b/templates/index.html index 4d23740..d348567 100644 --- a/templates/index.html +++ b/templates/index.html @@ -233,6 +233,26 @@

Processed frame

+
+ + +
@@ -256,6 +276,16 @@

Processed frame

aria-busy="false" > Processed preview frame from the uploaded video +
No video loaded Choose a local video above. The editor remains ready while it loads. @@ -274,6 +304,79 @@

Processed frame

Retry preview
+