From c193dcff2360956bbe2a226fb55ee01f1eaa5327 Mon Sep 17 00:00:00 2001 From: John Crafts <5889731+Artsen@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:39:01 -0400 Subject: [PATCH 1/2] feat: add progressive effect controls --- docs/architecture.md | 6 + docs/product-direction.md | 6 + docs/progressive-controls.md | 53 ++++ docs/recipes.md | 10 +- docs/service-contract.md | 12 +- docs/storage.md | 4 +- docs/temporal-effects.md | 6 + docs/testing.md | 12 + docs/video-workflow.md | 8 + glitchcraft/contracts/progressive.py | 75 +++++ glitchcraft/effects/progressive.py | 365 +++++++++++++++++++++++ glitchcraft/effects/registry.py | 148 ++++++++++ glitchcraft/service_contract.py | 56 +++- glitchcraft/version.py | 2 +- glitchcraft/web/routes.py | 66 +++++ pyproject.toml | 2 +- static/app-manifest.json | 8 +- static/app.js | 423 ++++++++++++++++++++++++++- static/style.css | 45 +++ templates/index.html | 22 ++ tests/browser/image-workflow.spec.js | 71 ++++- tests/test_progressive_controls.py | 338 +++++++++++++++++++++ 22 files changed, 1711 insertions(+), 27 deletions(-) create mode 100644 docs/progressive-controls.md create mode 100644 glitchcraft/contracts/progressive.py create mode 100644 glitchcraft/effects/progressive.py create mode 100644 tests/test_progressive_controls.py diff --git a/docs/architecture.md b/docs/architecture.md index fc44385..abd5c1a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -58,3 +58,9 @@ and centralized parameter scaling. This separation keeps routes and the video loop free of effect-specific timing rules. Smooth variation derives random anchors on demand and is independent of processing order; no mutable global RNG or previous-frame buffer exists. +# Progressive control resolution + +The progressive layer is upstream of Recipe v2 validation and temporal compilation: +strict Basic contract → registry profile → canonical Recipe v2 effect fragment. Reverse +inference is pure and bounded to 101 candidates. Neither path decodes media or adds render +overhead. UI ownership and disclosure state are deliberately outside persisted contracts. diff --git a/docs/product-direction.md b/docs/product-direction.md index 10cbdf4..d04c1a0 100644 --- a/docs/product-direction.md +++ b/docs/product-direction.md @@ -38,3 +38,9 @@ A future orchestration dashboard may discover and check GlitchCraft, ColorCraft, and Web Video Optimizer through related contracts. It is not implemented here and cannot launch or remotely control this service. The family-aligned React workspace and proposed 5175/4200 frontend/API split are also deferred. +# Progressive controls milestone + +Version 0.3.1 makes the deterministic temporal engine approachable without hiding its exact +contracts. It is intentionally a focused control-resolution layer 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. diff --git a/docs/progressive-controls.md b/docs/progressive-controls.md new file mode 100644 index 0000000..cf7f090 --- /dev/null +++ b/docs/progressive-controls.md @@ -0,0 +1,53 @@ +# Progressive effect controls + +GlitchCraft 0.3.1 adds a progressive control layer for Recipe v2 video effects. It does +not add a recipe format. Basic controls resolve immediately to the same exact `intensity`, +`timing`, `envelope`, and `variation` fields shown by Advanced controls and stored in jobs +and outputs. + +## Basic and Advanced ownership + +Basic **Intensity** is an overall effect-amount macro from 0 through 100. Depending on the +effect profile it changes visual strength, activity, or both. Advanced **Visual Strength** +is the persisted 0-through-1 severity while active. Advanced activity fields control exact +frequency and cooldown behavior. + +Burst effects expose minimum and maximum lengths in integer decoded frames. The scheduler +selects each duration deterministically within that range. The UI also shows seconds using +the uploaded source frame rate. Continuous noise, static, and scan lines keep their natural +continuous behavior; their duration controls become relevant only after choosing burst +timing in Advanced controls. + +An effect is **Basic-derived** while its canonical fields match the resolver. Editing an +Advanced value marks it **Custom** and never discards that value when the disclosure closes. +**Reset to Basic controls** explicitly replaces the custom fields. Moving Basic Intensity +after customization is also an explicit replacement. + +## Contract and profiles + +`BasicEffectControlContract` version 1 accepts an effect type, Basic Intensity, duration +bounds, an optional source range, and an optional regeneration request. The pure +`POST /api/effects/resolve-basic` endpoint uses Basic effect profile version 1 and performs +no media access, schedule compilation, FFmpeg work, or persistence. + +Profiles are owned by the Python effect registry and returned by `GET /api/effects`. +Horizontal glitch, frame shift, color bleed, pixelation, and flicker use effect-specific +nonlinear strength/frequency/cooldown mappings. Noise, static, and scan lines primarily +map visual strength and remain continuous. The frontend contains no duplicated profile +numbers. + +`POST /api/effects/infer-basic` performs a bounded search over the 101 possible macro +values and reports `exact`, `approximate`, or `custom`. Approximate inference never rewrites +the recipe. Advanced configurations outside a profile remain untouched. + +## Reproducibility + +Persisted Recipe v2 records contain exact resolved values and the root seed, not the Basic +macro, UI ownership, summaries, schedule output, or profile version. Future profile changes +therefore cannot alter an existing output. **Regenerate all effect patterns** changes the +root seed deliberately, refreshes schedule and still preview requests, and affects every +stochastic effect without re-uploading or rendering a persistent output. + +The interface remains transitional Flask and dependency-free JavaScript. The final editable +timeline, manual-event UI, presets, React workspace, stateful feedback, and codec +datamoshing remain deferred. diff --git a/docs/recipes.md b/docs/recipes.md index cc152aa..a1177f8 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -1,6 +1,6 @@ # Effect recipes -GlitchCraft 0.3.0 supports recipe schema versions 1 and 2. Recipe documents are +GlitchCraft 0.3.1 supports recipe schema versions 1 and 2. Recipe documents are independently versioned inside manifest-v2 records, so adding Recipe v2 does not change the manifest, storage, or video-job schemas. @@ -84,3 +84,11 @@ may contain Recipe v1 or Recipe v2. Existing files load without migration or rewriting. Recipe v2 persists only its portable contract; compiled intervals and random-generator state are never stored. Restart recovery recompiles the same schedule from the recipe, source timeline, effect ID, and isolated namespaces. +# Progressive Recipe v2 controls + +Basic controls are an authoring convenience, not a persisted schema. Basic Intensity is an +overall amount macro; Advanced Visual Strength remains the Recipe v2 `intensity` value and +Advanced Activity remains exact timing frequency/cooldown. Resolution stores only canonical +Recipe v2 fields. Profile version 1 is not needed to replay an output. Burst lengths are +deterministically selected inside the stored frame bounds, and changing the root seed +regenerates every stochastic pattern. See [progressive-controls.md](progressive-controls.md). diff --git a/docs/service-contract.md b/docs/service-contract.md index 1d5d739..0f4bb14 100644 --- a/docs/service-contract.md +++ b/docs/service-contract.md @@ -40,7 +40,7 @@ state. Readiness reports queue capacity, worker concurrency, manifest migration or reconciliation state, and both required video tools. Saved recipe management, WVO handoff, authentication, and a visible library interface are deferred. -Application version 0.3.0 advertises video-job contract v2 and the +Application version 0.3.1 advertises video-job contract v2 and the `video-render-telemetry` capability. `/metadata` publishes supported phase codes, the client-polling model, a four-Hz runtime update target, FFmpeg program-progress support, and redacted queue totals. The capability is available @@ -61,3 +61,13 @@ Temporal capability slugs are `temporal-effect-modulation`, events timing; all five envelope curves; and per-frame, per-event, and smooth variation. It does not claim feedback, optical flow, datamoshing, keyframes, or timeline editing. +# Progressive-control endpoints + +- `POST /api/effects/resolve-basic` resolves strict Basic contract v1 without media or writes. +- `POST /api/effects/infer-basic` reports exact, approximate, or custom ownership. +- `GET /api/effects` includes registry-owned Basic profile version 1 metadata. +- `/metadata` reports the versions, endpoints, supported effects, range 0–100, and Advanced + customization availability. + +Stable capability slugs are `progressive-effect-controls`, `basic-effect-intensity`, +`effect-burst-range`, and `effect-pattern-regeneration`. diff --git a/docs/storage.md b/docs/storage.md index 4d05028..eb78efd 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -1,6 +1,6 @@ # Persistent media storage -GlitchCraft 0.3.0 uses a configurable managed data root: +GlitchCraft 0.3.1 uses a configurable managed data root: ```text data/ @@ -58,7 +58,7 @@ diagnostics whose values and granularity may vary by platform. Video completion installs the final MP4 and marks its job completed in one manifest mutation. Intermediate job files remain temporary and are never served. -Manifest schema remains v2 in application 0.3.0. Telemetry contract v2 adds only +Manifest schema remains v2 in application 0.3.1. Telemetry contract v2 adds only backward-compatible job defaults: a phase, at most 32 durable milestones, a terminal timing summary, and coarse last-known frame counters. High-frequency FPS, ETA, encoded-time, queue-position, and stale calculations are runtime-only diff --git a/docs/temporal-effects.md b/docs/temporal-effects.md index 082695e..ca90a55 100644 --- a/docs/temporal-effects.md +++ b/docs/temporal-effects.md @@ -129,3 +129,9 @@ Temporal modulation evaluates independent decoded frames. It does not use previous-frame feedback, frame echo, motion trails, optical flow, or stateful blending. Codec datamoshing changes compressed-frame or motion-vector structures and remains a separate future processing domain. +# Progressive temporal authoring + +The Basic control resolver selects existing continuous or sporadic timing, envelope, and +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. diff --git a/docs/testing.md b/docs/testing.md index 85aae89..489fe9c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -101,3 +101,15 @@ one-hour 60 fps synthetic schedule containing 5,810 events compiled in 60.33 ms. These are diagnostic measurements, not platform guarantees; bounded event counts, one compile per job, binary-search lookup, and inactive-operation skipping are the enforced properties. +# Progressive-control validation + +Resolver matrices cover all eight effects at Basic Intensity 0, 25, 50, 75, and 100. +Round-trip inference, custom fields, duration bounds, strict APIs, metadata, accessibility, +responsive layout, seed regeneration, schedule refresh, and existing Recipe v1/v2 +regressions are covered. Benchmarks resolve and infer all eight profiles 10,000 times. + +On the PR development machine, 80,000 individual resolutions completed in 4.454 seconds +(55.676 µs per effect; 0.445 ms per batch of eight). After bounded candidate-cache warmup, +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. diff --git a/docs/video-workflow.md b/docs/video-workflow.md index 25d0e55..9772d7d 100644 --- a/docs/video-workflow.md +++ b/docs/video-workflow.md @@ -62,3 +62,11 @@ active effects. Moving the timestamp never changes the root seed or schedule, and the previous valid still remains visible while its replacement loads. Manual events are supported by the API contract but do not yet have a full editor. +# Basic video controls + +Enabled video effects show Basic Intensity, burst bounds, and a resolved summary before a +semantic Advanced disclosure. Advanced edits become Custom until explicitly reset. +Regenerating changes the shared root seed, not the source or effect fields. Schedule and +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. diff --git a/glitchcraft/contracts/progressive.py b/glitchcraft/contracts/progressive.py new file mode 100644 index 0000000..7b011c6 --- /dev/null +++ b/glitchcraft/contracts/progressive.py @@ -0,0 +1,75 @@ +"""Strict contracts for progressive Recipe v2 effect controls.""" + +from __future__ import annotations + +from typing import Annotated, ClassVar, Literal + +from pydantic import ConfigDict, Field, model_validator + +from glitchcraft.contracts.effects import ( + ContractModel, + EffectEnvelope, + EffectTiming, + EffectType, + VariationSpec, +) + +BASIC_CONTROL_CONTRACT_VERSION = 1 +BASIC_EFFECT_PROFILE_VERSION = 1 + + +class BasicSourceRange(ContractModel): + """Optional source window applied by the Basic resolver.""" + + start_seconds: Annotated[float, Field(alias="startSeconds", ge=0)] = 0 + end_seconds: Annotated[float, Field(alias="endSeconds", gt=0)] + model_config: ClassVar[ConfigDict] = ConfigDict( + extra="forbid", validate_assignment=True, populate_by_name=True + ) + + @model_validator(mode="after") + def validate_window(self) -> BasicSourceRange: + if self.end_seconds <= self.start_seconds: + raise ValueError("endSeconds must be later than startSeconds") + return self + + +class BasicEffectControlContract(ContractModel): + """Versioned user-facing macro input; never persisted in a Recipe.""" + + contract_version: Literal[1] = Field(default=1, alias="contractVersion") + effect_type: EffectType = Field(alias="effectType") + intensity: Annotated[int, Field(ge=0, le=100)] + minimum_duration_frames: Annotated[int, Field(alias="minimumDurationFrames", ge=1, le=3600)] + maximum_duration_frames: Annotated[int, Field(alias="maximumDurationFrames", ge=1, le=3600)] + source_range: BasicSourceRange | None = Field(default=None, alias="sourceRange") + regenerate_pattern: bool = Field(default=False, alias="regeneratePattern") + model_config: ClassVar[ConfigDict] = ConfigDict( + extra="forbid", validate_assignment=True, populate_by_name=True + ) + + @model_validator(mode="after") + def validate_duration_range(self) -> BasicEffectControlContract: + if self.minimum_duration_frames > self.maximum_duration_frames: + raise ValueError("minimumDurationFrames cannot exceed maximumDurationFrames") + return self + + +class ResolvedBasicEffect(ContractModel): + """Canonical Recipe v2 effect fields produced by the macro resolver.""" + + intensity: Annotated[float, Field(ge=0, le=1)] + timing: EffectTiming + envelope: EffectEnvelope + variation: dict[str, VariationSpec] + + +class BasicInferenceRequest(ContractModel): + """Strict request for reverse mapping a canonical Recipe v2 fragment.""" + + contract_version: Literal[1] = Field(default=1, alias="contractVersion") + effect_type: EffectType = Field(alias="effectType") + effect: ResolvedBasicEffect + model_config: ClassVar[ConfigDict] = ConfigDict( + extra="forbid", validate_assignment=True, populate_by_name=True + ) diff --git a/glitchcraft/effects/progressive.py b/glitchcraft/effects/progressive.py new file mode 100644 index 0000000..e2bfcbd --- /dev/null +++ b/glitchcraft/effects/progressive.py @@ -0,0 +1,365 @@ +"""Pure resolution and bounded inference for progressive Recipe v2 controls.""" + +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass +from functools import lru_cache +from typing import Any, Literal + +from glitchcraft.contracts.effects import ( + ContinuousTiming, + EffectEnvelope, + EffectInstanceV2, + EffectType, + SporadicTiming, +) +from glitchcraft.contracts.progressive import ( + BASIC_CONTROL_CONTRACT_VERSION, + BASIC_EFFECT_PROFILE_VERSION, + BasicEffectControlContract, + BasicSourceRange, + ResolvedBasicEffect, +) +from glitchcraft.effects.registry import ( + BASIC_CONTROL_PROFILES, + EFFECT_REGISTRY, + BasicControlProfile, +) + + +@dataclass(frozen=True) +class BasicResolution: + contract_version: int + profile_version: int + effect_type: EffectType + resolved: ResolvedBasicEffect + summary: dict[str, Any] + warnings: tuple[str, ...] = () + + +@dataclass(frozen=True) +class BasicInference: + contract_version: int + profile_version: int + effect_type: EffectType + status: Literal["exact", "approximate", "custom"] + intensity: int | None + nearest_intensity: int + minimum_duration_frames: int + maximum_duration_frames: int + summary: dict[str, Any] + + +def _smoothstep(value: float) -> float: + bounded = min(1.0, max(0.0, value)) + return bounded * bounded * (3.0 - 2.0 * bounded) + + +def _interpolate(low: float, high: float, response: float) -> float: + return low + ((high - low) * response) + + +def _duration_compatible_envelope( + envelope: EffectEnvelope, minimum_duration_frames: int +) -> EffectEnvelope: + attack = min(envelope.attack_frames, minimum_duration_frames // 2) + release = min(envelope.release_frames, minimum_duration_frames - attack) + return envelope.model_copy(update={"attack_frames": attack, "release_frames": release}) + + +def _activity_label(intensity: int) -> str: + if intensity == 0: + return "Inactive" + if intensity <= 20: + return "Very subtle" + if intensity <= 40: + return "Occasional" + if intensity <= 60: + return "Noticeable" + if intensity <= 80: + return "Frequent" + return "Aggressive" + + +def _summary( + *, + effect_type: EffectType, + intensity: int, + resolved: ResolvedBasicEffect, + minimum_duration_frames: int, + maximum_duration_frames: int, +) -> dict[str, Any]: + definition = EFFECT_REGISTRY[effect_type] + frequency = ( + resolved.timing.frequency_per_minute + if isinstance(resolved.timing, SporadicTiming) + else None + ) + behavior = ( + f"{_activity_label(intensity)} {definition.display_name.lower()}" + if frequency is not None + else f"Continuous {_activity_label(intensity).lower()} {definition.display_name.lower()}" + ) + return { + "label": behavior, + "frequencyPerMinute": frequency, + "durationFrames": { + "minimum": minimum_duration_frames, + "maximum": maximum_duration_frames, + }, + "timingMode": resolved.timing.mode, + "variation": ( + "Per-frame detail variation" + if any(item.mode == "perFrame" for item in resolved.variation.values()) + else "Coherent seeded variation" + if resolved.variation + else "No stochastic variation" + ), + } + + +def _profile(effect_type: EffectType) -> BasicControlProfile: + profile = BASIC_CONTROL_PROFILES.get(effect_type) + if profile is None or not profile.supported: + raise LookupError(f"Basic controls are not supported for {effect_type.value}.") + return profile + + +def resolve_basic_control(request: BasicEffectControlContract) -> BasicResolution: + """Resolve one Basic macro to strict canonical Recipe v2 effect fields.""" + + profile = _profile(request.effect_type) + allowed_minimum, allowed_maximum = profile.allowed_duration_frames + if not ( + allowed_minimum + <= request.minimum_duration_frames + <= request.maximum_duration_frames + <= allowed_maximum + ): + raise ValueError( + f"Duration frames must stay between {allowed_minimum} and {allowed_maximum}." + ) + + response = _smoothstep(request.intensity / 100) + strength = 0.0 + if request.intensity: + strength = round(_interpolate(*profile.strength_range, response), 4) + source_range = request.source_range + start_seconds = source_range.start_seconds if source_range else 0 + end_seconds = source_range.end_seconds if source_range else None + envelope = profile.envelope.model_copy(deep=True) + timing: ContinuousTiming | SporadicTiming + if profile.timing_mode == "sporadic": + if ( + profile.frequency_range is None + or profile.minimum_cooldown_range is None + or profile.maximum_cooldown_range is None + ): + raise RuntimeError(f"The Basic profile for {request.effect_type.value} is incomplete.") + frequency = round(_interpolate(*profile.frequency_range, response), 3) + minimum_cooldown = round(_interpolate(*profile.minimum_cooldown_range, response)) + maximum_cooldown = round(_interpolate(*profile.maximum_cooldown_range, response)) + timing = SporadicTiming( + mode="sporadic", + start_seconds=start_seconds, + end_seconds=end_seconds, + frequency_per_minute=frequency, + minimum_duration_frames=request.minimum_duration_frames, + maximum_duration_frames=request.maximum_duration_frames, + minimum_cooldown_frames=minimum_cooldown, + maximum_cooldown_frames=max(minimum_cooldown, maximum_cooldown), + ) + envelope = _duration_compatible_envelope(envelope, request.minimum_duration_frames) + else: + timing = ContinuousTiming(start_seconds=start_seconds, end_seconds=end_seconds) + + variation = { + name: specification.model_copy(deep=True) + for name, specification in profile.variation.items() + } + # Validate the complete combination against the authoritative Recipe v2 contract. + validated = EffectInstanceV2( + id="basic-resolution", + type=request.effect_type, + intensity=strength, + parameters=EFFECT_REGISTRY[request.effect_type].defaults, + timing=timing, + envelope=envelope, + variation=variation, + ) + resolved = ResolvedBasicEffect( + intensity=validated.intensity, + timing=validated.timing, + envelope=validated.envelope, + variation=validated.variation, + ) + warnings = ( + ("Regenerating changes the root recipe seed for every stochastic effect.",) + if request.regenerate_pattern + else () + ) + return BasicResolution( + BASIC_CONTROL_CONTRACT_VERSION, + BASIC_EFFECT_PROFILE_VERSION, + request.effect_type, + resolved, + _summary( + effect_type=request.effect_type, + intensity=request.intensity, + resolved=resolved, + minimum_duration_frames=request.minimum_duration_frames, + maximum_duration_frames=request.maximum_duration_frames, + ), + warnings, + ) + + +def _source_range(effect: ResolvedBasicEffect) -> BasicSourceRange | None: + timing = effect.timing + if isinstance(timing, ContinuousTiming | SporadicTiming) and ( + timing.start_seconds != 0 or timing.end_seconds is not None + ): + if timing.end_seconds is None: + return None + return BasicSourceRange( + start_seconds=timing.start_seconds, + end_seconds=timing.end_seconds, + ) + return None + + +def _duration_range(effect_type: EffectType, effect: ResolvedBasicEffect) -> tuple[int, int]: + if isinstance(effect.timing, SporadicTiming): + return ( + effect.timing.minimum_duration_frames, + effect.timing.maximum_duration_frames, + ) + return BASIC_CONTROL_PROFILES[effect_type].default_duration_frames + + +def _serialized(effect: ResolvedBasicEffect) -> dict[str, Any]: + return effect.model_dump(mode="json", by_alias=True) + + +@lru_cache(maxsize=512) +def _candidate_resolutions( + effect_type: EffectType, + minimum_duration: int, + maximum_duration: int, + start_seconds: float | None, + end_seconds: float | None, +) -> tuple[BasicResolution, ...]: + source_range = ( + BasicSourceRange(start_seconds=start_seconds or 0, end_seconds=end_seconds) + if end_seconds is not None + else None + ) + return tuple( + resolve_basic_control( + BasicEffectControlContract( + effect_type=effect_type, + intensity=intensity, + minimum_duration_frames=minimum_duration, + maximum_duration_frames=maximum_duration, + source_range=source_range, + ) + ) + for intensity in range(101) + ) + + +def infer_basic_control(effect_type: EffectType, effect: ResolvedBasicEffect) -> BasicInference: + """Infer exact/approximate/custom Basic ownership using a bounded search.""" + + _profile(effect_type) + # Validate variation channels and all cross-field constraints before inference. + validated = EffectInstanceV2( + id="basic-inference", + type=effect_type, + intensity=effect.intensity, + parameters=EFFECT_REGISTRY[effect_type].defaults, + timing=deepcopy(effect.timing), + envelope=deepcopy(effect.envelope), + variation=deepcopy(effect.variation), + ) + canonical = ResolvedBasicEffect( + intensity=validated.intensity, + timing=validated.timing, + envelope=validated.envelope, + variation=validated.variation, + ) + minimum_duration, maximum_duration = _duration_range(effect_type, canonical) + source_range = _source_range(canonical) + candidates = _candidate_resolutions( + effect_type, + minimum_duration, + maximum_duration, + source_range.start_seconds if source_range else None, + source_range.end_seconds if source_range else None, + ) + target = _serialized(canonical) + exact = next( + ( + intensity + for intensity, candidate in enumerate(candidates) + if _serialized(candidate.resolved) == target + ), + None, + ) + nearest = min( + range(101), + key=lambda intensity: abs(candidates[intensity].resolved.intensity - canonical.intensity), + ) + if exact is not None: + status: Literal["exact", "approximate", "custom"] = "exact" + else: + target_without_strength = {**target, "intensity": 0} + matching_activity = [ + intensity + for intensity, candidate in enumerate(candidates) + if { + **_serialized(candidate.resolved), + "intensity": 0, + } + == target_without_strength + ] + if matching_activity: + status = "approximate" + nearest = min( + matching_activity, + key=lambda intensity: abs( + candidates[intensity].resolved.intensity - canonical.intensity + ), + ) + else: + status = "custom" + summary = ( + candidates[exact].summary + if exact is not None + else { + **_summary( + effect_type=effect_type, + intensity=nearest, + resolved=canonical, + minimum_duration_frames=minimum_duration, + maximum_duration_frames=maximum_duration, + ), + "label": ( + f"Approximately Basic Intensity {nearest}" + if status == "approximate" + else "Custom advanced timing" + ), + } + ) + return BasicInference( + BASIC_CONTROL_CONTRACT_VERSION, + BASIC_EFFECT_PROFILE_VERSION, + effect_type, + status, + exact, + nearest, + minimum_duration, + maximum_duration, + summary, + ) diff --git a/glitchcraft/effects/registry.py b/glitchcraft/effects/registry.py index 76bc937..ba7dec2 100644 --- a/glitchcraft/effects/registry.py +++ b/glitchcraft/effects/registry.py @@ -60,6 +60,29 @@ class EffectDefinition: intensity_scaling: bool = True +@dataclass(frozen=True) +class BasicControlProfile: + """Registry-owned mapping from approachable controls to Recipe v2 fields.""" + + default_intensity: int + default_duration_frames: tuple[int, int] + allowed_duration_frames: tuple[int, int] + strength_range: tuple[float, float] + timing_mode: str + frequency_range: tuple[float, float] | None + minimum_cooldown_range: tuple[int, int] | None + maximum_cooldown_range: tuple[int, int] | None + envelope: EffectEnvelope + variation: dict[str, VariationSpec] + intensity_label: str + helper_text: str + affects_strength: bool = True + affects_activity: bool = True + duration_user_controllable: bool = True + supported: bool = True + profile_version: int = 1 + + def _definition( effect_type: EffectType, name: str, @@ -299,6 +322,131 @@ def _channel( } +BASIC_CONTROL_PROFILES: dict[EffectType, BasicControlProfile] = { + EffectType.NOISE: BasicControlProfile( + 35, + (6, 18), + (1, 3600), + (0.08, 1.0), + "continuous", + None, + None, + None, + EffectEnvelope(), + {"detail": PerFrameVariation(mode="perFrame")}, + "Noise intensity", + "Controls noise amount and visual strength while remaining continuous.", + affects_activity=False, + duration_user_controllable=False, + ), + EffectType.PIXELATION: BasicControlProfile( + 35, + (24, 60), + (1, 3600), + (0.2, 0.9), + "sporadic", + (0.5, 10.0), + (150, 20), + (420, 80), + EffectEnvelope(attack_frames=8, release_frames=12, curve=EnvelopeCurve.EASE_IN_OUT), + {}, + "Pixelation intensity", + "Controls block severity and how often pixelation bursts occur.", + ), + EffectType.HORIZONTAL_GLITCH: BasicControlProfile( + 40, + (6, 18), + (1, 3600), + (0.2, 1.0), + "sporadic", + (1.0, 24.0), + (120, 8), + (360, 30), + EffectEnvelope(attack_frames=1, release_frames=5, curve=EnvelopeCurve.EASE_OUT), + { + "layout": PerEventVariation(mode="perEvent"), + "offset": SmoothVariation(mode="smooth", period_frames=6), + }, + "Glitch intensity", + "Controls displacement strength and the amount of glitch activity.", + ), + EffectType.FRAME_SHIFT: BasicControlProfile( + 30, + (2, 8), + (1, 3600), + (0.15, 0.85), + "sporadic", + (0.5, 10.0), + (180, 15), + (480, 80), + EffectEnvelope(attack_frames=0, release_frames=2, curve=EnvelopeCurve.EASE_OUT), + {"offset": PerEventVariation(mode="perEvent")}, + "Shift intensity", + "Controls translation distance and occasional full-frame shifts.", + ), + EffectType.COLOR_BLEED: BasicControlProfile( + 35, + (18, 48), + (1, 3600), + (0.15, 0.9), + "sporadic", + (1.0, 14.0), + (120, 12), + (360, 50), + EffectEnvelope(attack_frames=6, release_frames=10, curve=EnvelopeCurve.EASE_IN_OUT), + {"channels": SmoothVariation(mode="smooth", period_frames=8)}, + "Color bleed intensity", + "Controls channel separation and how often smooth color drift appears.", + ), + EffectType.SCAN_LINES: BasicControlProfile( + 30, + (6, 18), + (1, 3600), + (0.1, 0.85), + "continuous", + None, + None, + None, + EffectEnvelope(), + {}, + "Scan-line intensity", + "Controls line darkness while preserving continuous scan lines.", + affects_activity=False, + duration_user_controllable=False, + ), + EffectType.STATIC: BasicControlProfile( + 30, + (6, 18), + (1, 3600), + (0.08, 0.9), + "continuous", + None, + None, + None, + EffectEnvelope(), + {"detail": PerFrameVariation(mode="perFrame")}, + "Static intensity", + "Primarily controls static density and strength on every frame.", + affects_activity=False, + duration_user_controllable=False, + ), + EffectType.FLICKER: BasicControlProfile( + 25, + (3, 10), + (1, 3600), + (0.1, 0.75), + "sporadic", + (1.0, 18.0), + (90, 10), + (240, 45), + EffectEnvelope(attack_frames=1, release_frames=2, curve=EnvelopeCurve.EASE_OUT), + {"level": PerFrameVariation(mode="perFrame")}, + "Flicker intensity", + "Controls pulse brightness and frequency without continuous flashing.", + ), +} + + def get_effect_definition(effect_type: EffectType) -> EffectDefinition: return EFFECT_REGISTRY[effect_type] diff --git a/glitchcraft/service_contract.py b/glitchcraft/service_contract.py index f99b344..a24fe5e 100644 --- a/glitchcraft/service_contract.py +++ b/glitchcraft/service_contract.py @@ -5,7 +5,8 @@ import shutil from typing import Any -from glitchcraft.effects.registry import EFFECT_REGISTRY +from glitchcraft.contracts.progressive import BASIC_EFFECT_PROFILE_VERSION +from glitchcraft.effects.registry import BASIC_CONTROL_PROFILES, EFFECT_REGISTRY from glitchcraft.media.image_io import SUPPORTED_IMAGE_FORMATS CAPABILITY_SLUGS = ( @@ -27,6 +28,10 @@ "timestamp-video-preview", "audio-preserving-video-export", "http-range-video-streaming", + "progressive-effect-controls", + "basic-effect-intensity", + "effect-burst-range", + "effect-pattern-regeneration", ) SUPPORTED_VIDEO_EXTENSIONS = ("avi", "mkv", "mov", "mp4") @@ -59,6 +64,7 @@ def effect_metadata() -> list[dict[str, Any]]: "stochastic": definition.stochastic, "temporalSupport": True, "intensityScaling": definition.intensity_scaling, + "basicControls": _basic_profile_metadata(definition.type), "naturalVideoDefaults": { "timing": definition.temporal_defaults.timing.model_dump( by_alias=True, mode="json" @@ -83,6 +89,50 @@ def effect_metadata() -> list[dict[str, Any]]: ] +def _basic_profile_metadata(effect_type: Any) -> dict[str, Any]: + profile = BASIC_CONTROL_PROFILES[effect_type] + return { + "supported": profile.supported, + "profileVersion": BASIC_EFFECT_PROFILE_VERSION, + "defaultIntensity": profile.default_intensity, + "intensityRange": {"minimum": 0, "maximum": 100}, + "defaultDurationFrames": { + "minimum": profile.default_duration_frames[0], + "maximum": profile.default_duration_frames[1], + }, + "allowedDurationFrames": { + "minimum": profile.allowed_duration_frames[0], + "maximum": profile.allowed_duration_frames[1], + }, + "strengthRange": { + "minimum": profile.strength_range[0], + "maximum": profile.strength_range[1], + }, + "frequencyRange": ( + { + "minimum": profile.frequency_range[0], + "maximum": profile.frequency_range[1], + } + if profile.frequency_range + else None + ), + "timingMode": profile.timing_mode, + "intensityLabel": profile.intensity_label, + "intensityHelperText": profile.helper_text, + "affects": { + "strength": profile.affects_strength, + "activity": profile.affects_activity, + }, + "durationUserControllable": profile.duration_user_controllable, + "advancedControlsAvailable": True, + "envelopeDefaults": profile.envelope.model_dump(by_alias=True, mode="json"), + "variationDefaults": { + name: specification.model_dump(by_alias=True, mode="json") + for name, specification in profile.variation.items() + }, + } + + def capability_details( *, storage_available: bool, video_manager_running: bool = True ) -> list[dict[str, Any]]: @@ -156,6 +206,10 @@ def capability_details( "deterministic-effect-schedules", "effect-envelopes", "coherent-effect-variation", + "progressive-effect-controls", + "basic-effect-intensity", + "effect-burst-range", + "effect-pattern-regeneration", ) ], { diff --git a/glitchcraft/version.py b/glitchcraft/version.py index c504205..aea8e7c 100644 --- a/glitchcraft/version.py +++ b/glitchcraft/version.py @@ -3,7 +3,7 @@ APP_ID = "glitchcraft" APP_NAME = "GlitchCraft" APP_DESCRIPTOR = "Local visual-effects workspace" -APP_VERSION = "0.3.0" +APP_VERSION = "0.3.1" MANIFEST_SCHEMA_VERSION = 2 RECIPE_SCHEMA_VERSION = 2 STORAGE_SCHEMA_VERSION = 2 diff --git a/glitchcraft/web/routes.py b/glitchcraft/web/routes.py index b260883..5e462f1 100644 --- a/glitchcraft/web/routes.py +++ b/glitchcraft/web/routes.py @@ -27,7 +27,14 @@ 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.progressive import ( + BASIC_CONTROL_CONTRACT_VERSION, + BASIC_EFFECT_PROFILE_VERSION, + BasicEffectControlContract, + BasicInferenceRequest, +) from glitchcraft.effects.engine import apply_effect_stack +from glitchcraft.effects.progressive import infer_basic_control, resolve_basic_control from glitchcraft.effects.registry import EFFECT_REGISTRY, SPORADIC_CONTROL_DEFAULTS from glitchcraft.effects.temporal import MediaTimelineContext, compile_recipe from glitchcraft.errors import ExternalToolError, GlitchCraftError, QueueCapacityError @@ -419,6 +426,15 @@ def metadata() -> Response | tuple[Response, int]: "scheduleInspection": "/api/video-sources/{sourceId}/effect-schedule", "effectMetadata": url_for("glitchcraft.effects"), }, + progressiveEffectControls={ + "contractVersion": BASIC_CONTROL_CONTRACT_VERSION, + "profileVersion": BASIC_EFFECT_PROFILE_VERSION, + "resolver": url_for("glitchcraft.resolve_basic_effect"), + "inference": url_for("glitchcraft.infer_basic_effect"), + "supportedEffects": [effect_type.value for effect_type in EFFECT_REGISTRY], + "advancedCustomizationAvailable": True, + "intensityRange": {"minimum": 0, "maximum": 100}, + }, library={ "imageSources": len(repository.list_sources()) if repository.available else 0, "imageOutputs": len(repository.list_outputs()) if repository.available else 0, @@ -574,11 +590,61 @@ def effects() -> Response: schemaVersion=1, recipeSchemaVersion=RECIPE_SCHEMA_VERSION, supportedRecipeSchemaVersions=[1, 2], + basicControlContractVersion=BASIC_CONTROL_CONTRACT_VERSION, + basicEffectProfileVersion=BASIC_EFFECT_PROFILE_VERSION, + basicResolver=url_for("glitchcraft.resolve_basic_effect"), + basicInference=url_for("glitchcraft.infer_basic_effect"), sporadicControlDefaults=SPORADIC_CONTROL_DEFAULTS.model_dump(by_alias=True, mode="json"), effects=effect_metadata(), ) +@bp.post("/api/effects/resolve-basic") +def resolve_basic_effect() -> Response | tuple[Response, int]: + try: + resolution = resolve_basic_control( + BasicEffectControlContract.model_validate(request.get_json(silent=True)) + ) + response = jsonify( + contractVersion=resolution.contract_version, + profileVersion=resolution.profile_version, + effectType=resolution.effect_type, + resolved=resolution.resolved.model_dump(by_alias=True, mode="json"), + summary=resolution.summary, + warnings=list(resolution.warnings), + ) + response.headers["Cache-Control"] = "private, max-age=300" + return response + except LookupError as exc: + return jsonify(status="error", message=str(exc)), 404 + except (ValidationError, ValueError) as exc: + return _validation_error(exc) + + +@bp.post("/api/effects/infer-basic") +def infer_basic_effect() -> Response | tuple[Response, int]: + try: + payload = BasicInferenceRequest.model_validate(request.get_json(silent=True)) + inference = infer_basic_control(payload.effect_type, payload.effect) + response = jsonify( + contractVersion=inference.contract_version, + profileVersion=inference.profile_version, + effectType=inference.effect_type, + status=inference.status, + intensity=inference.intensity, + nearestIntensity=inference.nearest_intensity, + minimumDurationFrames=inference.minimum_duration_frames, + maximumDurationFrames=inference.maximum_duration_frames, + summary=inference.summary, + ) + response.headers["Cache-Control"] = "private, max-age=300" + return response + except LookupError as exc: + return jsonify(status="error", message=str(exc)), 404 + except (ValidationError, ValueError) as exc: + return _validation_error(exc) + + @bp.get("/api/storage") def storage_status() -> Response | tuple[Response, int]: try: diff --git a/pyproject.toml b/pyproject.toml index 13aaa32..48bd268 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "glitchcraft" -version = "0.3.0" +version = "0.3.1" 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 6edebd8..e132947 100644 --- a/static/app-manifest.json +++ b/static/app-manifest.json @@ -17,7 +17,11 @@ "coherent-effect-variation", "timestamp-video-preview", "audio-preserving-video-export", - "http-range-video-streaming" + "http-range-video-streaming", + "progressive-effect-controls", + "basic-effect-intensity", + "effect-burst-range", + "effect-pattern-regeneration" ], "defaults": { "apiAddress": "http://127.0.0.1:5000", @@ -34,5 +38,5 @@ "id": "glitchcraft", "name": "GlitchCraft", "schemaVersion": 1, - "version": "0.3.0" + "version": "0.3.1" } diff --git a/static/app.js b/static/app.js index 3cef8a2..d92b30f 100644 --- a/static/app.js +++ b/static/app.js @@ -48,6 +48,10 @@ effectMetadata: new Map(), sporadicDefaults: null, effectMetadataPromise: null, + basicControllers: new Map(), + basicTimers: new Map(), + applyingBasicResolution: false, + lastSchedule: null, }; // Kept inspectable while this transitional interface remains in one script. @@ -172,7 +176,11 @@ const mode = select.value; const value = {mode}; if (mode === "smooth") { - value.periodFrames = Number(select.dataset.periodFrames || 6); + value.periodFrames = Number( + panel.querySelector( + `[data-variation-period="${CSS.escape(select.dataset.variationChannel)}"]`, + )?.value || select.dataset.periodFrames || 6, + ); } return [select.dataset.variationChannel, value]; }), @@ -248,17 +256,267 @@ return select; } + function basicField(section, effectId, labelText, role, options = {}) { + const input = labeledInput(section, effectId, labelText, `basic-${role}`, options); + input.dataset.basicRole = role; + return input; + } + + function setBasicOwnership(panel, ownership, message = "") { + panel.dataset.configurationOwnership = ownership; + const status = panel.querySelector("[data-basic-status]"); + const reset = panel.querySelector("[data-reset-basic]"); + status.textContent = + message || + (ownership === "custom" ? "Advanced timing is customized." : "Using Basic-derived settings."); + reset.hidden = ownership !== "custom"; + } + + function applyResolvedSettings(panel, resolved) { + imageState.applyingBasicResolution = true; + try { + panel.querySelector('[data-temporal-role="intensity"]').value = String(resolved.intensity); + panel.querySelector('[data-temporal-role="mode"]').value = resolved.timing.mode; + panel.querySelector('[data-temporal-role="start"]').value = String( + resolved.timing.startSeconds ?? 0, + ); + panel.querySelector('[data-temporal-role="end"]').value = + resolved.timing.endSeconds ?? ""; + if (resolved.timing.mode === "sporadic") { + const values = { + frequency: resolved.timing.frequencyPerMinute, + "minimum-duration": resolved.timing.minimumDurationFrames, + "maximum-duration": resolved.timing.maximumDurationFrames, + "minimum-cooldown": resolved.timing.minimumCooldownFrames, + "maximum-cooldown": resolved.timing.maximumCooldownFrames, + }; + for (const [role, value] of Object.entries(values)) { + panel.querySelector(`[data-temporal-role="${role}"]`).value = String(value); + } + } + panel.querySelector('[data-temporal-role="attack"]').value = String( + resolved.envelope.attackFrames, + ); + panel.querySelector('[data-temporal-role="release"]').value = String( + resolved.envelope.releaseFrames, + ); + panel.querySelector('[data-temporal-role="curve"]').value = resolved.envelope.curve; + for (const [channel, specification] of Object.entries(resolved.variation)) { + const select = panel.querySelector(`[data-variation-channel="${channel}"]`); + if (select) { + select.value = specification.mode; + select.dataset.periodFrames = String(specification.periodFrames || 6); + const period = panel.querySelector(`[data-variation-period="${channel}"]`); + if (period) { + period.value = String(specification.periodFrames || 6); + } + } + } + } finally { + imageState.applyingBasicResolution = false; + } + } + + async function resolveBasicSettings(effectId, {announce = false} = {}) { + const panel = temporalPanel(effectId); + const metadata = imageState.effectMetadata.get(panel.dataset.effectType); + const minimum = Number(panel.querySelector('[data-basic-role="minimum-duration"]').value); + const maximum = Number(panel.querySelector('[data-basic-role="maximum-duration"]').value); + const validation = panel.querySelector("[data-basic-validation]"); + if (minimum > maximum) { + validation.textContent = "Minimum burst length cannot exceed maximum burst length."; + return false; + } + validation.textContent = ""; + imageState.basicControllers.get(effectId)?.abort(); + const controller = new AbortController(); + imageState.basicControllers.set(effectId, controller); + const payload = { + contractVersion: 1, + effectType: metadata.type, + intensity: Number(panel.querySelector('[data-basic-role="intensity-number"]').value), + minimumDurationFrames: minimum, + maximumDurationFrames: maximum, + }; + try { + const response = await fetch("/api/effects/resolve-basic", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(payload), + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(await errorMessage(response, "Basic controls could not be resolved.")); + } + const resolution = await response.json(); + if (imageState.basicControllers.get(effectId) !== controller) { + return false; + } + applyResolvedSettings(panel, resolution.resolved); + panel.querySelector("[data-basic-summary]").textContent = [ + resolution.summary.label, + resolution.summary.frequencyPerMinute === null + ? "Continuous activity" + : `About ${resolution.summary.frequencyPerMinute} events per minute`, + `${minimum}–${maximum} frames${metadata.basicControls.durationUserControllable ? " per burst" : " (used only after choosing burst timing)"}`, + ].join(" · "); + setBasicOwnership( + panel, + "basic", + announce ? "Basic controls replaced the previous custom settings." : "", + ); + updateTemporalVisibility(); + return true; + } catch (error) { + if (error.name !== "AbortError") { + validation.textContent = error.message; + } + return false; + } finally { + if (imageState.basicControllers.get(effectId) === controller) { + imageState.basicControllers.delete(effectId); + } + } + } + + function scheduleBasicResolution(effectId) { + window.clearTimeout(imageState.basicTimers.get(effectId)); + imageState.basicTimers.set( + effectId, + window.setTimeout(async () => { + const panel = temporalPanel(effectId); + const wasCustom = panel.dataset.configurationOwnership === "custom"; + if (await resolveBasicSettings(effectId, {announce: wasCustom})) { + scheduleTemporalRefresh(); + } + }, 180), + ); + } + + function markAdvancedCustom(panel) { + const effectId = panel.dataset.effectId; + window.clearTimeout(imageState.basicTimers.get(effectId)); + imageState.basicTimers.delete(effectId); + imageState.basicControllers.get(effectId)?.abort(); + imageState.basicControllers.delete(effectId); + setBasicOwnership(panel, "custom"); + panel.querySelector("[data-basic-summary]").textContent = + "Custom advanced timing · Exact values are shown below."; + } + function installTimingPanel(card, metadata) { const effectId = card.dataset.effectId; const defaults = metadata.naturalVideoDefaults; const timing = defaults.timing; const envelope = defaults.envelope; const sporadicDefaults = imageState.sporadicDefaults; + const container = document.createElement("div"); + container.className = "temporal-control"; + container.dataset.effectId = effectId; + container.dataset.effectType = metadata.type; + container.dataset.configurationOwnership = "basic"; + const basic = document.createElement("fieldset"); + basic.className = "basic-effect-controls"; + const basicLegend = document.createElement("legend"); + basicLegend.textContent = "Basic controls"; + const basicGrid = document.createElement("div"); + basicGrid.className = "basic-control-grid"; + const intensityRange = basicField( + basicGrid, + effectId, + metadata.basicControls.intensityLabel, + "intensity", + { + type: "range", + min: metadata.basicControls.intensityRange.minimum, + max: metadata.basicControls.intensityRange.maximum, + step: "1", + value: metadata.basicControls.defaultIntensity, + }, + ); + intensityRange.setAttribute( + "aria-describedby", + `basic-${effectId}-helper basic-${effectId}-status`, + ); + basicField(basicGrid, effectId, "Intensity value", "intensity-number", { + min: metadata.basicControls.intensityRange.minimum, + max: metadata.basicControls.intensityRange.maximum, + step: "1", + value: metadata.basicControls.defaultIntensity, + }); + const minimumDuration = basicField( + basicGrid, + effectId, + "Minimum burst length (frames)", + "minimum-duration", + { + min: metadata.basicControls.allowedDurationFrames.minimum, + max: metadata.basicControls.allowedDurationFrames.maximum, + step: "1", + value: metadata.basicControls.defaultDurationFrames.minimum, + }, + ); + const maximumDuration = basicField( + basicGrid, + effectId, + "Maximum burst length (frames)", + "maximum-duration", + { + min: metadata.basicControls.allowedDurationFrames.minimum, + max: metadata.basicControls.allowedDurationFrames.maximum, + step: "1", + value: metadata.basicControls.defaultDurationFrames.maximum, + }, + ); + minimumDuration.disabled = maximumDuration.disabled = + !metadata.basicControls.durationUserControllable; + const helper = document.createElement("p"); + helper.id = `basic-${effectId}-helper`; + helper.className = "field-help"; + helper.textContent = metadata.basicControls.intensityHelperText; + const durationContext = document.createElement("p"); + durationContext.dataset.durationContext = ""; + durationContext.className = "field-help"; + const basicSummary = document.createElement("p"); + basicSummary.dataset.basicSummary = ""; + basicSummary.className = "basic-summary"; + const validation = document.createElement("p"); + validation.dataset.basicValidation = ""; + validation.className = "field-error"; + validation.id = `basic-${effectId}-validation`; + validation.setAttribute("role", "alert"); + minimumDuration.setAttribute("aria-describedby", validation.id); + maximumDuration.setAttribute("aria-describedby", validation.id); + const ownership = document.createElement("p"); + ownership.id = `basic-${effectId}-status`; + ownership.dataset.basicStatus = ""; + ownership.className = "quiet-status"; + ownership.textContent = "Using Basic-derived settings."; + const reset = document.createElement("button"); + reset.type = "button"; + reset.className = "action action-quiet compact-action"; + reset.dataset.resetBasic = ""; + reset.textContent = "Reset to Basic controls"; + reset.hidden = true; + reset.addEventListener("click", async () => { + if (await resolveBasicSettings(effectId, {announce: true})) { + scheduleTemporalRefresh(); + } + }); + basic.append( + basicLegend, + basicGrid, + helper, + durationContext, + basicSummary, + validation, + ownership, + reset, + ); const details = document.createElement("details"); - details.className = "temporal-control"; - details.dataset.effectId = effectId; + details.className = "advanced-effect-controls"; const summary = document.createElement("summary"); - summary.textContent = "Advanced timing"; + summary.textContent = "Advanced controls"; const fieldset = document.createElement("fieldset"); const legend = document.createElement("legend"); legend.textContent = `${metadata.name} timing`; @@ -296,7 +554,7 @@ labeledInput(sporadic, effectId, "Events per minute", "frequency", { min: "0.1", max: "120", - step: "0.1", + step: "any", value: timing.frequencyPerMinute ?? sporadicDefaults.frequencyPerMinute, }); labeledInput(sporadic, effectId, "Minimum duration (frames)", "minimum-duration", { @@ -338,10 +596,10 @@ step: "1", value: envelope.releaseFrames, }); - labeledInput(envelopeFields, effectId, "Maximum intensity", "intensity", { + labeledInput(envelopeFields, effectId, "Visual Strength", "intensity", { min: "0", max: "1", - step: "0.05", + step: "any", value: "1", }); labeledSelect( @@ -374,14 +632,38 @@ ); select.dataset.variationChannel = channelName; select.dataset.periodFrames = String(channel.default.periodFrames || 6); + if (channel.supportedModes.includes("smooth")) { + const period = labeledInput( + fieldset, + effectId, + `${channelName} smooth period (frames)`, + `variation-period-${channelName}`, + { + min: "2", + max: "3600", + step: "1", + value: channel.default.periodFrames || 6, + }, + ); + period.dataset.variationPeriod = channelName; + } } const help = document.createElement("p"); help.className = "field-help"; help.textContent = "Durations and cooldowns use decoded frames. Schedules are seeded and repeat after restart."; fieldset.append(help); + const seed = document.createElement("p"); + seed.className = "field-help"; + seed.textContent = "Recipe seed: "; + const seedValue = document.createElement("output"); + seedValue.dataset.recipeSeed = ""; + seedValue.textContent = imageState.seed ?? "assigned after upload"; + seed.append(seedValue); + fieldset.append(seed); details.append(summary, fieldset); - card.append(details); + container.append(basic, details); + card.append(container); } async function loadEffectMetadata() { @@ -391,6 +673,7 @@ } const payload = await response.json(); imageState.sporadicDefaults = payload.sporadicControlDefaults; + const resolutions = []; for (const metadata of payload.effects) { imageState.effectMetadata.set(metadata.type, metadata); const card = document.querySelector( @@ -398,8 +681,10 @@ ); if (card) { installTimingPanel(card, metadata); + resolutions.push(resolveBasicSettings(card.dataset.effectId)); } } + await Promise.all(resolutions); updateTemporalVisibility(); } @@ -419,6 +704,31 @@ } const end = panel.querySelector('[data-temporal-role="end"]'); end.required = mode === "range"; + for (const select of panel.querySelectorAll("[data-variation-channel]")) { + const period = panel.querySelector( + `[data-variation-period="${CSS.escape(select.dataset.variationChannel)}"]`, + ); + if (period) { + period.disabled = select.value !== "smooth"; + period.closest(".temporal-field").hidden = period.disabled; + } + } + } + } + + function updateBasicDurationContexts() { + const frameRate = Number(imageState.videoSource?.frameRate || 0); + for (const panel of document.querySelectorAll(".temporal-control")) { + const minimum = Number( + panel.querySelector('[data-basic-role="minimum-duration"]').value, + ); + const maximum = Number( + panel.querySelector('[data-basic-role="maximum-duration"]').value, + ); + panel.querySelector("[data-duration-context]").textContent = + frameRate > 0 + ? `${(minimum / frameRate).toFixed(2)}–${(maximum / frameRate).toFixed(2)} seconds at ${frameRate.toFixed(2)} fps` + : "Frame durations will be shown in seconds after a video is uploaded."; } } @@ -656,6 +966,10 @@ const source = await response.json(); imageState.videoSource = source; imageState.seed = source.seed; + for (const output of document.querySelectorAll("[data-recipe-seed]")) { + output.textContent = String(source.seed); + } + updateBasicDurationContexts(); document.querySelector("#video-source-metadata").textContent = `${source.originalName} · ${source.width} × ${source.height} · ` + `${source.durationSeconds.toFixed(2)}s · ${source.videoCodec}` + @@ -714,7 +1028,7 @@ ? `${activeCount} scheduled ${activeCount === 1 ? "effect" : "effects"} active: ${activeIds .map((id) => effectNames[buildRecipeV1().effects.find((effect) => effect.id === id)?.type] || id) .join(", ")}.` - : "No scheduled effects are active at this preview time."; + : "No scheduled effects are active here."; } catch (error) { if (error.name !== "AbortError" && revision === imageState.videoPreviewRevision) { setNotice(error.message, "error"); @@ -742,12 +1056,17 @@ } function renderEffectSchedule(schedule) { + imageState.lastSchedule = schedule; const enabledIds = new Set( buildRecipeV1() .effects.filter((effect) => effect.enabled) .map((effect) => effect.id), ); const effects = schedule.effects.filter((effect) => enabledIds.has(effect.effectId)); + const [rateNumerator, rateDenominator = 1] = String(schedule.frameRate) + .split("/") + .map(Number); + const frameRate = rateNumerator / rateDenominator; const summaries = effects.map((effect) => { const name = effectNames[effect.type] || effect.type; if (effect.mode === "continuous" || effect.mode === "range") { @@ -759,9 +1078,11 @@ : `${name}: inactive in the selected range`; } const first = effect.events[0]; + const affectedSeconds = + effect.events.reduce((total, event) => total + event.durationFrames, 0) / frameRate; return `${name}: ${effect.eventCount} ${effect.eventCount === 1 ? "burst" : "bursts"}${ first ? `, first at ${formatScheduleTime(first.startSeconds)}` : "" - }`; + }, about ${affectedSeconds.toFixed(2)}s affected`; }); document.querySelector("#schedule-status").textContent = summaries.join(" · ") || "No enabled effects are scheduled."; @@ -782,6 +1103,9 @@ } eventList.append(item); } + document.querySelector("#jump-next-glitch").disabled = !effects.some( + (effect) => effect.events.length, + ); } async function requestEffectSchedule() { @@ -793,7 +1117,11 @@ const controller = new AbortController(); const revision = ++imageState.scheduleRevision; imageState.scheduleController = controller; - document.querySelector("#schedule-status").textContent = "Compiling deterministic schedule…"; + document.querySelector("#schedule-summary").setAttribute("aria-busy", "true"); + if (!imageState.lastSchedule) { + document.querySelector("#schedule-status").textContent = + "Compiling deterministic schedule…"; + } try { const response = await fetch( `/api/video-sources/${encodeURIComponent(source.sourceId)}/effect-schedule`, @@ -816,6 +1144,9 @@ document.querySelector("#schedule-status").textContent = error.message; } } finally { + if (revision === imageState.scheduleRevision) { + document.querySelector("#schedule-summary").setAttribute("aria-busy", "false"); + } if (imageState.scheduleController === controller) { imageState.scheduleController = null; } @@ -824,6 +1155,7 @@ function scheduleTemporalRefresh() { updateTemporalVisibility(); + updateBasicDurationContexts(); schedulePreview(); window.clearTimeout(imageState.scheduleTimer); if (imageState.videoSource && currentMode() === "video") { @@ -831,6 +1163,40 @@ } } + function nextScheduledEvent() { + const events = (imageState.lastSchedule?.effects || []) + .filter((effect) => effect.mode === "sporadic") + .flatMap((effect) => effect.events || []) + .sort((left, right) => left.startSeconds - right.startSeconds); + if (!events.length) { + return; + } + const time = Number(document.querySelector("#video-preview-time").value); + const next = events.find((event) => event.startSeconds > time + 0.000001); + const selected = next || events[0]; + document.querySelector("#video-preview-time").value = String(selected.startSeconds); + document.querySelector("#preview-active-state").textContent = next + ? `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(); + } + + function regeneratePatterns() { + const values = new Uint32Array(2); + crypto.getRandomValues(values); + const generated = + (values[0] * 0x200000 + (values[1] >>> 11)) % Number.MAX_SAFE_INTEGER; + imageState.seed = + generated === imageState.seed ? (generated + 1) % Number.MAX_SAFE_INTEGER : generated; + for (const output of document.querySelectorAll("[data-recipe-seed]")) { + output.textContent = String(imageState.seed); + } + document.querySelector("#pattern-status").textContent = + `All stochastic effect patterns regenerated with seed ${imageState.seed}.`; + requestEffectSchedule(); + requestVideoPreview(); + } + const effectNames = { noise: "Noise", pixelation: "Pixelation", @@ -1219,13 +1585,39 @@ if (event.target.matches('input[type="range"]')) { updateRangeOutputs(); } - if (event.target.closest("#effect-controls")) { + const basicRole = event.target.dataset.basicRole; + if (basicRole) { + const panel = event.target.closest(".temporal-control"); + if (basicRole === "intensity") { + panel.querySelector('[data-basic-role="intensity-number"]').value = + event.target.value; + } else if (basicRole === "intensity-number") { + panel.querySelector('[data-basic-role="intensity"]').value = event.target.value; + } + scheduleBasicResolution(panel.dataset.effectId); + } else if ( + event.target.matches("[data-temporal-role]") && + !imageState.applyingBasicResolution + ) { + const panel = event.target.closest(".temporal-control"); + markAdvancedCustom(panel); + scheduleTemporalRefresh(); + } else if (event.target.closest("#effect-controls")) { scheduleTemporalRefresh(); } }); form.addEventListener("change", (event) => { if (event.target.name === "mode") { updateMode(); + } else if (event.target.dataset.basicRole) { + scheduleBasicResolution(event.target.closest(".temporal-control").dataset.effectId); + } else if ( + event.target.matches("[data-temporal-role]") && + !imageState.applyingBasicResolution + ) { + const panel = event.target.closest(".temporal-control"); + markAdvancedCustom(panel); + scheduleTemporalRefresh(); } else if (event.target.closest("#effect-controls")) { scheduleTemporalRefresh(); } @@ -1248,6 +1640,8 @@ newImageButton.addEventListener("click", resetImageWorkspace); document.querySelector("#process-full").addEventListener("click", processFullVideo); document.querySelector("#video-preview-time").addEventListener("input", schedulePreview); + document.querySelector("#jump-next-glitch").addEventListener("click", nextScheduledEvent); + document.querySelector("#regenerate-patterns").addEventListener("click", regeneratePatterns); document.querySelector("#cancel-processing").addEventListener("click", async () => { if (imageState.videoJobId && !imageState.videoCancelPending) { imageState.videoCancelPending = true; @@ -1278,6 +1672,8 @@ imageState.videoPollRevision += 1; imageState.videoJobId = null; imageState.videoSource = null; + imageState.lastSchedule = null; + document.querySelector("#jump-next-glitch").disabled = true; fileInput.value = ""; fileInput.focus(); }); @@ -1293,6 +1689,9 @@ stopVideoPolling(); releaseVideoPreview(); imageState.scheduleController?.abort(); + for (const controller of imageState.basicControllers.values()) { + controller.abort(); + } window.clearTimeout(imageState.scheduleTimer); }); })(); diff --git a/static/style.css b/static/style.css index 253dd98..1d88846 100644 --- a/static/style.css +++ b/static/style.css @@ -486,6 +486,47 @@ progress { padding-top: var(--space-3); } +.basic-effect-controls { + display: grid; + gap: var(--space-2); +} + +.basic-control-grid { + display: grid; + grid-template-columns: minmax(10rem, 2fr) minmax(6rem, 0.7fr) repeat(2, minmax(8rem, 1fr)); + gap: var(--space-2); + align-items: end; +} + +.basic-summary { + margin: 0; + font-weight: 700; +} + +.field-error { + min-height: 1.25rem; + margin: 0; + color: var(--danger); +} + +.advanced-effect-controls { + margin-top: var(--space-3); + border-top: 1px solid var(--border); + padding-top: var(--space-3); +} + +.compact-action { + min-height: 2.25rem; + padding: 0.4rem 0.7rem; +} + +.schedule-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-block: var(--space-2); +} + .temporal-control summary, .schedule-summary summary { width: fit-content; @@ -582,6 +623,10 @@ progress { .temporal-grid { grid-template-columns: 1fr; } + + .basic-control-grid { + grid-template-columns: 1fr; + } } @media (max-width: 380px) { diff --git a/templates/index.html b/templates/index.html index ab79fbe..e3d60a4 100644 --- a/templates/index.html +++ b/templates/index.html @@ -238,6 +238,28 @@

Effect timing

+
+ + +
+

+ Changes the root recipe seed for every stochastic effect without uploading or rendering. +

+

View scheduled events diff --git a/tests/browser/image-workflow.spec.js b/tests/browser/image-workflow.spec.js index 83ca399..ce007a1 100644 --- a/tests/browser/image-workflow.spec.js +++ b/tests/browser/image-workflow.spec.js @@ -153,7 +153,10 @@ test("persistent video UI uploads once, previews a timestamp, and polls its job" let scheduleRecipe = null; let polls = 0; let submissions = 0; + let uploads = 0; + const scheduleSeeds = []; await page.route("**/api/video-sources", async (route) => { + uploads += 1; await route.fulfill({ status: 201, contentType: "application/json", @@ -163,6 +166,8 @@ test("persistent video UI uploads once, previews a timestamp, and polls its job" width: 640, height: 360, durationSeconds: 4, + frameRate: 29.97002997, + frameCount: 120, videoCodec: "h264", hasAudio: true, audioCodec: "aac", @@ -186,6 +191,8 @@ test("persistent video UI uploads once, previews a timestamp, and polls its job" }); await page.route("**/api/video-sources/*/effect-schedule", async (route) => { scheduleRecipe = route.request().postDataJSON().recipe; + scheduleSeeds.push(scheduleRecipe.seed); + const regenerated = scheduleRecipe.seed !== 17; await route.fulfill({ status: 200, contentType: "application/json", @@ -220,10 +227,10 @@ test("persistent video UI uploads once, previews a timestamp, and polls its job" events: [ { eventIndex: 0, - startFrame: 30, - endFrame: 42, - startSeconds: 1, - endSeconds: 1.4, + startFrame: regenerated ? 60 : 30, + endFrame: regenerated ? 72 : 42, + startSeconds: regenerated ? 2 : 1, + endSeconds: regenerated ? 2.4 : 1.4, durationFrames: 12, }, ], @@ -236,7 +243,7 @@ test("persistent video UI uploads once, previews a timestamp, and polls its job" submissions += 1; const payload = route.request().postDataJSON(); expect(payload.audioMode).toBe("preserve"); - expect(payload.recipe.seed).toBe(17); + expect(payload.recipe.seed).toBe(scheduleSeeds.at(-1)); expect(payload.recipe.schemaVersion).toBe(2); expect(payload.recipe.effects[2].timing.mode).toBe("sporadic"); await route.fulfill({ @@ -392,6 +399,7 @@ test("persistent video UI uploads once, previews a timestamp, and polls its job" }), ); + await expect(page.locator(".temporal-control:visible")).toHaveCount(0); await page.getByLabel("Video").check(); await page.setInputFiles("#input_file", { name: "signal.mp4", @@ -406,6 +414,7 @@ test("persistent video UI uploads once, previews a timestamp, and polls its job" await expect(page.locator("#preview-active-state")).toContainText("1 scheduled effect active"); await expect(page.locator("#schedule-status")).toContainText("Noise: continuous"); await expect(page.locator("#video-source-metadata")).toContainText("640 × 360"); + expect(uploads).toBe(1); await page.locator("#video-preview-time").fill("1.5"); await expect.poll(() => previewTimestamp).toBe(1.5); @@ -414,9 +423,38 @@ test("persistent video UI uploads once, previews a timestamp, and polls its job" '.temporal-control[data-effect-id="legacy-horizontal-glitch"]', ); await expect(glitchTiming).toBeVisible(); + await expect( + glitchTiming.locator('[data-basic-role="intensity-number"]'), + ).toHaveValue("40"); + await expect(glitchTiming.locator("[data-basic-summary]")).toContainText( + "Occasional horizontal glitch displacement", + ); + await glitchTiming.locator('[data-basic-role="intensity"]').fill("50"); + await expect(glitchTiming.locator('[data-basic-role="intensity-number"]')).toHaveValue( + "50", + ); + await expect(glitchTiming.locator('[data-temporal-role="intensity"]')).toHaveValue("0.6"); + await glitchTiming.locator('[data-basic-role="minimum-duration"]').fill("7"); + await glitchTiming.locator('[data-basic-role="maximum-duration"]').fill("16"); + await expect( + glitchTiming.locator('[data-temporal-role="minimum-duration"]'), + ).toHaveValue("7"); + await expect( + glitchTiming.locator('[data-temporal-role="maximum-duration"]'), + ).toHaveValue("16"); + await expect(glitchTiming.locator("[data-duration-context]")).toContainText( + "seconds at 29.97 fps", + ); + await glitchTiming.locator('[data-basic-role="minimum-duration"]').fill("20"); + await glitchTiming.locator('[data-basic-role="maximum-duration"]').fill("10"); + await expect(glitchTiming.locator("[data-basic-validation]")).toContainText( + "cannot exceed", + ); + await glitchTiming.locator('[data-basic-role="minimum-duration"]').fill("7"); + await glitchTiming.locator('[data-basic-role="maximum-duration"]').fill("16"); await glitchTiming.locator("summary").click(); await expect(glitchTiming.locator('[data-temporal-role="mode"]')).toHaveValue("sporadic"); - await expect(glitchTiming.locator('[data-temporal-role="minimum-duration"]')).toHaveValue("6"); + await expect(glitchTiming.locator('[data-temporal-role="minimum-duration"]')).toHaveValue("7"); await expect(glitchTiming.locator('[data-temporal-role="release"]')).toHaveValue("5"); await expect(glitchTiming.locator('[data-variation-channel="layout"]')).toHaveValue( "perEvent", @@ -424,6 +462,27 @@ test("persistent video UI uploads once, previews a timestamp, and polls its job" await expect(glitchTiming.locator('[data-variation-channel="offset"]')).toHaveValue( "smooth", ); + await glitchTiming.locator('[data-temporal-role="frequency"]').fill("7.5"); + await expect(glitchTiming).toHaveAttribute("data-configuration-ownership", "custom"); + await expect(glitchTiming.locator("[data-basic-status]")).toContainText("customized"); + await glitchTiming.locator("summary").click(); + await glitchTiming.locator("summary").click(); + await expect(glitchTiming.locator('[data-temporal-role="frequency"]')).toHaveValue("7.5"); + await glitchTiming.getByRole("button", {name: "Reset to Basic controls"}).click(); + await expect(glitchTiming).toHaveAttribute("data-configuration-ownership", "basic"); + await expect(glitchTiming.locator('[data-temporal-role="frequency"]')).toHaveValue("12.5"); + const seedBeforeRegeneration = await page.evaluate( + () => window.__glitchcraftState.seed, + ); + await page.getByRole("button", {name: "Regenerate all effect patterns"}).click(); + await expect + .poll(() => page.evaluate(() => window.__glitchcraftState.seed)) + .not.toBe(seedBeforeRegeneration); + await expect(page.locator("#pattern-status")).toContainText("patterns regenerated"); + await expect.poll(() => scheduleSeeds.at(-1)).not.toBe(17); + expect(uploads).toBe(1); + await page.getByRole("button", {name: "Jump to next scheduled glitch"}).click(); + await expect(page.locator("#video-preview-time")).toHaveValue("2"); await page.setViewportSize({width: 390, height: 844}); await glitchTiming.scrollIntoViewIfNeeded(); await expect(glitchTiming).toBeInViewport(); diff --git a/tests/test_progressive_controls.py b/tests/test_progressive_controls.py new file mode 100644 index 0000000..04a78d5 --- /dev/null +++ b/tests/test_progressive_controls.py @@ -0,0 +1,338 @@ +from __future__ import annotations + +from copy import deepcopy + +import pytest +from pydantic import ValidationError + +from glitchcraft.contracts.effects import EffectType, RecipeV2, SporadicTiming +from glitchcraft.contracts.progressive import ( + BASIC_CONTROL_CONTRACT_VERSION, + BASIC_EFFECT_PROFILE_VERSION, + BasicEffectControlContract, + BasicSourceRange, +) +from glitchcraft.effects.progressive import infer_basic_control, resolve_basic_control +from glitchcraft.effects.registry import BASIC_CONTROL_PROFILES, EFFECT_REGISTRY +from glitchcraft.effects.temporal import MediaTimelineContext, compile_recipe + + +def basic_request( + effect_type: EffectType, + intensity: int, + *, + minimum: int | None = None, + maximum: int | None = None, +) -> BasicEffectControlContract: + profile = BASIC_CONTROL_PROFILES[effect_type] + return BasicEffectControlContract( + effectType=effect_type, + intensity=intensity, + minimumDurationFrames=minimum or profile.default_duration_frames[0], + maximumDurationFrames=maximum or profile.default_duration_frames[1], + ) + + +@pytest.mark.parametrize("effect_type", list(EffectType)) +def test_profiles_are_complete_registry_owned_and_versioned(effect_type: EffectType) -> None: + profile = BASIC_CONTROL_PROFILES[effect_type] + assert effect_type in EFFECT_REGISTRY + assert profile.supported + assert profile.profile_version == BASIC_EFFECT_PROFILE_VERSION + assert 0 <= profile.default_intensity <= 100 + assert profile.allowed_duration_frames[0] <= profile.default_duration_frames[0] + assert profile.default_duration_frames[1] <= profile.allowed_duration_frames[1] + assert profile.affects_strength + if profile.timing_mode == "sporadic": + assert profile.affects_activity + assert profile.frequency_range + assert profile.minimum_cooldown_range + assert profile.maximum_cooldown_range + else: + assert not profile.affects_activity + assert not profile.duration_user_controllable + + +@pytest.mark.parametrize("effect_type", list(EffectType)) +def test_every_effect_maps_five_points_monotonically(effect_type: EffectType) -> None: + outputs = [ + resolve_basic_control(basic_request(effect_type, intensity)) + for intensity in (0, 25, 50, 75, 100) + ] + assert [item.resolved.intensity for item in outputs] == sorted( + item.resolved.intensity for item in outputs + ) + assert outputs[0].resolved.intensity == 0 + assert all(item.contract_version == BASIC_CONTROL_CONTRACT_VERSION for item in outputs) + assert all(item.profile_version == BASIC_EFFECT_PROFILE_VERSION for item in outputs) + assert outputs == [ + resolve_basic_control(basic_request(effect_type, intensity)) + for intensity in (0, 25, 50, 75, 100) + ] + if BASIC_CONTROL_PROFILES[effect_type].timing_mode == "sporadic": + timings = [item.resolved.timing for item in outputs] + assert all(isinstance(timing, SporadicTiming) for timing in timings) + frequencies = [ + timing.frequency_per_minute for timing in timings if isinstance(timing, SporadicTiming) + ] + minimum_cooldowns = [ + timing.minimum_cooldown_frames + for timing in timings + if isinstance(timing, SporadicTiming) + ] + maximum_cooldowns = [ + timing.maximum_cooldown_frames + for timing in timings + if isinstance(timing, SporadicTiming) + ] + assert frequencies == sorted(frequencies) + assert minimum_cooldowns == sorted(minimum_cooldowns, reverse=True) + assert maximum_cooldowns == sorted(maximum_cooldowns, reverse=True) + assert all( + output.resolved.envelope.attack_frames + output.resolved.envelope.release_frames + <= output.resolved.timing.minimum_duration_frames + for output in outputs + if isinstance(output.resolved.timing, SporadicTiming) + ) + + +def test_effect_specific_profiles_retain_natural_character() -> None: + horizontal = resolve_basic_control(basic_request(EffectType.HORIZONTAL_GLITCH, 50)) + frame_shift = resolve_basic_control(basic_request(EffectType.FRAME_SHIFT, 50)) + flicker = resolve_basic_control(basic_request(EffectType.FLICKER, 50)) + static = resolve_basic_control(basic_request(EffectType.STATIC, 50)) + assert isinstance(horizontal.resolved.timing, SporadicTiming) + assert isinstance(frame_shift.resolved.timing, SporadicTiming) + assert isinstance(flicker.resolved.timing, SporadicTiming) + assert ( + len( + { + horizontal.resolved.timing.frequency_per_minute, + frame_shift.resolved.timing.frequency_per_minute, + flicker.resolved.timing.frequency_per_minute, + } + ) + == 3 + ) + assert horizontal.resolved.variation["layout"].mode == "perEvent" + assert horizontal.resolved.variation["offset"].mode == "smooth" + assert static.resolved.timing.mode == "continuous" + + +@pytest.mark.parametrize("effect_type", list(EffectType)) +@pytest.mark.parametrize("intensity", [0, 25, 50, 75, 100]) +def test_resolve_then_infer_is_an_exact_round_trip(effect_type: EffectType, intensity: int) -> None: + request = basic_request(effect_type, intensity) + resolution = resolve_basic_control(request) + snapshot = resolution.resolved.model_dump(mode="json", by_alias=True) + inference = infer_basic_control(effect_type, resolution.resolved) + assert inference.status == "exact" + assert inference.intensity == intensity + assert inference.nearest_intensity == intensity + assert inference.minimum_duration_frames == request.minimum_duration_frames + assert inference.maximum_duration_frames == request.maximum_duration_frames + assert resolution.resolved.model_dump(mode="json", by_alias=True) == snapshot + + +def test_inference_distinguishes_approximate_strength_and_custom_timing() -> None: + resolution = resolve_basic_control(basic_request(EffectType.HORIZONTAL_GLITCH, 40)) + approximate = resolution.resolved.model_copy(update={"intensity": 0.51111}) + approximate_result = infer_basic_control(EffectType.HORIZONTAL_GLITCH, approximate) + assert approximate_result.status == "approximate" + assert approximate_result.intensity is None + assert 0 <= approximate_result.nearest_intensity <= 100 + + timing = resolution.resolved.timing + assert isinstance(timing, SporadicTiming) + custom = resolution.resolved.model_copy( + update={"timing": timing.model_copy(update={"frequency_per_minute": 7.5})} + ) + custom_snapshot = deepcopy(custom) + custom_result = infer_basic_control(EffectType.HORIZONTAL_GLITCH, custom) + assert custom_result.status == "custom" + assert custom_result.summary["label"] == "Custom advanced timing" + assert custom == custom_snapshot + + +def test_source_range_and_regenerate_warning_are_pure() -> None: + request = basic_request(EffectType.COLOR_BLEED, 40) + request = request.model_copy( + update={ + "source_range": BasicSourceRange(startSeconds=1.25, endSeconds=4.5), + "regenerate_pattern": True, + } + ) + resolution = resolve_basic_control(request) + assert resolution.resolved.timing.start_seconds == 1.25 + assert resolution.resolved.timing.end_seconds == 4.5 + assert resolution.warnings + assert infer_basic_control(EffectType.COLOR_BLEED, resolution.resolved).status == "exact" + + +@pytest.mark.parametrize(("minimum", "maximum"), [(1, 1), (1, 3600), (17, 39)]) +def test_duration_ranges_are_preserved_and_envelope_safe(minimum: int, maximum: int) -> None: + resolution = resolve_basic_control( + basic_request(EffectType.PIXELATION, 50, minimum=minimum, maximum=maximum) + ) + timing = resolution.resolved.timing + assert isinstance(timing, SporadicTiming) + assert (timing.minimum_duration_frames, timing.maximum_duration_frames) == ( + minimum, + maximum, + ) + assert ( + resolution.resolved.envelope.attack_frames + resolution.resolved.envelope.release_frames + <= minimum + ) + + +def test_basic_contract_is_strict_and_validates_ranges() -> None: + payload = { + "contractVersion": 1, + "effectType": "horizontal_glitch", + "intensity": 40, + "minimumDurationFrames": 6, + "maximumDurationFrames": 18, + } + assert BasicEffectControlContract.model_validate(payload).intensity == 40 + for update in ( + {"intensity": -1}, + {"intensity": 101}, + {"minimumDurationFrames": 19}, + {"unknown": True}, + ): + with pytest.raises(ValidationError): + BasicEffectControlContract.model_validate({**payload, **update}) + + +def test_resolver_output_is_a_valid_recipe_v2_effect() -> None: + resolution = resolve_basic_control(basic_request(EffectType.HORIZONTAL_GLITCH, 40)) + recipe = RecipeV2.model_validate( + { + "schemaVersion": 2, + "seed": 17, + "effects": [ + { + "id": "glitch", + "type": "horizontal_glitch", + "parameters": {"count": 10, "shift": 20}, + **resolution.resolved.model_dump(mode="json", by_alias=True), + } + ], + } + ) + assert recipe.effects[0].timing.mode == "sporadic" + + +def test_resolved_burst_schedule_is_deterministic_isolated_and_bounded() -> None: + resolution = resolve_basic_control(basic_request(EffectType.HORIZONTAL_GLITCH, 40)) + effect = { + "id": "glitch", + "type": "horizontal_glitch", + "parameters": {"count": 10, "shift": 20}, + **resolution.resolved.model_dump(mode="json", by_alias=True), + } + recipe = RecipeV2.model_validate({"schemaVersion": 2, "seed": 17, "effects": [effect]}) + timeline = MediaTimelineContext.from_source( + frame_rate="30000/1001", duration_seconds=60, total_frames=1799 + ) + first = compile_recipe(recipe, timeline).effects[0].events + restarted = ( + compile_recipe( + RecipeV2.model_validate(recipe.model_dump(mode="json", by_alias=True)), + timeline, + ) + .effects[0] + .events + ) + assert first == restarted + assert all(6 <= event.duration_frames <= 18 for event in first) + changed_seed = ( + compile_recipe(recipe.model_copy(update={"seed": 18}), timeline).effects[0].events + ) + assert changed_seed != first + + unrelated = { + "id": "noise", + "type": "noise", + "parameters": {"amount": 10, "strength": 10, "monochromatic": False}, + "intensity": 0.5, + "timing": {"mode": "continuous"}, + "envelope": {}, + "variation": {"detail": {"mode": "perFrame"}}, + } + reordered = recipe.model_copy( + update={ + "effects": [ + RecipeV2.model_validate( + {"schemaVersion": 2, "seed": 17, "effects": [unrelated]} + ).effects[0], + recipe.effects[0], + ] + } + ) + assert compile_recipe(reordered, timeline).effects[1].events == first + + +def test_progressive_routes_are_strict_deterministic_and_side_effect_free(client) -> None: + request = basic_request(EffectType.HORIZONTAL_GLITCH, 40).model_dump(mode="json", by_alias=True) + first = client.post("/api/effects/resolve-basic", json=request) + second = client.post("/api/effects/resolve-basic", json=request) + assert first.status_code == second.status_code == 200 + assert first.json == second.json + assert first.json["profileVersion"] == 1 + assert first.json["resolved"]["timing"]["minimumDurationFrames"] == 6 + assert "path" not in str(first.json).lower() + + inference = client.post( + "/api/effects/infer-basic", + json={ + "contractVersion": 1, + "effectType": "horizontal_glitch", + "effect": first.json["resolved"], + }, + ) + assert inference.status_code == 200 + assert inference.json["status"] == "exact" + assert inference.json["intensity"] == 40 + + invalid = client.post( + "/api/effects/resolve-basic", + json={**request, "minimumDurationFrames": 19, "maximumDurationFrames": 6}, + ) + assert invalid.status_code == 400 + assert ( + client.post( + "/api/effects/resolve-basic", json={**request, "effectType": "unknown"} + ).status_code + == 400 + ) + assert ( + client.post("/api/effects/resolve-basic", json={**request, "unknown": True}).status_code + == 400 + ) + + +def test_effect_and_runtime_metadata_expose_progressive_contract(client) -> None: + payload = client.get("/api/effects").json + assert payload["basicControlContractVersion"] == 1 + assert payload["basicEffectProfileVersion"] == 1 + assert payload["basicResolver"] == "/api/effects/resolve-basic" + assert payload["basicInference"] == "/api/effects/infer-basic" + assert len(payload["effects"]) == 8 + assert all(effect["basicControls"]["supported"] for effect in payload["effects"]) + assert all(effect["basicControls"]["profileVersion"] == 1 for effect in payload["effects"]) + + metadata = client.get("/metadata").json + assert metadata["version"] == "0.3.1" + assert metadata["supportedRecipeSchemaVersions"] == [1, 2] + assert metadata["storageSchemaVersion"] == 2 + assert metadata["videoJobSchemaVersion"] == 2 + assert metadata["progressiveEffectControls"]["profileVersion"] == 1 + capabilities = {item["slug"] for item in client.get("/api/capabilities").json["capabilities"]} + assert { + "progressive-effect-controls", + "basic-effect-intensity", + "effect-burst-range", + "effect-pattern-regeneration", + } <= capabilities From 866f716262eeba0c57da545d6e39fa092fcc24bd Mon Sep 17 00:00:00 2001 From: John Crafts <5889731+Artsen@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:01:12 -0400 Subject: [PATCH 2/2] Rework progressive controls into a focused effect workspace --- .gitignore | 1 + docs/architecture.md | 14 + docs/interface-system.md | 64 ++ docs/product-direction.md | 21 +- docs/progressive-controls.md | 16 +- docs/testing.md | 14 + docs/video-workflow.md | 12 +- package.json | 3 +- playwright.review.config.js | 35 + static/app.js | 461 ++++++++++++- static/style.css | 829 +++++++++++++++++++++++- templates/index.html | 365 +++++++---- tests/browser/focused-workspace.spec.js | 313 +++++++++ tests/browser/image-workflow.spec.js | 13 +- tests/ui-review/ui-screens.spec.js | 255 ++++++++ 15 files changed, 2217 insertions(+), 199 deletions(-) create mode 100644 docs/interface-system.md create mode 100644 playwright.review.config.js create mode 100644 tests/browser/focused-workspace.spec.js create mode 100644 tests/ui-review/ui-screens.spec.js diff --git a/.gitignore b/.gitignore index 4a76315..f9759c5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ htmlcov/ node_modules/ playwright-report/ test-results/ +.tmp/ data/ build/ dist/ diff --git a/docs/architecture.md b/docs/architecture.md index abd5c1a..05e0bb8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -64,3 +64,17 @@ The progressive layer is upstream of Recipe v2 validation and temporal compilati strict Basic contract → registry profile → canonical Recipe v2 effect fragment. Reverse inference is pure and bounded to 101 candidates. Neither path decodes media or adds render overhead. UI ownership and disclosure state are deliberately outside persisted contracts. + +# Focused workspace state + +The focused editor is a Jinja shell enhanced by dependency-free JavaScript. Existing effect +cards stay inside the upload form so Recipe v1/v2 serialization does not change. In video +mode the cards are mounted in the inspector and non-selected cards are both hidden and +inert; in image mode they return to the existing effect grid and temporal controls are +hidden. This transitional DOM move preserves per-effect values without duplicate IDs. + +Selection, mobile view, and disclosure state are client-only. Effect enablement is distinct +from selection and feeds the existing Recipe v2 `enabled` field. Metadata loads once on +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. diff --git a/docs/interface-system.md b/docs/interface-system.md new file mode 100644 index 0000000..dfb681c --- /dev/null +++ b/docs/interface-system.md @@ -0,0 +1,64 @@ +# Interface system + +GlitchCraft uses a precision-slate interface that keeps media and effect parameters more +prominent than application chrome. The system borrows the shared family grammar of Web +Video Optimizer without copying its logo, product language, React components, routes, or +workflow assumptions. + +## Semantic color + +- App background: `#080c12` +- Workspace: `#0e151f` +- Sidebar: `#090e15` +- Surface: `#141d29` +- Elevated surface: `#1a2533` +- Recessed surface: `#0c121b` +- Hover surface: `#202c3c` +- Selected surface: `#1c2440` +- Iris interaction: `#6a5bcf` +- Ember transformation: `#f2763f` +- Success: `#35c981` +- Warning: `#e8b44d` +- Danger: `#f06a72` + +Iris identifies selection, focus, and primary interaction. Ember identifies transformation +actions such as pattern regeneration and full processing. Green is reserved for genuine +completion or valid local status. Status always includes text and never relies on color. + +## Workspace layout + +At 1180px and above, video mode uses a 230–280px effects rail, a fluid media preview, and a +320–400px inspector. The render dock spans all three regions. From 800px through 1179px, +the rail remains beside a preview/inspector stack. Below 800px, an accessible Preview, +Effects, Inspector, and Render switch exposes one focused region at a time. + +The effects list uses separate checkbox and selection controls. Selection has an iris rail +and `aria-current`; enablement remains independently operable. One effect inspector is +visible at a time. Non-selected inspectors remain mounted to preserve state but are hidden +and inert so they cannot receive focus. + +## Control hierarchy + +Each effect inspector is ordered: + +1. Basic intensity, numeric value, burst bounds, duration context, resolved summary, and + reset state. +2. Existing Appearance parameters. +3. Semantic Advanced disclosure with exact timing, envelope, and variation fields. + +The authored `.effect-intensity-slider` has a visible progress track and thumb, a practical +minimum width of 180px, a 32px hit area, WebKit and Mozilla styling, forced-colors support, +and an `aria-valuetext` category. Numeric and range controls clamp and synchronize. + +## Interaction and failure rules + +The shell must render before metadata. Metadata failure is recoverable in the rail and does +not erase a valid preview. Effects resolve independently with stale-request cancellation +and per-effect retry. Selection never requests metadata, resolves settings, compiles a +schedule, uploads media, refreshes a preview, or starts a render. An enabled unresolved +effect cannot be processed. + +Focus rings remain visible, hidden panels are inert, icon-free buttons use explicit action +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. diff --git a/docs/product-direction.md b/docs/product-direction.md index d04c1a0..5b94b43 100644 --- a/docs/product-direction.md +++ b/docs/product-direction.md @@ -5,20 +5,20 @@ GlitchCraft is becoming: > A local visual-effects workspace for building reproducible glitch, noise, > pixel, scan, distortion, and signal treatments for images and video. -A later interface will align with ColorCraft and Web Video Optimizer through -shared principles: system typography, semantic design tokens, consistent spacing, -a related application shell, familiar panels/notices/status/forms, desktop -workspace and responsive mobile navigation, clear local-processing and readiness -indicators, and comparable control with progressive disclosure. +The current focused video interface aligns with Web Video Optimizer through shared +principles: system typography, semantic design tokens, consistent spacing, a related +precision-slate shell, familiar panels/notices/status/forms, desktop workspace and +responsive mobile navigation, clear local-processing and readiness indicators, and +progressive disclosure. GlitchCraft keeps its own name, mark, effect vocabulary, Flask +routes, and Recipe behavior. The family relationship is a shared precision-oriented shell and interaction grammar. GlitchCraft will retain its own signal, interference, and transformation identity; another application's visual theme will not be copied wholesale. -The current sequence deliberately avoids a final visual redesign. It establishes -inspectable recipes, deterministic processing, persistent image and video -identity, bounded cancellable jobs, Craft discovery metadata, truthful -readiness, and testable boundaries needed by that future workspace. GlitchCraft +The current sequence establishes inspectable recipes, deterministic processing, +persistent image and video identity, bounded cancellable jobs, Craft discovery metadata, +truthful readiness, and a testable focused editor. GlitchCraft owns creative treatment; Web Video Optimizer remains the detailed delivery-optimization and packaging tool. @@ -41,6 +41,7 @@ workspace and proposed 5175/4200 frontend/API split are also deferred. # Progressive controls milestone Version 0.3.1 makes the deterministic temporal engine approachable without hiding its exact -contracts. It is intentionally a focused control-resolution layer in the current Flask and +contracts. It is a focused control-resolution layer presented through an effects rail, +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. diff --git a/docs/progressive-controls.md b/docs/progressive-controls.md index cf7f090..a6a75fb 100644 --- a/docs/progressive-controls.md +++ b/docs/progressive-controls.md @@ -48,6 +48,16 @@ therefore cannot alter an existing output. **Regenerate all effect patterns** ch root seed deliberately, refreshes schedule and still preview requests, and affects every stochastic effect without re-uploading or rendering a persistent output. -The interface remains transitional Flask and dependency-free JavaScript. The final editable -timeline, manual-event UI, presets, React workspace, stateful feedback, and codec -datamoshing remain deferred. +The Flask/Jinja and dependency-free JavaScript interface now presents these controls in a +focused video workspace. The effect rail separates enablement from selection, and the +inspector keeps every effect's Basic, Appearance, and Advanced state mounted while exposing +only the selected effect. Selection and disclosure changes are local UI operations: they do +not resolve controls, compile a schedule, upload media, request a preview, or render output. +Disabled effects remain selectable so their preserved settings can be inspected. + +Metadata and resolver startup are intentionally independent. The shell renders before +metadata, each effect reports its own resolver state and retry action, stale resolutions are +aborted, and `Promise.allSettled` prevents one profile failure from blocking the other seven. +An unresolved enabled effect cannot be submitted as a valid video recipe. The final editable +timeline, manual-event UI, presets, React workspace, stateful feedback, and codec datamoshing +remain deferred. diff --git a/docs/testing.md b/docs/testing.md index 489fe9c..1baa078 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -38,6 +38,7 @@ Playwright workflow: npm ci npx playwright install chromium npm run test:browser +npm run review:ui-screens ``` The browser suite starts the Flask application and generates its image fixture @@ -77,6 +78,19 @@ It checks concise formatting, keyboard-accessible details, ordered effects, previous-result retention, responsive containment, and Axe results without requiring a long render. +Focused-workspace browser coverage also checks metadata loading/failure/retry, preview +retention during metadata failure, one isolated resolver failure, slow and stale resolver +responses, disabled-effect selection, custom state across effect switches and reset, +selection/request-count invariants, duplicate IDs, inert hidden panels, range geometry, +six required viewport sizes, horizontal containment, performance marks, and Axe with no +serious or critical findings. + +`npm run review:ui-screens` generates 14 deterministic Chromium captures in +`.tmp/ui-review/`: six desktop states at 1440 x 900, one medium state at 1024 x 768, three +tablet views at 768 x 1024, and four mobile views at 390 x 844. The fixtures have stable +metadata, source identity, seed, schedule, preview artwork, telemetry, and output state. +The `.tmp` directory is ignored and screenshots must be inspected manually during UI review. + Recipe v1 golden regressions hash representative frames at indexes 0, 1, and 17 to prove the legacy RNG namespace and pixels remain exact. Temporal tests cover strict Recipe v2 parsing, rational conversion, every timing mode, event sorting diff --git a/docs/video-workflow.md b/docs/video-workflow.md index 9772d7d..9b9972c 100644 --- a/docs/video-workflow.md +++ b/docs/video-workflow.md @@ -49,8 +49,10 @@ chapters, attachments, multiple audio tracks, and hardware encoding are not preserved or implemented. Detailed delivery optimization remains the responsibility of Web Video Optimizer. -Video mode now builds Recipe v2 from `/api/effects` registry metadata. Advanced -timing disclosures appear only for enabled video effects and expose continuous, +Video mode now builds Recipe v2 from `/api/effects` registry metadata. The effects rail +separates enablement from selection. The selected inspector remains available for disabled +effects and exposes Basic, Appearance, and Advanced controls. Advanced timing disclosures +expose continuous, range, and sporadic modes, bounds, frequency, duration, cooldown, attack, release, curve, maximum intensity, and supported variation choices. Image mode continues to build Recipe v1. @@ -62,6 +64,12 @@ active effects. Moving the timestamp never changes the root seed or schedule, and the previous valid still remains visible while its replacement loads. Manual events are supported by the API contract but do not yet have a full editor. + +The preview is now the visual center of the focused workspace. Empty, loading, ready, and +recoverable error states keep the shell geometry stable. Timestamp and active-effect +feedback remain adjacent to the media, and the previous valid frame is retained while a +new frame loads or fails. Schedule inspection, pattern regeneration, audio choice, +processing, cancellation, progress, and completed output are grouped in the render dock. # Basic video controls Enabled video effects show Basic Intensity, burst bounds, and a resolved summary before a diff --git a/package.json b/package.json index 010becf..1c2ffed 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "private": true, "scripts": { "test": "playwright test", - "test:browser": "playwright test" + "test:browser": "playwright test", + "review:ui-screens": "playwright test --config=playwright.review.config.js" }, "devDependencies": { "@axe-core/playwright": "4.12.1", diff --git a/playwright.review.config.js b/playwright.review.config.js new file mode 100644 index 0000000..c309bb6 --- /dev/null +++ b/playwright.review.config.js @@ -0,0 +1,35 @@ +const {defineConfig, devices} = require("@playwright/test"); + +const pythonCommand = + process.platform === "win32" + ? ".venv\\Scripts\\python.exe app.py" + : "python app.py"; + +module.exports = defineConfig({ + testDir: "tests/ui-review", + outputDir: ".tmp/ui-review-results", + timeout: 45_000, + fullyParallel: false, + reporter: [["list"]], + use: { + baseURL: "http://127.0.0.1:5000", + trace: "retain-on-failure", + screenshot: "only-on-failure", + }, + webServer: { + command: pythonCommand, + url: "http://127.0.0.1:5000", + env: { + ...process.env, + GLITCHCRAFT_DATA_ROOT: ".tmp/ui-review-data", + }, + reuseExistingServer: false, + timeout: 30_000, + }, + projects: [ + { + name: "chromium", + use: {...devices["Desktop Chrome"]}, + }, + ], +}); diff --git a/static/app.js b/static/app.js index d92b30f..d741fc3 100644 --- a/static/app.js +++ b/static/app.js @@ -52,6 +52,14 @@ basicTimers: new Map(), applyingBasicResolution: false, lastSchedule: null, + selectedEffectId: null, + videoEnabledEffects: new Map(), + resolverStates: new Map(), + metadataState: "loading", + metadataRequestCount: 0, + resolverRequestCount: 0, + selectionCount: 0, + performanceMarks: {}, }; // Kept inspectable while this transitional interface remains in one script. @@ -196,6 +204,9 @@ const panel = temporalPanel(effect.id); return { ...effect, + enabled: imageState.videoEnabledEffects.has(effect.id) + ? imageState.videoEnabledEffects.get(effect.id) + : effect.enabled, intensity: temporalNumber(panel, "intensity"), timing: buildEffectTiming(effect.id), envelope: { @@ -262,6 +273,199 @@ return input; } + function intensityCategory(value) { + const numeric = Number(value); + if (numeric <= 20) { + return "Subtle"; + } + if (numeric <= 45) { + return "Light"; + } + if (numeric <= 70) { + return "Moderate"; + } + if (numeric <= 90) { + return "Strong"; + } + return "Maximum"; + } + + function updateIntensityPresentation(input) { + const minimum = Number(input.min || 0); + const maximum = Number(input.max || 100); + const value = Math.min(maximum, Math.max(minimum, Number(input.value))); + input.value = String(value); + const percentage = maximum === minimum ? 0 : ((value - minimum) / (maximum - minimum)) * 100; + input.style.setProperty("--range-progress", `${percentage}%`); + input.setAttribute("aria-valuetext", `${value}, ${intensityCategory(value)} intensity`); + } + + function effectEnabled(effectId) { + if (imageState.videoEnabledEffects.has(effectId)) { + return imageState.videoEnabledEffects.get(effectId); + } + return buildRecipeV1().effects.find((effect) => effect.id === effectId)?.enabled ?? false; + } + + function nativeEffectCheckbox(effectId) { + const names = { + "legacy-horizontal-glitch": "glitch", + "legacy-distortion": "distortion", + "legacy-color-bleed": "color_bleed", + "legacy-scan-lines": "scan_lines", + "legacy-static": "static", + "legacy-flicker": "flicker", + }; + return names[effectId] ? form.elements[names[effectId]] : null; + } + + function updateEnabledEffectCount() { + const count = [...imageState.videoEnabledEffects.values()].filter(Boolean).length; + document.querySelector("#enabled-effect-count").textContent = + `${count} enabled`; + } + + function updateRailItem(effectId) { + const item = document.querySelector( + `.effect-rail-item[data-effect-id="${CSS.escape(effectId)}"]`, + ); + const panel = temporalPanel(effectId); + if (!item || !panel) { + return; + } + const enabledState = effectEnabled(effectId); + item.querySelector('[data-effect-enable]').checked = enabledState; + item.dataset.enabled = String(enabledState); + const card = document.querySelector( + `.control-card[data-effect-id="${CSS.escape(effectId)}"]`, + ); + card?.setAttribute("aria-disabled", String(!enabledState)); + const summary = panel.querySelector("[data-basic-summary]")?.textContent; + if (summary) { + item.querySelector("[data-effect-summary]").textContent = summary; + } + item.querySelector("[data-custom-status]").textContent = + panel.dataset.configurationOwnership === "custom" ? "Custom" : ""; + if (imageState.selectedEffectId === effectId) { + document.querySelector("#selected-effect-status").textContent = + enabledState ? "Enabled" : "Disabled"; + } + updateEnabledEffectCount(); + } + + function selectEffect(effectId, {focus = false} = {}) { + const startedAt = performance.now(); + if (!document.querySelector(`.control-card[data-effect-id="${CSS.escape(effectId)}"]`)) { + return; + } + imageState.selectedEffectId = effectId; + imageState.selectionCount += 1; + for (const card of document.querySelectorAll(".control-card[data-effect-id]")) { + const selected = card.dataset.effectId === effectId; + card.hidden = !selected; + card.inert = !selected; + } + for (const item of document.querySelectorAll(".effect-rail-item")) { + const selected = item.dataset.effectId === effectId; + item.dataset.selected = String(selected); + const button = item.querySelector("[data-effect-select]"); + if (selected) { + button.setAttribute("aria-current", "true"); + } else { + button.removeAttribute("aria-current"); + } + } + const metadata = imageState.effectMetadata.get(temporalPanel(effectId)?.dataset.effectType); + document.querySelector("#effect-inspector-heading").textContent = + metadata?.name || effectNames[temporalPanel(effectId)?.dataset.effectType] || "Effect controls"; + document.querySelector("#effect-inspector-summary").textContent = + metadata?.basicControls?.intensityHelperText || "Adjust appearance and timing for this effect."; + updateRailItem(effectId); + if (focus) { + document.querySelector( + `.effect-rail-item[data-effect-id="${CSS.escape(effectId)}"] [data-effect-select]`, + )?.focus(); + } + imageState.performanceMarks.effectSelectionMs = performance.now() - startedAt; + } + + function setVideoEffectEnabled(effectId, nextEnabled) { + imageState.videoEnabledEffects.set(effectId, nextEnabled); + const native = nativeEffectCheckbox(effectId); + if (native && native.checked !== nextEnabled) { + native.checked = nextEnabled; + native.dispatchEvent(new Event("change", {bubbles: true})); + } else { + scheduleTemporalRefresh(); + } + updateRailItem(effectId); + } + + function createRailItem(card, metadata) { + const effectId = card.dataset.effectId; + if (document.querySelector(`.effect-rail-item[data-effect-id="${CSS.escape(effectId)}"]`)) { + return; + } + const item = document.createElement("li"); + item.className = "effect-rail-item"; + item.dataset.effectId = effectId; + item.dataset.selected = "false"; + const enableLabel = document.createElement("label"); + enableLabel.className = "effect-enable"; + const enable = document.createElement("input"); + enable.type = "checkbox"; + enable.dataset.effectEnable = ""; + enable.checked = effectEnabled(effectId); + enable.setAttribute("aria-label", `Enable ${metadata.name}`); + enable.addEventListener("change", () => setVideoEffectEnabled(effectId, enable.checked)); + enableLabel.append(enable); + const select = document.createElement("button"); + select.type = "button"; + select.className = "effect-select"; + select.dataset.effectSelect = ""; + select.innerHTML = + `Resolving defaults…` + + ``; + select.querySelector("strong").textContent = metadata.name; + select.setAttribute("aria-label", `Edit ${metadata.name}`); + select.addEventListener("click", () => selectEffect(effectId)); + item.append(enableLabel, select); + document.querySelector("#effects-rail-list").append(item); + } + + function setResolverState(effectId, state, message = "") { + imageState.resolverStates.set(effectId, state); + const panel = temporalPanel(effectId); + if (!panel) { + return; + } + panel.dataset.resolverState = state; + const validation = panel.querySelector("[data-basic-validation]"); + const retry = panel.querySelector("[data-retry-resolver]"); + if (state === "loading") { + validation.textContent = "Resolving deterministic defaults…"; + retry.hidden = true; + } else if (state === "error") { + validation.textContent = message || "Basic controls could not be resolved."; + retry.hidden = false; + } else { + if (validation.textContent === "Resolving deterministic defaults…") { + validation.textContent = ""; + } + retry.hidden = true; + } + updateRailItem(effectId); + } + + function videoRecipeReady() { + return ( + imageState.metadataState === "ready" && + [...imageState.videoEnabledEffects.entries()] + .filter(([, enabledState]) => enabledState) + .every(([effectId]) => imageState.resolverStates.get(effectId) === "ready") + ); + } + function setBasicOwnership(panel, ownership, message = "") { panel.dataset.configurationOwnership = ownership; const status = panel.querySelector("[data-basic-status]"); @@ -270,6 +474,7 @@ message || (ownership === "custom" ? "Advanced timing is customized." : "Using Basic-derived settings."); reset.hidden = ownership !== "custom"; + updateRailItem(panel.dataset.effectId); } function applyResolvedSettings(panel, resolved) { @@ -320,6 +525,10 @@ async function resolveBasicSettings(effectId, {announce = false} = {}) { const panel = temporalPanel(effectId); const metadata = imageState.effectMetadata.get(panel.dataset.effectType); + if (!metadata) { + setResolverState(effectId, "error", "Effect metadata is unavailable."); + return false; + } const minimum = Number(panel.querySelector('[data-basic-role="minimum-duration"]').value); const maximum = Number(panel.querySelector('[data-basic-role="maximum-duration"]').value); const validation = panel.querySelector("[data-basic-validation]"); @@ -331,6 +540,8 @@ imageState.basicControllers.get(effectId)?.abort(); const controller = new AbortController(); imageState.basicControllers.set(effectId, controller); + imageState.resolverRequestCount += 1; + setResolverState(effectId, "loading"); const payload = { contractVersion: 1, effectType: metadata.type, @@ -365,11 +576,13 @@ "basic", announce ? "Basic controls replaced the previous custom settings." : "", ); + setResolverState(effectId, "ready"); + updateRailItem(effectId); updateTemporalVisibility(); return true; } catch (error) { if (error.name !== "AbortError") { - validation.textContent = error.message; + setResolverState(effectId, "error", error.message); } return false; } finally { @@ -406,6 +619,15 @@ function installTimingPanel(card, metadata) { const effectId = card.dataset.effectId; + if (card.querySelector(".temporal-control")) { + return; + } + if (!imageState.videoEnabledEffects.has(effectId)) { + imageState.videoEnabledEffects.set( + effectId, + buildRecipeV1().effects.find((effect) => effect.id === effectId)?.enabled ?? false, + ); + } const defaults = metadata.naturalVideoDefaults; const timing = defaults.timing; const envelope = defaults.envelope; @@ -438,6 +660,8 @@ "aria-describedby", `basic-${effectId}-helper basic-${effectId}-status`, ); + intensityRange.classList.add("effect-intensity-slider"); + updateIntensityPresentation(intensityRange); basicField(basicGrid, effectId, "Intensity value", "intensity-number", { min: metadata.basicControls.intensityRange.minimum, max: metadata.basicControls.intensityRange.maximum, @@ -503,6 +727,13 @@ scheduleTemporalRefresh(); } }); + const retryResolver = document.createElement("button"); + retryResolver.type = "button"; + retryResolver.className = "action action-quiet compact-action"; + retryResolver.dataset.retryResolver = ""; + retryResolver.textContent = "Retry this effect"; + retryResolver.hidden = true; + retryResolver.addEventListener("click", () => resolveBasicSettings(effectId, {announce: true})); basic.append( basicLegend, basicGrid, @@ -512,7 +743,13 @@ validation, ownership, reset, + retryResolver, ); + const appearance = document.createElement("section"); + appearance.className = "appearance-effect-controls"; + const appearanceHeading = document.createElement("h3"); + appearanceHeading.textContent = "Appearance"; + appearance.append(appearanceHeading, ...card.childNodes); const details = document.createElement("details"); details.className = "advanced-effect-controls"; const summary = document.createElement("summary"); @@ -663,29 +900,81 @@ fieldset.append(seed); details.append(summary, fieldset); container.append(basic, details); - card.append(container); + card.replaceChildren(container, appearance); + document.querySelector("#effect-inspector-panels").append(card); + createRailItem(card, metadata); + if (!imageState.selectedEffectId && effectEnabled(effectId)) { + selectEffect(effectId); + } else if (imageState.selectedEffectId) { + selectEffect(imageState.selectedEffectId); + } + } + + function organizeEffectCards() { + const target = + currentMode() === "video" + ? document.querySelector("#effect-inspector-panels") + : document.querySelector("#effect-controls .control-grid"); + for (const card of document.querySelectorAll(".control-card[data-effect-id]")) { + target.append(card); + if (currentMode() === "image") { + card.hidden = false; + card.inert = false; + } + } + if (currentMode() === "video" && imageState.selectedEffectId) { + selectEffect(imageState.selectedEffectId); + } } async function loadEffectMetadata() { - const response = await fetch("/api/effects"); - if (!response.ok) { - throw new Error("Effect timing metadata could not be loaded."); - } - const payload = await response.json(); - imageState.sporadicDefaults = payload.sporadicControlDefaults; - const resolutions = []; - for (const metadata of payload.effects) { - imageState.effectMetadata.set(metadata.type, metadata); - const card = document.querySelector( - `.control-card[data-effect-type="${CSS.escape(metadata.type)}"]`, - ); - if (card) { - installTimingPanel(card, metadata); - resolutions.push(resolveBasicSettings(card.dataset.effectId)); + const state = document.querySelector("#effect-metadata-state"); + const retry = document.querySelector("#retry-effect-metadata"); + imageState.metadataState = "loading"; + imageState.metadataRequestCount += 1; + state.dataset.state = "loading"; + state.textContent = "Loading effect controls…"; + retry.hidden = true; + const startedAt = performance.now(); + try { + const response = await fetch("/api/effects"); + if (!response.ok) { + throw new Error("Effect timing metadata could not be loaded."); } + const payload = await response.json(); + imageState.sporadicDefaults = payload.sporadicControlDefaults; + const resolutions = payload.effects.map(async (metadata) => { + imageState.effectMetadata.set(metadata.type, metadata); + const card = document.querySelector( + `.control-card[data-effect-type="${CSS.escape(metadata.type)}"]`, + ); + if (!card) { + return; + } + installTimingPanel(card, metadata); + const resolved = await resolveBasicSettings(card.dataset.effectId); + if (!resolved) { + throw new Error(`${metadata.name} defaults did not resolve.`); + } + }); + imageState.metadataState = "ready"; + imageState.performanceMarks.metadataToFirstInspectorMs = performance.now() - startedAt; + const results = await Promise.allSettled(resolutions); + const failed = results.filter((result) => result.status === "rejected").length; + state.dataset.state = failed ? "warning" : "ready"; + state.textContent = failed + ? `${payload.effects.length - failed} effects ready; ${failed} need attention.` + : `${payload.effects.length} effects ready`; + updateTemporalVisibility(); + organizeEffectCards(); + return results; + } catch (error) { + imageState.metadataState = "error"; + state.dataset.state = "error"; + state.textContent = `${error.message} The current preview is preserved.`; + retry.hidden = false; + throw error; } - await Promise.all(resolutions); - updateTemporalVisibility(); } function updateTemporalVisibility() { @@ -695,7 +984,8 @@ if (!panel) { continue; } - panel.hidden = currentMode() !== "video" || !effect.enabled; + panel.hidden = false; + panel.dataset.effectEnabled = String(effectEnabled(effect.id)); const mode = panel.querySelector('[data-temporal-role="mode"]').value; const sporadic = panel.querySelector('[data-timing-group="sporadic"]'); sporadic.hidden = mode !== "sporadic"; @@ -930,13 +1220,23 @@ function updateMode() { const imageMode = currentMode() === "image"; + document.body.dataset.mode = imageMode ? "image" : "video"; submitButton.textContent = imageMode ? "Upload image" : "Create video preview"; fileInput.accept = imageMode ? ".png,.jpg,.jpeg,.bmp,.tif,.tiff" : ".mp4,.avi,.mov,.mkv"; + document.querySelector("#workspace-mode-status").textContent = + imageMode ? "Image mode" : "Video mode"; + document.querySelector("#file-support-help").textContent = imageMode + ? "Supported images: PNG, JPEG, BMP, and TIFF." + : "Supported videos: MP4, AVI, MOV, and MKV."; + document.querySelector("#preview-section").hidden = imageMode; if (imageState.sourceId) { imageWorkspace.hidden = !imageMode; } + if (imageState.effectMetadata.size) { + organizeEffectCards(); + } updateTemporalVisibility(); } @@ -947,18 +1247,40 @@ `${form.elements.pixel_size.value} px`; } + function setPreviewStageState(state, heading, detail) { + const stage = document.querySelector("#video-preview-stage"); + const message = document.querySelector("#preview-state"); + stage.dataset.state = state; + stage.setAttribute("aria-busy", String(state === "loading")); + message.querySelector("strong").textContent = heading; + message.querySelector("span").textContent = detail; + document.querySelector("#retry-video-preview").hidden = state !== "error"; + } + async function submitVideoPreview() { const file = fileInput.files[0]; if (!file) { setNotice("Choose a video before continuing.", "error"); return; } + if (!videoRecipeReady()) { + setNotice( + "Enabled effects must finish loading successfully before a video preview can be created.", + "error", + ); + document.querySelector("#effect-metadata-state").focus?.(); + return; + } submitButton.disabled = true; uploadStatus.textContent = "Inspecting and storing video…"; const body = new FormData(); body.append("video", file); try { - await imageState.effectMetadataPromise; + setPreviewStageState( + "loading", + "Inspecting video", + "The previous preview remains visible until the new frame is ready.", + ); const response = await fetch("/api/video-sources", {method: "POST", body}); if (!response.ok) { throw new Error(await errorMessage(response, "The video could not be uploaded.")); @@ -977,8 +1299,9 @@ const timeInput = document.querySelector("#video-preview-time"); timeInput.max = String(Math.max(0, source.durationSeconds - 0.001)); document.querySelector("#preview-section").hidden = false; - await requestEffectSchedule(); - await requestVideoPreview(); + document.querySelector("#workspace-source-status").textContent = source.originalName; + document.querySelector("#render-status-badge").textContent = "Ready to render"; + await Promise.allSettled([requestEffectSchedule(), requestVideoPreview()]); setNotice(""); } catch (error) { setNotice(error.message, "error"); @@ -997,6 +1320,11 @@ const controller = new AbortController(); const revision = ++imageState.videoPreviewRevision; imageState.videoPreviewController = controller; + setPreviewStageState( + "loading", + "Updating preview", + "The previous frame stays visible while this timestamp is processed.", + ); try { const response = await fetch( `/api/video-sources/${encodeURIComponent(source.sourceId)}/preview`, @@ -1020,6 +1348,7 @@ releaseVideoPreview(); imageState.videoPreviewUrl = URL.createObjectURL(blob); document.querySelector("#preview-image").src = imageState.videoPreviewUrl; + setPreviewStageState("ready", "Preview ready", "Processed frame is current."); const activeCount = Number(response.headers.get("X-GlitchCraft-Active-Effect-Count") || 0); const activeIds = (response.headers.get("X-GlitchCraft-Active-Effect-Ids") || "") .split(",") @@ -1032,6 +1361,11 @@ } catch (error) { if (error.name !== "AbortError" && revision === imageState.videoPreviewRevision) { setNotice(error.message, "error"); + setPreviewStageState( + "error", + "Preview could not be updated", + `${error.message} The previous frame is still available.`, + ); } } finally { if (imageState.videoPreviewController === controller) { @@ -1468,6 +1802,10 @@ if (!imageState.videoSource) { return; } + if (!videoRecipeReady()) { + setNotice("Resolve every enabled effect before processing the full video.", "error"); + return; + } const processButton = document.querySelector("#process-full"); const cancelButton = document.querySelector("#cancel-processing"); stopVideoPolling(); @@ -1495,6 +1833,9 @@ const job = await response.json(); imageState.videoJobId = job.jobId; progressPanel.hidden = false; + const renderStatus = document.querySelector("#render-status-badge"); + renderStatus.dataset.state = "running"; + renderStatus.textContent = "Rendering"; progressBar.value = 0; if (!document.querySelector("#video-result").hidden) { document.querySelector("#video-result-heading").textContent = "Previous processed video"; @@ -1504,6 +1845,9 @@ processButton.disabled = false; cancelButton.disabled = true; setNotice(error.message, "error"); + const renderStatus = document.querySelector("#render-status-badge"); + renderStatus.dataset.state = "error"; + renderStatus.textContent = "Render failed"; } } @@ -1538,6 +1882,9 @@ document.querySelector("#download-link").href = `${job.outputUrl}/download`; document.querySelector("#video-result-heading").textContent = "Processed video"; document.querySelector("#video-result").hidden = false; + const renderStatus = document.querySelector("#render-status-badge"); + renderStatus.dataset.state = "complete"; + renderStatus.textContent = "Completed"; return; } if (job.state === "failed" || job.state === "canceled") { @@ -1545,6 +1892,9 @@ document.querySelector("#process-full").disabled = false; document.querySelector("#cancel-processing").disabled = true; imageState.videoCancelPending = false; + const renderStatus = document.querySelector("#render-status-badge"); + renderStatus.dataset.state = job.state === "failed" ? "error" : "ready"; + renderStatus.textContent = job.state === "failed" ? "Render failed" : "Canceled"; if (job.state === "failed") { setNotice(job.error?.message || "Video processing failed.", "error"); } @@ -1589,10 +1939,17 @@ if (basicRole) { const panel = event.target.closest(".temporal-control"); if (basicRole === "intensity") { - panel.querySelector('[data-basic-role="intensity-number"]').value = - event.target.value; + updateIntensityPresentation(event.target); + panel.querySelector('[data-basic-role="intensity-number"]').value = event.target.value; } else if (basicRole === "intensity-number") { - panel.querySelector('[data-basic-role="intensity"]').value = event.target.value; + const range = panel.querySelector('[data-basic-role="intensity"]'); + const clamped = Math.min( + Number(range.max), + Math.max(Number(range.min), Number(event.target.value || range.min)), + ); + event.target.value = String(clamped); + range.value = String(clamped); + updateIntensityPresentation(range); } scheduleBasicResolution(panel.dataset.effectId); } else if ( @@ -1602,7 +1959,7 @@ const panel = event.target.closest(".temporal-control"); markAdvancedCustom(panel); scheduleTemporalRefresh(); - } else if (event.target.closest("#effect-controls")) { + } else if (event.target.closest(".control-card")) { scheduleTemporalRefresh(); } }); @@ -1618,7 +1975,15 @@ const panel = event.target.closest(".temporal-control"); markAdvancedCustom(panel); scheduleTemporalRefresh(); - } else if (event.target.closest("#effect-controls")) { + } else if (event.target.closest(".control-card")) { + const card = event.target.closest(".control-card"); + if ( + currentMode() === "video" && + event.target.matches('.appearance-effect-controls input[type="checkbox"]') + ) { + imageState.videoEnabledEffects.set(card.dataset.effectId, event.target.checked); + updateRailItem(card.dataset.effectId); + } scheduleTemporalRefresh(); } }); @@ -1631,7 +1996,7 @@ toggleSubControls("flicker", "#flicker-params"); imageState.effectMetadataPromise = loadEffectMetadata().catch((error) => { setNotice(error.message, "error"); - throw error; + return []; }); updateRangeOutputs(); updateMode(); @@ -1642,6 +2007,22 @@ document.querySelector("#video-preview-time").addEventListener("input", schedulePreview); document.querySelector("#jump-next-glitch").addEventListener("click", nextScheduledEvent); document.querySelector("#regenerate-patterns").addEventListener("click", regeneratePatterns); + document.querySelector("#retry-effect-metadata").addEventListener("click", () => { + imageState.effectMetadataPromise = loadEffectMetadata().catch((error) => { + setNotice(error.message, "error"); + return []; + }); + }); + document.querySelector("#retry-video-preview").addEventListener("click", requestVideoPreview); + for (const button of document.querySelectorAll("[data-editor-view]")) { + button.addEventListener("click", () => { + const view = button.dataset.editorView; + document.querySelector("#preview-section").dataset.mobileView = view; + for (const peer of document.querySelectorAll("[data-editor-view]")) { + peer.setAttribute("aria-pressed", String(peer === button)); + } + }); + } document.querySelector("#cancel-processing").addEventListener("click", async () => { if (imageState.videoJobId && !imageState.videoCancelPending) { imageState.videoCancelPending = true; @@ -1666,7 +2047,6 @@ } }); document.querySelector("#cancel-preview").addEventListener("click", () => { - document.querySelector("#preview-section").hidden = true; stopVideoPolling(); releaseVideoPreview(); imageState.videoPollRevision += 1; @@ -1674,6 +2054,15 @@ imageState.videoSource = null; imageState.lastSchedule = null; document.querySelector("#jump-next-glitch").disabled = true; + document.querySelector("#preview-image").removeAttribute("src"); + document.querySelector("#video-source-metadata").textContent = "Choose a video to begin."; + document.querySelector("#workspace-source-status").textContent = "No source selected"; + document.querySelector("#render-status-badge").textContent = "Ready for a source"; + setPreviewStageState( + "empty", + "No video loaded", + "Choose a local video above. The editor remains ready while it loads.", + ); fileInput.value = ""; fileInput.focus(); }); @@ -1681,10 +2070,20 @@ stopVideoPolling(); releaseVideoPreview(); document.querySelector("#video-result").hidden = true; - document.querySelector("#preview-section").hidden = true; fileInput.value = ""; fileInput.focus(); }); + imageState.performanceMarks.shellRenderMs = performance.now(); + window.__glitchcraftReview = { + reloadEffectMetadata: () => loadEffectMetadata(), + selectEffect: (effectId) => selectEffect(effectId), + metrics: () => ({ + metadataRequests: imageState.metadataRequestCount, + resolverRequests: imageState.resolverRequestCount, + selections: imageState.selectionCount, + ...imageState.performanceMarks, + }), + }; window.addEventListener("pagehide", () => { stopVideoPolling(); releaseVideoPreview(); diff --git a/static/style.css b/static/style.css index 1d88846..16d61d5 100644 --- a/static/style.css +++ b/static/style.css @@ -1,24 +1,33 @@ :root { color-scheme: dark; - --bg: #111218; - --surface: #1a1c25; - --surface-raised: #222530; - --border: #383c49; - --border-strong: #555b6c; - --text: #f4f4f7; - --muted: #b5b8c4; - --accent: #d18be7; - --accent-strong: #e3a6f5; - --accent-ink: #231526; - --danger: #ffaaa5; - --success: #9bd8b0; - --focus: #f2c4ff; - --radius: 12px; - --space-1: 0.375rem; - --space-2: 0.75rem; - --space-3: 1rem; - --space-4: 1.5rem; - --space-5: 2rem; + --bg: #080c12; + --workspace: #0e151f; + --sidebar: #090e15; + --surface: #141d29; + --surface-raised: #1a2533; + --surface-recessed: #0c121b; + --surface-hover: #202c3c; + --surface-selected: #1c2440; + --border: rgba(190, 205, 225, 0.1); + --border-strong: rgba(190, 205, 225, 0.18); + --text: #f2f5f8; + --muted: #b8c2cf; + --subtle: #7e8b9b; + --accent: #6a5bcf; + --accent-strong: #9b8eff; + --accent-ink: #ffffff; + --ember: #f2763f; + --ember-strong: #ff9664; + --danger: #f06a72; + --success: #35c981; + --warning: #e8b44d; + --focus: #9b8eff; + --radius: 10px; + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.5rem; } * { @@ -35,7 +44,7 @@ body { background: var(--bg); color: var(--text); font-family: - Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + "Segoe UI Variable", "Segoe UI", Inter, ui-sans-serif, system-ui, -apple-system, sans-serif; font-size: 1rem; line-height: 1.5; @@ -315,7 +324,7 @@ output, } .action-primary:hover:not(:disabled) { - background: var(--accent-strong); + background: #5d4fc0; } .action-secondary { @@ -647,3 +656,781 @@ progress { padding: var(--space-3); } } + +/* Focused video workspace: slate structure, iris selection, ember transformation. */ +.workspace-shell { + width: min(100% - 1.5rem, 1680px); + padding: var(--space-4) 0 3rem; +} + +.workspace-header { + position: relative; + align-items: center; + margin-bottom: var(--space-4); + padding: var(--space-3) 0 var(--space-4); + border-bottom: 1px solid var(--border); +} + +.workspace-header::after { + position: absolute; + right: 0; + bottom: -1px; + left: 0; + height: 1px; + background: linear-gradient(90deg, var(--accent), rgba(242, 118, 63, 0.38), transparent 70%); + content: ""; +} + +.product-lockup, +.workspace-context, +.region-heading, +.preview-toolbar, +.preview-state-strip { + display: flex; + align-items: center; +} + +.product-lockup { + gap: var(--space-3); + min-width: 0; +} + +.product-mark { + width: 58px; + height: 58px; + flex: 0 0 auto; +} + +.workspace-header h1 { + font-size: clamp(1.45rem, 2.4vw, 2rem); + letter-spacing: -0.02em; +} + +.eyebrow, +.step-label { + color: var(--subtle); + letter-spacing: 0; + text-transform: none; +} + +.workspace-summary { + margin-top: var(--space-1); + font-size: 0.88rem; +} + +.workspace-context { + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--space-2); + color: var(--muted); + font-size: 0.78rem; +} + +.workspace-context > span { + min-height: 1.8rem; + padding: 0.3rem 0.55rem; + border: 1px solid var(--border); + border-radius: 999px; +} + +.workspace-context .local-badge { + color: var(--success); + border-color: color-mix(in srgb, var(--success) 24%, transparent); +} + +.control-panel, +.result-panel, +.progress-panel { + border-color: var(--border); + background: var(--surface); + box-shadow: none; +} + +.control-panel { + margin-top: 0; + padding: var(--space-4); +} + +body[data-mode="video"] .control-panel { + background: var(--workspace); +} + +body[data-mode="video"] .control-panel > .section-heading, +body[data-mode="video"] #effect-controls { + display: none; +} + +body[data-mode="video"] #upload-form { + display: grid; + grid-template-columns: auto minmax(280px, 1fr) auto; + gap: var(--space-3); + align-items: end; +} + +body[data-mode="video"] .mode-fieldset, +body[data-mode="video"] .file-field, +body[data-mode="video"] .primary-actions { + margin: 0; +} + +body[data-mode="video"] .primary-actions { + min-height: 44px; +} + +body[data-mode="video"] #upload-status { + grid-column: 1 / -1; +} + +input[type="file"], +input[type="number"], +select { + min-height: 42px; + border-color: var(--border-strong); + background: var(--surface-recessed); +} + +.video-editor { + grid-column: 1 / -1; + display: grid; + gap: var(--space-3); + min-width: 0; + margin-top: var(--space-2); + padding-top: var(--space-3); + border-top: 1px solid var(--border); +} + +.video-editor-grid { + display: grid; + grid-template-columns: minmax(230px, 260px) minmax(360px, 1fr) minmax(320px, 380px); + gap: var(--space-3); + min-width: 0; + align-items: start; +} + +.editor-region { + min-width: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); +} + +.effects-rail, +.effect-inspector { + overflow: auto; + max-height: min(700px, calc(100vh - 190px)); + padding: var(--space-3); +} + +.effects-rail { + background: var(--sidebar); +} + +.preview-workspace { + padding: var(--space-3); + background: var(--workspace); +} + +.region-heading { + justify-content: space-between; + gap: var(--space-3); + margin-bottom: var(--space-3); +} + +.region-heading h2, +.region-heading h3, +.schedule-summary h3 { + margin: 0; + font-size: 1rem; +} + +.region-count, +.status-badge { + flex: 0 0 auto; + min-height: 1.75rem; + padding: 0.25rem 0.5rem; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--muted); + font-size: 0.75rem; +} + +.inline-state { + margin-bottom: var(--space-2); + color: var(--muted); + font-size: 0.82rem; +} + +.inline-state[data-state="error"] { + color: var(--danger); +} + +.effects-rail-list { + display: grid; + gap: var(--space-1); + margin: 0; + padding: 0; + list-style: none; +} + +.effect-rail-item { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--space-2); + align-items: stretch; + min-width: 0; + border: 1px solid transparent; + border-radius: 8px; +} + +.effect-rail-item[data-selected="true"] { + border-color: color-mix(in srgb, var(--accent) 38%, transparent); + background: var(--surface-selected); + box-shadow: inset 3px 0 0 var(--accent); +} + +.effect-enable { + display: grid; + width: 36px; + min-height: 52px; + margin: 0; + place-items: center; + border-right: 1px solid var(--border); +} + +.effect-enable input { + width: 18px; + height: 18px; + accent-color: var(--accent); +} + +.effect-select { + display: grid; + gap: 2px; + min-width: 0; + padding: 0.5rem 0.5rem 0.5rem 0; + border: 0; + color: var(--text); + background: transparent; + text-align: left; + cursor: pointer; +} + +.effect-select strong, +.effect-select span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.effect-select strong { + font-size: 0.88rem; +} + +.effect-select span { + color: var(--muted); + font-size: 0.74rem; +} + +.effect-select [data-custom-status] { + color: var(--accent-strong); +} + +.preview-heading { + align-items: flex-start; +} + +.preview-heading .source-metadata { + margin: 0; + text-align: right; + font-size: 0.78rem; +} + +.preview-toolbar { + flex-wrap: wrap; + gap: var(--space-2); + margin-bottom: var(--space-2); +} + +.preview-toolbar > label { + color: var(--muted); + font-size: 0.8rem; +} + +.preview-time-control { + display: grid; + grid-template-columns: minmax(80px, 108px) auto; + align-items: center; + gap: var(--space-2); +} + +.preview-time-control span { + color: var(--muted); + font-size: 0.78rem; +} + +.preview-toolbar #jump-next-glitch { + margin-left: auto; +} + +.video-preview-stage { + position: relative; + min-height: clamp(280px, 48vh, 600px); + border-color: var(--border-strong); + border-radius: 8px; + background: #06090e; + background-image: + radial-gradient(circle at 25% 20%, rgba(106, 91, 207, 0.1), transparent 34%), + radial-gradient(circle at 82% 22%, rgba(242, 118, 63, 0.06), transparent 30%); +} + +.video-preview-stage img { + max-height: min(56vh, 620px); +} + +#preview-image:not([src]) { + display: none; +} + +.preview-state { + position: absolute; + inset: 50% auto auto 50%; + display: grid; + width: min(320px, calc(100% - 2rem)); + gap: var(--space-1); + padding: var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius); + background: rgba(12, 18, 27, 0.92); + text-align: center; + transform: translate(-50%, -50%); +} + +.preview-state span { + color: var(--muted); + font-size: 0.82rem; +} + +.video-preview-stage[data-state="ready"] .preview-state { + display: none; +} + +.video-preview-stage[data-state="error"] .preview-state { + border-color: color-mix(in srgb, var(--danger) 32%, var(--border)); +} + +.preview-state-strip { + justify-content: space-between; + gap: var(--space-2); + min-height: 38px; + margin-top: var(--space-2); +} + +.inspector-summary { + margin: 0 0 var(--space-3); + color: var(--muted); + font-size: 0.82rem; +} + +.effect-inspector-panels .control-card { + display: grid; + padding: 0; + border: 0; + background: transparent; +} + +.effect-inspector-panels .control-card[hidden] { + display: none; +} + +.effect-inspector-panels .control-card[aria-disabled="true"] { + opacity: 0.68; +} + +.effect-inspector-panels .control-card[aria-disabled="true"]::before { + display: block; + margin-bottom: var(--space-3); + padding: var(--space-2); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--muted); + background: var(--surface-recessed); + content: "This effect is disabled. Its settings remain available and will be preserved."; + font-size: 0.78rem; +} + +.temporal-control { + display: contents; + margin: 0; + padding: 0; + border: 0; +} + +.basic-effect-controls, +.appearance-effect-controls, +.advanced-effect-controls { + display: grid; + gap: var(--space-2); + margin: 0; + padding: 0 0 var(--space-4); + border: 0; + border-bottom: 1px solid var(--border); +} + +.basic-effect-controls { + order: 1; +} + +.appearance-effect-controls h3, +.basic-effect-controls legend { + margin: 0 0 var(--space-1); + color: var(--text); + font-size: 0.92rem; +} + +.appearance-effect-controls { + order: 2; +} + +.advanced-effect-controls { + order: 3; + border-bottom: 0; +} + +.basic-control-grid { + grid-template-columns: minmax(180px, 1fr) minmax(72px, 92px); + align-items: end; +} + +.basic-control-grid .temporal-field:nth-child(n + 3) { + grid-column: auto; +} + +.effect-intensity-slider { + --range-progress: 0%; + min-width: 180px; + min-height: 32px; + margin: 0; + padding: 0; + background: transparent; + cursor: pointer; + appearance: none; +} + +.effect-intensity-slider::-webkit-slider-runnable-track { + height: 6px; + border-radius: 999px; + background: + linear-gradient(var(--accent), var(--accent)) 0 / var(--range-progress) 100% no-repeat, + var(--surface-recessed); + border: 1px solid var(--border-strong); +} + +.effect-intensity-slider::-webkit-slider-thumb { + width: 18px; + height: 18px; + margin-top: -7px; + border: 2px solid var(--text); + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 0 3px var(--surface); + appearance: none; +} + +.effect-intensity-slider::-moz-range-track { + height: 6px; + border: 1px solid var(--border-strong); + border-radius: 999px; + background: var(--surface-recessed); +} + +.effect-intensity-slider::-moz-range-progress { + height: 6px; + border-radius: 999px; + background: var(--accent); +} + +.effect-intensity-slider::-moz-range-thumb { + width: 18px; + height: 18px; + border: 2px solid var(--text); + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 0 3px var(--surface); +} + +.effect-intensity-slider:hover:not(:disabled)::-webkit-slider-thumb, +.effect-intensity-slider:focus-visible::-webkit-slider-thumb { + background: var(--accent-strong); +} + +.effect-intensity-slider:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.render-dock { + padding: var(--space-3); +} + +.render-heading { + margin-bottom: var(--space-2); +} + +.render-dock-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(250px, 320px); + gap: var(--space-3); +} + +.schedule-summary { + margin: 0; + padding: var(--space-3); + background: var(--surface-recessed); +} + +.render-actions { + display: grid; + align-content: start; + gap: var(--space-2); + padding: var(--space-3); + border-left: 1px solid var(--border); +} + +.render-actions > label { + color: var(--muted); + font-size: 0.8rem; +} + +.action-transform { + border-color: color-mix(in srgb, var(--ember) 55%, transparent); + color: #111722; + background: var(--ember); +} + +.action-transform:hover:not(:disabled) { + background: var(--ember-strong); +} + +.action-success { + border-color: color-mix(in srgb, var(--success) 50%, transparent); + color: var(--success); + background: transparent; +} + +.progress-panel, +#video-result { + margin-top: var(--space-3); + padding: var(--space-3); + background: var(--surface-recessed); +} + +progress { + height: 8px; +} + +progress::-webkit-progress-value { + background: linear-gradient(120deg, #5f6fe5 0%, #765bc8 54%, #e66a3a 100%); +} + +progress::-moz-progress-bar { + background: linear-gradient(120deg, #5f6fe5 0%, #765bc8 54%, #e66a3a 100%); +} + +.status-badge[data-state="running"] { + color: var(--ember-strong); + border-color: color-mix(in srgb, var(--ember) 32%, transparent); +} + +.status-badge[data-state="complete"] { + color: var(--success); + border-color: color-mix(in srgb, var(--success) 28%, transparent); +} + +.video-mobile-nav { + display: none; +} + +body[data-mode="image"] .temporal-control { + display: none; +} + +body[data-mode="image"] .appearance-effect-controls { + padding: 0; + border: 0; +} + +body[data-mode="image"] .appearance-effect-controls > h3 { + display: none; +} + +@media (min-width: 800px) and (max-width: 1179px) { + body[data-mode="video"] #upload-form { + grid-template-columns: auto minmax(260px, 1fr); + } + + body[data-mode="video"] .primary-actions { + grid-column: 1 / -1; + } + + .video-editor-grid { + grid-template-columns: minmax(220px, 250px) minmax(0, 1fr); + } + + .effects-rail { + grid-row: 1 / span 2; + } + + .effect-inspector { + grid-column: 2; + } +} + +@media (max-width: 799px) { + .workspace-shell { + width: min(100% - 1rem, 1680px); + } + + .workspace-header, + .workspace-context { + align-items: flex-start; + } + + .workspace-context { + justify-content: flex-start; + } + + body[data-mode="video"] #upload-form { + grid-template-columns: 1fr; + } + + .video-mobile-nav { + position: sticky; + top: var(--space-2); + z-index: 20; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 2px; + padding: 3px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: rgba(9, 14, 21, 0.96); + } + + .video-mobile-nav button { + min-width: 0; + min-height: 40px; + padding: 0.35rem; + border: 0; + border-radius: 7px; + color: var(--muted); + background: transparent; + } + + .video-mobile-nav button[aria-pressed="true"] { + color: var(--text); + background: var(--surface-selected); + box-shadow: inset 0 -3px 0 var(--accent); + } + + .video-editor-grid { + display: block; + } + + .effects-rail, + .effect-inspector { + max-height: none; + } + + .video-editor[data-mobile-view="preview"] .effects-rail, + .video-editor[data-mobile-view="preview"] .effect-inspector, + .video-editor[data-mobile-view="effects"] .preview-workspace, + .video-editor[data-mobile-view="effects"] .effect-inspector, + .video-editor[data-mobile-view="inspector"] .effects-rail, + .video-editor[data-mobile-view="inspector"] .preview-workspace { + display: none; + } + + .video-editor[data-mobile-view="render"] .video-editor-grid { + display: none; + } + + .video-editor:not([data-mobile-view="render"]) .render-dock { + display: none; + } + + .render-dock-grid { + grid-template-columns: 1fr; + } + + .render-actions { + border-top: 1px solid var(--border); + border-left: 0; + } + + .preview-heading, + .preview-state-strip { + align-items: flex-start; + flex-direction: column; + } + + .preview-heading .source-metadata { + text-align: left; + } + + .preview-toolbar #jump-next-glitch { + width: 100%; + margin-left: 0; + } + + .video-preview-stage { + min-height: min(58vh, 420px); + } + + .basic-control-grid, + .temporal-grid { + grid-template-columns: 1fr; + } + + .effect-intensity-slider { + width: 100%; + } +} + +@media (max-width: 420px) { + .control-panel { + padding: var(--space-3); + } + + .product-mark { + width: 46px; + height: 46px; + } + + .workspace-summary { + display: none; + } + + .workspace-context > span:first-child { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .video-mobile-nav button { + font-size: 0.76rem; + } +} + +@media (forced-colors: active) { + .effect-rail-item[data-selected="true"], + .video-mobile-nav button[aria-pressed="true"] { + outline: 2px solid Highlight; + outline-offset: 2px; + box-shadow: none; + } + + .effect-intensity-slider { + forced-color-adjust: auto; + } +} diff --git a/templates/index.html b/templates/index.html index e3d60a4..4d23740 100644 --- a/templates/index.html +++ b/templates/index.html @@ -10,14 +10,26 @@
-
-

Local visual-effects workspace

-

GlitchCraft

-

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

+
+ +
+

Local visual-effects workspace

+

GlitchCraft

+

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

+
+
+
+ No source selected + Image mode + Local processing
- Local processing
@@ -55,7 +67,7 @@

Choose media and treatment

accept=".png,.jpg,.jpeg,.bmp,.tif,.tiff" required > -

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

+

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

@@ -170,6 +182,224 @@

Choose media and treatment

+ + @@ -215,125 +445,6 @@

Processed

- - - - -
diff --git a/tests/browser/focused-workspace.spec.js b/tests/browser/focused-workspace.spec.js new file mode 100644 index 0000000..f965010 --- /dev/null +++ b/tests/browser/focused-workspace.spec.js @@ -0,0 +1,313 @@ +const {test, expect} = require("@playwright/test"); +const AxeBuilder = require("@axe-core/playwright").default; + +const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAGCAIAAABxZ0isAAAAFElEQVR4nGMUiTrBgA0wYRWlkwQAtp4BQqPZizoAAAAASUVORK5CYII=", + "base64", +); + +async function chooseVideo(page) { + await page.getByRole("radio", {name: "Video"}).check(); + await expect(page.locator("#preview-section")).toBeVisible(); +} + +async function mockVideoWorkspace(page) { + await page.route("**/api/video-sources", (route) => + route.fulfill({ + status: 201, + contentType: "application/json", + body: JSON.stringify({ + sourceId: "review-source", + originalName: "review-signal.mp4", + width: 1280, + height: 720, + durationSeconds: 8, + frameRate: 30, + frameCount: 240, + videoCodec: "h264", + hasAudio: true, + audioCodec: "aac", + seed: 417, + }), + }), + ); + await page.route("**/api/video-sources/*/preview", (route) => + route.fulfill({ + status: 200, + contentType: "image/png", + headers: { + "X-GlitchCraft-Active-Effect-Count": "1", + "X-GlitchCraft-Active-Effect-Ids": "legacy-noise", + }, + body: png, + }), + ); + await page.route("**/api/video-sources/*/effect-schedule", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + frameRate: "30/1", + totalFrames: 240, + durationSeconds: 8, + effects: [ + { + effectId: "legacy-noise", + type: "noise", + mode: "continuous", + eventCount: 1, + truncated: false, + events: [{startSeconds: 0, endSeconds: 8, durationFrames: 240}], + }, + ], + }), + }), + ); +} + +async function uploadMockVideo(page) { + await page.setInputFiles("#input_file", { + name: "review-signal.mp4", + mimeType: "video/mp4", + buffer: Buffer.from("deterministic review video"), + }); + await page.getByRole("button", {name: "Create video preview"}).click(); + await expect(page.locator("#video-preview-stage")).toHaveAttribute("data-state", "ready"); +} + +test("metadata failure keeps the shell stable and retry restores independent controls", async ({ + page, +}) => { + let failMetadata = true; + await page.route("**/api/effects", async (route) => { + if (failMetadata) { + await route.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({error: {message: "metadata unavailable"}}), + }); + } else { + await route.continue(); + } + }); + await page.goto("/"); + await chooseVideo(page); + await expect(page.locator(".video-editor-grid")).toBeVisible(); + await expect(page.locator("#effect-metadata-state")).toContainText( + "could not be loaded", + ); + failMetadata = false; + await page.getByRole("button", {name: "Retry effect controls"}).click(); + await expect(page.locator(".effect-rail-item")).toHaveCount(8); + await expect(page.locator("#effect-metadata-state")).toHaveText("8 effects ready"); +}); + +test("metadata reload failure preserves the current preview and recovers in place", async ({ + page, +}) => { + let failMetadata = false; + await page.route("**/api/effects", async (route) => { + if (failMetadata) { + await route.fulfill({status: 503, body: "temporary metadata failure"}); + } else { + await route.continue(); + } + }); + await mockVideoWorkspace(page); + await page.goto("/"); + await chooseVideo(page); + await expect(page.locator("#effect-metadata-state")).toHaveText("8 effects ready"); + await uploadMockVideo(page); + const previewUrl = await page.locator("#preview-image").getAttribute("src"); + failMetadata = true; + await page.evaluate(() => window.__glitchcraftReview.reloadEffectMetadata().catch(() => {})); + await expect(page.locator("#effect-metadata-state")).toContainText( + "current preview is preserved", + ); + await expect(page.locator("#preview-image")).toHaveAttribute("src", previewUrl); + failMetadata = false; + await page.getByRole("button", {name: "Retry effect controls"}).click(); + await expect(page.locator("#effect-metadata-state")).toHaveText("8 effects ready"); + await expect(page.locator("#preview-image")).toHaveAttribute("src", previewUrl); +}); + +test("one resolver failure stays local and blocks only an enabled unresolved effect", async ({ + page, +}) => { + await page.route("**/api/effects/resolve-basic", async (route) => { + if (route.request().postDataJSON().effectType === "horizontal_glitch") { + await route.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({error: {message: "horizontal resolver unavailable"}}), + }); + } else { + await route.continue(); + } + }); + await page.goto("/"); + await chooseVideo(page); + await expect(page.locator("#effect-metadata-state")).toContainText("1 need attention"); + await page.getByRole("button", {name: "Edit Horizontal glitch"}).click(); + const glitch = page.locator( + '.temporal-control[data-effect-id="legacy-horizontal-glitch"]', + ); + await expect(glitch.locator("[data-basic-validation]")).toContainText( + "Basic controls could not be resolved", + ); + await expect(glitch.locator(".advanced-effect-controls")).toBeVisible(); + await expect( + page.locator( + '.temporal-control[data-effect-id="legacy-noise"] [data-basic-role="intensity"]', + ), + ).toBeEnabled(); + await page.getByRole("checkbox", {name: "Enable Horizontal glitch"}).check(); + await page.setInputFiles("#input_file", { + name: "blocked.mp4", + mimeType: "video/mp4", + buffer: Buffer.from("video"), + }); + await page.getByRole("button", {name: "Create video preview"}).click(); + await expect(page.locator("#image-notice")).toContainText( + "Enabled effects must finish loading", + ); +}); + +test("selection is local, disabled effects remain selectable, and custom state persists", async ({ + page, +}) => { + await page.goto("/"); + await chooseVideo(page); + await expect(page.locator("#effect-metadata-state")).toHaveText("8 effects ready"); + const before = await page.evaluate(() => window.__glitchcraftReview.metrics()); + await page.getByRole("button", {name: "Edit Static"}).click(); + await expect(page.locator("#selected-effect-status")).toHaveText("Disabled"); + await page.getByRole("checkbox", {name: "Enable Static"}).check(); + const staticPanel = page.locator('.temporal-control[data-effect-id="legacy-static"]'); + await staticPanel.locator("summary").click(); + const start = staticPanel.locator('[data-temporal-role="start"]'); + await start.fill("1.25"); + await expect(staticPanel.locator("[data-basic-status]")).toContainText("customized"); + await page.getByRole("button", {name: "Edit Noise"}).click(); + await page.getByRole("button", {name: "Edit Static"}).click(); + await expect(start).toHaveValue("1.25"); + await expect( + page.locator( + '.effect-rail-item[data-effect-id="legacy-static"] [data-custom-status]', + ), + ).toHaveText("Custom"); + const after = await page.evaluate(() => window.__glitchcraftReview.metrics()); + expect(after.metadataRequests).toBe(before.metadataRequests); + expect(after.resolverRequests).toBe(before.resolverRequests); + const duplicateIds = await page.evaluate(() => { + const ids = [...document.querySelectorAll("[id]")].map((element) => element.id); + return ids.filter((id, index) => ids.indexOf(id) !== index); + }); + expect(duplicateIds).toEqual([]); + const hiddenFocusable = await page.evaluate(() => + [...document.querySelectorAll(".control-card[hidden]")].some((card) => + [...card.querySelectorAll("button, input, select, a[href]")].some( + (control) => !control.closest("[inert]"), + ), + ), + ); + expect(hiddenFocusable).toBe(false); + await staticPanel.getByRole("button", {name: "Reset to Basic controls"}).click(); + await expect(start).toHaveValue("0"); + await expect(page.locator("#effect-inspector-heading")).toHaveText("Static"); +}); + +test("stale resolver responses cannot overwrite the newest intensity", async ({page}) => { + let newestResolvedIntensity = null; + await page.route("**/api/effects/resolve-basic", async (route) => { + const payload = route.request().postDataJSON(); + const response = await route.fetch(); + if (payload.effectType === "noise" && payload.intensity === 72) { + newestResolvedIntensity = (await response.json()).resolved.intensity; + } + if (payload.effectType === "noise" && payload.intensity === 41) { + await new Promise((resolve) => setTimeout(resolve, 350)); + } + try { + await route.fulfill({response}); + } catch { + // The stale request is expected to be aborted. + } + }); + await page.goto("/"); + await chooseVideo(page); + const number = page.locator( + '.temporal-control[data-effect-id="legacy-noise"] [data-basic-role="intensity-number"]', + ); + await number.fill("41"); + await page.waitForTimeout(220); + await number.fill("72"); + await expect(number).toHaveValue("72"); + await expect.poll(() => newestResolvedIntensity).not.toBeNull(); + await expect( + page.locator( + '.temporal-control[data-effect-id="legacy-noise"] [data-temporal-role="intensity"]', + ), + ).toHaveValue(String(newestResolvedIntensity)); +}); + +test("workspace geometry, slider affordance, performance, and accessibility stay bounded", async ({ + page, +}) => { + await page.goto("/"); + await chooseVideo(page); + await expect(page.locator("#effect-metadata-state")).toHaveText("8 effects ready"); + for (const viewport of [ + {width: 1440, height: 900}, + {width: 1280, height: 720}, + {width: 1024, height: 768}, + {width: 768, height: 1024}, + {width: 390, height: 844}, + {width: 360, height: 800}, + ]) { + await page.setViewportSize(viewport); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, + ), + `horizontal overflow at ${viewport.width}x${viewport.height}`, + ).toBe(true); + if (viewport.width < 800) { + for (const view of ["preview", "effects", "inspector", "render"]) { + await page.getByRole("button", {name: new RegExp(`^${view}$`, "i")}).click(); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, + ), + `${view} overflow at ${viewport.width}`, + ).toBe(true); + } + } + } + await page.setViewportSize({width: 1440, height: 900}); + const regionWidths = await page.evaluate(() => ({ + rail: document.querySelector(".effects-rail").getBoundingClientRect().width, + inspector: document.querySelector(".effect-inspector").getBoundingClientRect().width, + })); + expect(regionWidths.rail).toBeGreaterThanOrEqual(230); + expect(regionWidths.rail).toBeLessThanOrEqual(280); + expect(regionWidths.inspector).toBeGreaterThanOrEqual(320); + expect(regionWidths.inspector).toBeLessThanOrEqual(400); + const slider = page.locator(".effect-intensity-slider:visible"); + const sliderBox = await slider.boundingBox(); + expect(sliderBox.width).toBeGreaterThanOrEqual(180); + expect(sliderBox.height).toBeGreaterThanOrEqual(32); + await expect(slider).toHaveAttribute("aria-valuetext", /intensity/); + const metrics = await page.evaluate(() => window.__glitchcraftReview.metrics()); + expect(metrics.shellRenderMs).toBeLessThan(1500); + expect(metrics.metadataToFirstInspectorMs).toBeLessThan(1500); + expect(metrics.effectSelectionMs).toBeLessThan(100); + expect(metrics.metadataRequests).toBe(1); + expect(metrics.resolverRequests).toBe(8); + const axe = await new AxeBuilder({page}).analyze(); + expect( + axe.violations.filter((violation) => + ["serious", "critical"].includes(violation.impact), + ), + ).toEqual([]); +}); diff --git a/tests/browser/image-workflow.spec.js b/tests/browser/image-workflow.spec.js index ce007a1..4ed8fa2 100644 --- a/tests/browser/image-workflow.spec.js +++ b/tests/browser/image-workflow.spec.js @@ -418,7 +418,8 @@ test("persistent video UI uploads once, previews a timestamp, and polls its job" await page.locator("#video-preview-time").fill("1.5"); await expect.poll(() => previewTimestamp).toBe(1.5); - await page.locator("#glitch").check(); + await page.getByRole("button", {name: "Edit Horizontal glitch"}).click(); + await page.getByRole("checkbox", {name: "Enable Horizontal glitch"}).check(); const glitchTiming = page.locator( '.temporal-control[data-effect-id="legacy-horizontal-glitch"]', ); @@ -484,8 +485,10 @@ test("persistent video UI uploads once, previews a timestamp, and polls its job" await page.getByRole("button", {name: "Jump to next scheduled glitch"}).click(); await expect(page.locator("#video-preview-time")).toHaveValue("2"); await page.setViewportSize({width: 390, height: 844}); - await glitchTiming.scrollIntoViewIfNeeded(); - await expect(glitchTiming).toBeInViewport(); + await page.getByRole("button", {name: "Inspector"}).click(); + const glitchBasicControls = glitchTiming.locator(".basic-effect-controls"); + await glitchBasicControls.scrollIntoViewIfNeeded(); + await expect(glitchBasicControls).toBeInViewport(); const accessibility = await new AxeBuilder({page}) .withTags(["wcag2a", "wcag2aa"]) .analyze(); @@ -494,7 +497,9 @@ test("persistent video UI uploads once, previews a timestamp, and polls its job" ["serious", "critical"].includes(violation.impact), ), ).toEqual([]); - await page.locator("#scan_lines").check(); + await page.getByRole("button", {name: "Effects"}).click(); + await page.getByRole("checkbox", {name: "Enable Horizontal scan lines"}).check(); + await page.getByRole("button", {name: "Render"}).click(); await page.getByRole("button", {name: "Process full video"}).click(); await expect(page.locator("#progress-indicator")).toBeVisible(); await expect(page.locator("#video-progress-heading")).toContainText("2 jobs ahead"); diff --git a/tests/ui-review/ui-screens.spec.js b/tests/ui-review/ui-screens.spec.js new file mode 100644 index 0000000..e8be3ae --- /dev/null +++ b/tests/ui-review/ui-screens.spec.js @@ -0,0 +1,255 @@ +const path = require("path"); +const {test, expect} = require("@playwright/test"); + +const outputRoot = path.join(".tmp", "ui-review"); +const previewSvg = Buffer.from(` + + + + + + + + + + + + + + + + + + + + + GLITCHCRAFT // SIGNAL PREVIEW + FRAME 072 · 00:02.40 · HORIZONTAL GLITCH ACTIVE + + + + + + + +`); + +async function capture(page, name) { + await page.screenshot({ + path: path.join(outputRoot, `${name}.png`), + fullPage: false, + animations: "disabled", + }); +} + +test("captures the deterministic focused-workspace review set", async ({page}) => { + let jobState = "processing"; + await page.route("**/api/video-sources", (route) => + route.fulfill({ + status: 201, + contentType: "application/json", + body: JSON.stringify({ + sourceId: "ui-review-source", + originalName: "deterministic-signal.mp4", + width: 1920, + height: 1080, + durationSeconds: 12, + frameRate: 30, + frameCount: 360, + videoCodec: "h264", + hasAudio: true, + audioCodec: "aac", + seed: 20260728, + }), + }), + ); + await page.route("**/api/video-sources/*/preview", (route) => + route.fulfill({ + status: 200, + contentType: "image/svg+xml", + headers: { + "X-GlitchCraft-Active-Effect-Count": "1", + "X-GlitchCraft-Active-Effect-Ids": "legacy-horizontal-glitch", + }, + body: previewSvg, + }), + ); + await page.route("**/api/video-sources/*/effect-schedule", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + frameRate: "30/1", + totalFrames: 360, + durationSeconds: 12, + effects: [ + { + effectId: "legacy-noise", + type: "noise", + mode: "continuous", + eventCount: 1, + truncated: false, + events: [{startSeconds: 0, endSeconds: 12, durationFrames: 360}], + }, + { + effectId: "legacy-horizontal-glitch", + type: "horizontal_glitch", + mode: "sporadic", + eventCount: 2, + truncated: false, + events: [ + {startSeconds: 2.4, endSeconds: 2.7, durationFrames: 9}, + {startSeconds: 7.2, endSeconds: 7.6, durationFrames: 12}, + ], + }, + ], + }), + }), + ); + await page.route("**/api/video-sources/*/jobs", (route) => + route.fulfill({ + status: 202, + contentType: "application/json", + body: JSON.stringify({jobId: "ui-review-job"}), + }), + ); + await page.route("**/api/video-jobs/ui-review-job", (route) => { + const completed = jobState === "completed"; + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + state: completed ? "completed" : "processing", + phase: completed ? "completed" : "applying_effects", + progress: completed ? 100 : 48, + outputUrl: completed ? "/api/video-outputs/ui-review-output" : null, + telemetry: { + phase: completed ? "completed" : "applying_effects", + phaseLabel: completed ? "Complete" : "Applying effects", + progress: completed ? 100 : 48, + updatedAt: "2026-07-28T06:00:00Z", + stale: false, + enabledEffectCount: 2, + enabledEffectTypes: ["noise", "horizontal_glitch"], + audioMode: "preserve", + attempt: 1, + recoveredAfterRestart: false, + queuePosition: null, + jobsAhead: null, + activeWorkers: 1, + workerConcurrency: 1, + queueCapacity: 4, + framesProcessed: completed ? 360 : 173, + totalFrames: 360, + sourceFrameRate: "30/1", + processedDurationSeconds: completed ? 12 : 5.77, + sourceDurationSeconds: 12, + currentFramesPerSecond: completed ? null : 29.8, + averageFramesPerSecond: 29.4, + stageElapsedSeconds: completed ? 0 : 6, + totalElapsedSeconds: completed ? 13 : 7, + estimatedRemainingSeconds: completed ? null : 7, + outputBytes: completed ? 12582912 : null, + timingSummary: completed + ? {framesProcessed: 360, totalDurationSeconds: 13} + : null, + }, + }), + }); + }); + + await page.setViewportSize({width: 1440, height: 900}); + await page.goto("/"); + await page.getByRole("radio", {name: "Video"}).check(); + await expect(page.locator("#effect-metadata-state")).toHaveText("8 effects ready"); + await capture(page, "desktop-1440-empty"); + + await page.setInputFiles("#input_file", { + name: "deterministic-signal.mp4", + mimeType: "video/mp4", + buffer: Buffer.from("stable screenshot fixture"), + }); + await page.getByRole("button", {name: "Create video preview"}).click(); + await expect(page.locator("#video-preview-stage")).toHaveAttribute("data-state", "ready"); + await capture(page, "desktop-1440-uploaded"); + + await page.setViewportSize({width: 1024, height: 768}); + await capture(page, "medium-1024-uploaded"); + await page.setViewportSize({width: 1440, height: 900}); + + await page.getByRole("button", {name: "Edit Horizontal glitch"}).click(); + await page.getByRole("checkbox", {name: "Enable Horizontal glitch"}).check(); + const glitch = page.locator( + '.temporal-control[data-effect-id="legacy-horizontal-glitch"]', + ); + await glitch.locator("summary").click(); + await page.evaluate(() => { + window.scrollTo(0, 0); + document.querySelector(".effect-inspector").scrollTop = 1000; + }); + await capture(page, "desktop-1440-advanced"); + + await glitch.locator('[data-temporal-role="start"]').fill("1.5"); + await expect(glitch.locator("[data-basic-status]")).toContainText("customized"); + await page.evaluate(() => { + window.scrollTo(0, 0); + document.querySelector(".effect-inspector").scrollTop = 1000; + }); + await capture(page, "desktop-1440-custom"); + + await page.getByRole("button", {name: "Process full video"}).click(); + await expect(page.locator("#progress-indicator")).toBeVisible(); + await expect(page.locator("#video-progress-heading")).toContainText("Applying 2 effects"); + await page.locator("#progress-indicator").scrollIntoViewIfNeeded(); + await capture(page, "desktop-1440-rendering"); + + jobState = "completed"; + await expect(page.locator("#video-result")).toBeVisible({timeout: 5_000}); + await page.locator("#video-result").scrollIntoViewIfNeeded(); + await capture(page, "desktop-1440-completed"); + + await page.setViewportSize({width: 768, height: 1024}); + for (const view of ["preview", "inspector", "effects"]) { + await page.locator(`[data-editor-view="${view}"]`).click(); + await capture(page, `tablet-768-${view}`); + } + + await page.setViewportSize({width: 390, height: 844}); + await page.locator('[data-editor-view="effects"]').click(); + await capture(page, "mobile-390-effects"); + await page.locator('[data-editor-view="inspector"]').click(); + if (await glitch.locator("details").evaluate((details) => details.open)) { + await glitch.locator("summary").click(); + } + await page.evaluate(() => { + document.querySelector(".effect-inspector").scrollTop = 0; + document.querySelector(".effect-inspector").scrollIntoView({block: "start"}); + }); + await capture(page, "mobile-390-basic"); + await glitch.locator("summary").click(); + await capture(page, "mobile-390-advanced"); + await page.locator('[data-editor-view="render"]').click(); + await capture(page, "mobile-390-render"); + + const captures = [ + "desktop-1440-empty", + "desktop-1440-uploaded", + "desktop-1440-advanced", + "desktop-1440-custom", + "desktop-1440-rendering", + "desktop-1440-completed", + "medium-1024-uploaded", + "tablet-768-preview", + "tablet-768-inspector", + "tablet-768-effects", + "mobile-390-effects", + "mobile-390-basic", + "mobile-390-advanced", + "mobile-390-render", + ]; + console.log(`UI review captured ${captures.length} states in ${outputRoot}`); + for (const name of captures) { + console.log(path.join(outputRoot, `${name}.png`)); + } +});