diff --git a/.claude/skills/run-concat/SKILL.md b/.claude/skills/run-concat/SKILL.md new file mode 100644 index 00000000..adbf82e3 --- /dev/null +++ b/.claude/skills/run-concat/SKILL.md @@ -0,0 +1,62 @@ +--- +name: run-concat +description: Build, launch, and verify the Concat desktop app after a code change. Use this whenever the user asks to run Concat, test a change in the app, check whether something works, see the editor, or verify a fix "in the real app" rather than just via tests — even if they don't say "run the app" explicitly (e.g. "does this look right now", "let's see it"). Concat is a native Rust + Slint desktop video editor with a Cargo workspace root at src/, not the repo root, which trips up plain `cargo run`. +--- + +# Running and verifying Concat + +Concat is a native Rust + Slint desktop app. The git repo root and the Cargo workspace root are **different directories** — this is the single most common way a run fails. + +## Repo layout + +- Git repo root: wherever `.git` lives (e.g. `/Users/maubrick/Documents/Coding Projects/Concat`). +- Cargo workspace root: `/src` — this is where `Cargo.toml` for the workspace lives. **Every `cargo` command must run from here**, not the repo root. + +If a `cargo` command fails with something like `no such file or directory` for a path that looks otherwise correct, the most likely cause is running from the repo root instead of `src/`. Fix with `cd` into `src/` (use the absolute path — don't assume the shell's prior `cd` state). + +## Building and running + +```bash +cd "/src" +cargo run --profile quick -p concat # optimized build, much faster than a full release build — the default choice for iteration +cargo run -p concat # plain debug build if `quick` profile is unavailable for some reason +``` + +The `quick` profile build of the whole workspace typically takes a few minutes the first time (or after touching a widely-depended-on crate like `concat-core` or `concat-effects`) and is fast on incremental rebuilds. Don't assume a hang — a multi-minute compile is normal for this codebase's size. + +This launches a real GUI window (Slint desktop app on macOS/Windows/Linux). It is **not** a web app and **not** an iOS simulator target — don't reach for the browser tools or the iOS simulator tools for it. There is no dedicated MCP or CLI-driven UI automation for this app in a typical session; verification is primarily: + +1. **Does it build and launch without error.** Read the terminal it's running in (prefer the terminal MCP's `read_terminal` over asking the user to paste output, if a terminal tool is available) and check for a clean startup log line (e.g. `INFO [main] concat_host::logs: Concat · · INFO`) with no panics or error-level lines after it. +2. **Ask the user to look.** Since there's no headless way to drive this specific GUI, once it's running, tell the user what to check and let them report back — don't claim a UI behavior works without either seeing it yourself (if a computer-use tool is available and the user has granted it) or being told so by the user. This matches the project's general rule: type-checking and test suites verify code correctness, not feature correctness. +3. **If a computer-use tool is available and the user wants a screenshot**, it's a normal macOS window — screenshot/click it like any other native app, not through the browser or simulator tools. + +## Verification commands (run these first — cheaper and faster than launching the GUI) + +```bash +cd "/src" + +# Full test suite for the crates most likely to be touched by an effects/transitions/rendering change +cargo test -p concat-effects -p concat-export -p concat-render -p concat-project --features gpu + +# Full app type-check, including the Slint UI macro — catches .slint wiring errors cargo test alone won't +cargo check -p concat + +# Lint +cargo clippy -p concat -p concat-export -p concat-effects -p concat-render -p concat-project \ + --features concat-render/gpu -- -D warnings +``` + +Run `cargo check -p concat` even for changes that look pure-Rust or pure-`.slint` — the Slint build macro re-generates Rust bindings from the `.slint` files at compile time, so a mismatch between a `.slint` property/callback and its Rust wiring only surfaces here, not in a narrower per-crate `cargo check`. + +The `--features gpu` tests actually create a real GPU pipeline (via wgpu) and run shaders on it — this is the only thing that catches a bind-group-layout or binding mistake in a `.wgsl` shader; plain compilation of the shader (which `cargo test -p concat-effects` alone also does, via naga validation) only catches syntax and type errors, not pipeline-creation errors. Don't skip the GPU tests for a shader change just because the plain build succeeded. + +## Package system note (if the change touches effects/filters/transitions) + +Concat auto-discovers every effect/filter/transition/audio package from a folder under `src/crates/concat-effects/packages/concat./` at compile time (`build.rs`, via `include_str!`) — a new package needs no Rust code to register it, just the folder. If a change there doesn't show up after a rebuild, check the folder was actually picked up (`cargo clean -p concat-effects` forces `build.rs` to rerun if `cargo`'s change-detection seems stale) before assuming the change itself is wrong. + +## Quick checklist for "does this change actually work" + +1. Run the cheap verification commands above first — most bugs are caught here, long before a GUI launch is worth the time. +2. `cd` into `src/` with an absolute path, then `cargo run --profile quick -p concat`. +3. Read the terminal for a clean startup and no error-level log lines. +4. Tell the user specifically what to click/check for the change in question, rather than a generic "let me know how it looks" — point at the exact panel, tab, or clip state that exercises the change. diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 00000000..f6d8c629 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,343 @@ +# Concat — Engineering Handoff + +**Date**: 2026-09-19 +**Status**: Builds clean · Tests passing · All changes uncommitted on `main` + +--- + +## 1. What Is Concat + +Concat is a **native, offline-first video editor** built with **Rust + Slint UI**, targeting CapCut feature parity. It runs on macOS, Windows, Linux, iOS, and Android from a single codebase. There is no cloud dependency, no sign-in, and no telemetry — everything runs on-device. + +The project is in active early development. The goal is a free, open alternative to CapCut with the same snappiness and creative breadth. + +--- + +## 2. Repository Layout + +``` +Concat/ ← git repo root +├── src/ ← Cargo workspace root (cd here for ALL cargo commands) +│ └── crates/ +│ ├── concat/ ← Main desktop app (Slint UI + Rust glue) +│ │ ├── src/ +│ │ │ ├── lib.rs ← All UI→Rust callback wiring (on_* handlers) +│ │ │ └── studio.rs ← Core editor state machine (~8 000 lines) +│ │ └── ui/ +│ │ ├── workspace/ +│ │ │ └── media-pane.slint ← Library panel (Effects/Transitions/Filters/etc.) +│ │ ├── timeline/ +│ │ │ └── lanes.slint ← Timeline lanes, clip cards, transition seams +│ │ └── inspector/ +│ │ └── clip-inspector.slint ← Right-panel inspector +│ ├── concat-effects/ ← Package system: all effects/filters/transitions/audio +│ │ ├── packages/ ← 165 packages, one folder each (auto-discovered at build) +│ │ └── src/ +│ │ ├── catalogue.rs ← Category pill filtering logic +│ │ ├── manifest.rs ← effect.toml schema + Kind enum +│ │ └── expr.rs ← Tiny template language for FFmpeg chain strings +│ ├── concat-export/ ← FFmpeg-based export pipeline +│ │ └── src/lib.rs ← resolve_transitions(), composite_treated() +│ ├── concat-project/ ← Project model, commands, animation presets +│ │ └── src/animation.rs ← All animation keyframe presets +│ ├── concat-render/ ← GPU compositor (wgpu), transition shader runtime +│ │ └── src/gpu.rs ← GPU regression tests (run with --features gpu) +│ ├── concat-core/ ← TransitionShader / TransitionPass types +│ ├── concat-api/ ← Stable public API +│ ├── concat-cli/ ← CLI front-end +│ ├── concat-media/ ← Media probe & decode +│ ├── concat-server/ ← HTTP server / MCP integration +│ ├── concat-speech/ ← TTS / captions / speech +│ ├── concat-text/ ← Text rendering +│ └── concat-vision/ ← Background removal, segmentation models +├── engine/ ← Native rendering engine (non-Cargo) +├── assets/ ← App icons, splash screens +├── models/ ← Bundled AI models (segmentation, etc.) +└── scripts/ ← Tooling scripts (locale checker, etc.) +``` + +> **CRITICAL**: The Cargo workspace root is `src/`, not the git repo root. Every `cargo` command must be run from `src/`. + +--- + +## 3. Dev Setup + +```bash +# Prerequisites: Rust stable, FFmpeg (system), wgpu-compatible GPU driver + +git clone +cd "Concat/src" + +# Run the desktop app +cargo run -p concat + +# Faster iteration build profile +cargo run --profile quick -p concat + +# Full test suite (includes GPU shader tests) +cargo test -p concat-effects -p concat-export -p concat-render -p concat-project --features gpu + +# Type-check the whole app (including Slint UI macros) — ~45s +cargo check -p concat + +# Lint everything +cargo clippy -p concat -p concat-export -p concat-effects -p concat-render -p concat-project \ + --features concat-render/gpu -- -D warnings +``` + +> **Note for sandbox/CI environments**: `cargo check` and `cargo test` require network access to `index.crates.io`. They will fail in a fully sandboxed environment even with `--offline`. Always run these commands with real network access. + +--- + +## 4. Architecture — Things You Must Know + +### 4.1 The Package System (Effects, Filters, Transitions, Audio) + +Every built-in effect/filter/transition/audio item is a **folder** in `src/crates/concat-effects/packages/`. Naming convention: `concat./`. + +Each folder contains: +- `effect.toml` — manifest (id, name, kind, params, category tags) +- `main.wgsl` or `effect.wgsl` — GPU shader (required for transitions; optional for effects/filters which have a CPU fallback) +- `ffmpeg.txt` or `ffmpeg.wgsl` — CPU-path FFmpeg filtergraph string (effects and filters; **forbidden** for transitions) +- `fixtures.toml` — golden test fixtures pinning exact FFmpeg chain output + +**`build.rs` auto-discovers every folder at compile time.** Adding a package = adding a folder. Nothing in Rust ever names individual packages by ID. `Catalogue::builtin()` is the runtime entry point. + +### 4.2 The `Kind` Enum (`manifest.rs`) + +| Kind | GPU | FFmpeg | Intensity Slider | Notes | +|---|---|---|---|---| +| `Effect` | ✅ | ✅ | ❌ (define your own params) | Applied to selected clip's chain | +| `Filter` | ✅ | ✅ | ✅ (free, auto, don't declare it) | Overlay layer on timeline | +| `Transition` | ✅ ONLY | ❌ forbidden | ❌ | Two-input shader, `transition()` fn | +| `Audio` | — | ✅ | depends | Applied to clip's audio chain | +| `Generator` | reserved | — | — | No packages exist yet | + +### 4.3 Transition Runtime + +The GPU transition pipeline spans four crates: + +``` +effect.toml [transition] → TransitionShader (concat-core) + → TransitionPass (concat-render GPU compositor) + → Compositor::combine() called per-frame during preview + → resolve_transitions() + composite_treated() (concat-export) + → exported via FFmpeg frame-by-frame +``` + +A transition shader body declares: +```wgsl +fn transition(uv: vec2, progress: f32) -> vec4 { + // from_at(uv) = outgoing clip pixel + // to_at(uv) = incoming clip pixel + // progress = 0.0 (start of transition) → 1.0 (end) +} +``` + +**Handle constraint** (not a bug): a dissolve needs real unused source footage ("handle") before/after a clip's trim point. `Studio::apply_transition()` computes available handle and clamps or refuses. `resolve_transitions()` previously had a double-clamp bug that made it impossible to add transitions to untrimmed clips — this was fixed (see §6.3). + +### 4.4 UI Callback Wiring + +Every UI event flows: +``` +Slint UI callback → lib.rs on_* handler → studio.rs method → apply(Command) → state mutation → notify() +``` + +- `src/crates/concat/src/lib.rs` — **All** UI→Rust wiring lives here. ~1 400 lines of `editor.on_*` registrations. +- `src/crates/concat/src/studio.rs` — The editor state machine. Nearly everything goes through `self.apply(Command::...)`. + +### 4.5 Effects vs Filters vs Audition (IMPORTANT) + +This distinction caused a critical bug and is non-obvious: + +| | Applied where | Single-click behaviour | Double-click / `+` | +|---|---|---|---| +| **Effect** (tab 3) | Selected clip's `video_effects` chain | ✅ Applies directly | N/A (same) | +| **Filter** (tab 4) | Overlay layer placed at playhead | Auditions on monitor (overlay preview only) | Places as layer | +| **Transition** (tab 2) | Cut between two adjacent clips | ✅ Applies directly | N/A (same) | +| **Audio** (tab 3, audio mode) | Selected clip's `filters` chain | ✅ Applies directly | N/A (same) | + +**The `audition` system** (`self.audition: Option` in `studio.rs`, `audition_catalogue()`) renders an effect/filter overlay on the monitor **without touching the project**. It was designed for **Filters only**. If you wire `auditions: true` + `auditioned(id) => { root.audition-filter(id); }` to the Effects or Transitions shelf, single-click will flash the effect across the entire monitor frame (looks like "applied to the imported file"). **Do not do this.** The Filters shelf correctly uses `auditions: true`; Effects and Transitions shelves must use `auditions: false`. + +### 4.6 Category Pill Bar + +The Effects shelf has a horizontal pill category bar: + +`All | Featured | Retro & Film | Optical & Lens | Distortion & Glitch | Party & Club | Light & Shadow` + +- `matches_category(id, cat)` in `catalogue.rs` maps package IDs to categories +- `Library::category_changed()` (Slint global) + `Studio::library_category()` drive the filter +- When `category == "All"`, `view.group` is set to `-1` to bypass the group filter + +### 4.7 The `{...}` Template Language in FFmpeg Chains + +`expr.rs` implements a small expression language for FFmpeg filtergraph strings. Supported: `if / clamp / min / max / abs / floor / ceil / round / sqrt / pow / fixed`, arithmetic, comparisons. **No trig (`sin/cos/atan2`).** For per-pixel math (ripple, warps), use FFmpeg's own `geq` filter's expression language instead — it has `sin/hypot/T/X/Y/W/H/r/g/b`. + +> ⚠️ Always spot-check a `geq` chain against a real `ffmpeg` binary before trusting it: +> ```bash +> ffmpeg -f lavfi -i testsrc=size=64x64:rate=1:duration=1 -vf "" -frames:v 1 -f null - +> ``` + +--- + +## 5. The Effects Library (165 Packages) + +### 5.1 Transitions (19 packages) + +| Package ID | Name | +|---|---| +| `concat.blur-dissolve` | Blur Dissolve | +| `concat.film-burn` | Film Burn | +| `concat.zoom-transition` | Zoom | +| `concat.slide` | Slide (L/R/U/D enum) | +| `concat.push` | Push (L/R/U/D enum) | +| `concat.spin` | Spin | +| `concat.swirl` | Swirl | +| `concat.glitch-transition` | Glitch | +| `concat.rgb-split-wipe` | RGB Split Wipe | +| `concat.linear-wipe` | Linear Wipe (L/R/U/D enum) | +| `concat.iris` | Iris | +| `concat.shape-wipe` | Shape Wipe (Circle/Heart/Star enum) | +| `concat.light-leak-transition` | Light Leak | +| `concat.cube` | Cube | +| `concat.clock-wipe` | Clock Wipe | +| `concat.whip-pan` | Whip Pan | +| `concat.zoom-blur-transition` | Zoom Blur | +| `concat.light-leak-flash` | Light Leak Flash | +| `concat.glitch-warp` | Glitch Warp | + +### 5.2 Video Effects — Modern Pack (19 packages added in recent session) + +**Featured**: `concat.camera-shake`, `concat.rgb-glitch`, `concat.sparkle`, `concat.light-leak`, `concat.tilt-shift`, `concat.strobe-flash` + +**Cinematic & Film**: `concat.crt-scanlines`, `concat.film-reel`, `concat.camcorder-90s`, `concat.halation` + +**Distortion & Optical**: `concat.wave-warp`, `concat.vortex-swirl`, `concat.prism-dispersion`, `concat.fisheye`, `concat.mirror-tile` + +**Party & Club**: `concat.bass-shockwave`, `concat.laser-beams`, `concat.neon-glow`, `concat.color-cycle` + +### 5.3 Fixtures Convention + +Every package with an `[ffmpeg]` chain must have a `fixtures.toml` with: +- `[[case]] at = "default"` — always required +- `[[case]] at = "min"` and `[[case]] at = "max"` — required if the package has any `[[param]]` + +Enforced by the test `every_ffmpeg_package_pins_its_default_and_every_slider_bound` in `concat-effects/src/lib.rs`. + +**Fastest way to generate fixtures**: write a temporary `#[test]` that loads via `Catalogue::builtin()` and prints `package.ffmpeg_fragment(¶ms, index)` with `--nocapture`, paste into `fixtures.toml`, then delete the test. + +--- + +## 6. Recent Work (This Session — All Uncommitted) + +### 6.1 Transition Engine Fix (`concat-export/src/lib.rs`) +- Removed a double-clamp bug in `resolve_transitions()` that prevented transitions on untrimmed clips +- Source start is now clamped to `.max(0.0)` only (was also clamping against `b.source_start / b.speed`) +- Added tests: `a_cross_fade_on_untrimmed_clip_succeeds`, `a_packaged_transition_on_untrimmed_clip_succeeds` + +### 6.2 Transition Seam UI Redesign (`lanes.slint`) +- Symmetric cut placement (seam spans `-half-w` to `+half-w`) +- 18×18px rounded square seam button `[ ⧗ ]` centered at the cut +- Left/right draggable slider handles (3px wide, 14px hit target, `ew-resize` cursor) +- Floating duration tooltip pill +- Fixed `if` conditional blocks to `visible` properties for proper TouchArea access + +### 6.3 Live Animated Previews (`clip-inspector.slint`) +- Added `id` field to `AnimCardData` +- Live procedural hover animations per type (Zoom In/Out, Slide Up/Down/Left/Right, Spin, Pulse, Fade, Bounce, Shake, Wobble, Sway, etc.) + +### 6.4 Card Hover Animations + Category Pills (`media-pane.slint`) +- `sweep` animation (wipe overlay) on transition cards on hover +- `shimmer` gradient overlay on effect cards on hover +- `CategoryPillBar` component added for the effects shelf +- Preview artwork mapped for all new packages via `art-of(id)` + +### 6.5 Effects/Transitions Click Bug Fix (`media-pane.slint`) +- **Bug**: `auditions: true` was set on the Effects and Transitions shelves, causing single-click to invoke `audition_catalogue()` — which renders an effect overlay across the entire monitor frame. This looked like "the effect applied to the imported file itself." +- **Fix**: Set `auditions: false` on both the Effects shelf (tab 3) and Transitions shelf (tab 2). Single-click now applies directly to the selected clip/cut. Only the Filters shelf (tab 4) retains `auditions: true` (correct for color grade preview). + +--- + +## 7. Known Open Issues / Next Steps + +### 🔴 Critical / High Priority + +| # | Issue | Location | +|---|---|---| +| 1 | **Transition seam drag handles** don't yet write back to the project (UI draws them but the resize gesture isn't wired to `Studio::resize_transition()`) | `lanes.slint` drag callbacks → `studio.rs` | +| 2 | **No live preview for transitions on the monitor** — applying a transition to a cut should show an animated preview in the monitor at the playhead; currently the monitor only updates on playback | `studio.rs` request_preview / transition preview | +| 3 | **Export: `xfade` filtergraph not wired** — packaged transitions export as a simple dissolve fallback; a two-stream `xfade` FFmpeg export path was never built | `concat-export/src/lib.rs` | + +### 🟡 Medium Priority + +| # | Issue | Location | +|---|---|---| +| 4 | **Transition seam tooltip shows raw seconds** — should format as `"0.5s"` not `"0.5"` | `lanes.slint` tooltip text | +| 5 | **Category pill bar doesn't scroll on very narrow media panel** — content clips instead of scrolling | `media-pane.slint` `CategoryPillBar` | +| 6 | **`concat.twirl` and `concat.kaleidoscope` FFmpeg paths are simplified** — the GPU shaders are accurate but the FFmpeg fallback is a mirror approximation (documented in previous `HANDOFF.md`) | `packages/concat.twirl/`, `concat.kaleidoscope/` | +| 7 | **Audio effects batch** — 5 planned audio packages (Vinyl, Underwater, Megaphone, Stadium, Whisper) exist as stubs or are missing `fixtures.toml` | `packages/concat.vinyl/` etc. | + +### 🟢 Nice-to-Have / Roadmap + +| # | Feature | Notes | +|---|---|---| +| 8 | **Keyframe Graph Editor** — Fine-grained curve editing for animation params | `concat-project/src/animation.rs` + new UI | +| 9 | **Generators** (`Kind::Generator`) — Gradients, noise, solid backgrounds | Reserved in `manifest.rs`, no packages yet | +| 10 | **Effect Mixer / Transition Mixer** — Combine nodes into custom effects | Major new system; `concat-render` + new UI | +| 11 | **Concat Hub / Registry** — Community package registry | Infrastructure + distribution | +| 12 | **Object & Face Tracking** — Attach effects/text to tracked targets | `concat-vision` | + +--- + +## 8. Verification Commands (Run After Any Change) + +```bash +cd src + +# All tests including GPU shader pipeline +cargo test -p concat-effects -p concat-export -p concat-render -p concat-project --features gpu + +# Full app type-check (catches Slint UI macro errors too) +cargo check -p concat + +# Lint +cargo clippy -p concat -p concat-export -p concat-effects -p concat-render -p concat-project \ + --features concat-render/gpu -- -D warnings + +# Run the app +cargo run -p concat +# or faster: +cargo run --profile quick -p concat +``` + +Test count as of this handoff: **128 passing** (`concat-effects`, `concat-export`, `concat-project`). The GPU render tests (`concat-render --features gpu`) add more on top of that. + +--- + +## 9. Git State + +Everything described in §6 is **uncommitted**, sitting as working tree changes on `main`. Nothing has been pushed. + +```bash +git status # See all modified and untracked files +git diff # See all line-level changes +``` + +Suggested commit strategy: +1. **Engine fix** (`concat-export` transition clamp + tests) — atomic commit +2. **UI redesign** (seam handles + animations) — atomic commit +3. **Effects library expansion** (19 effects + 4 transitions + category pills) — could be one or split by category +4. **Click bug fix** (`auditions: false` on effects/transitions shelf) — atomic commit, clearly described + +--- + +## 10. Critical Gotchas Summary + +1. **Cargo workspace is `src/`, not repo root** — `cd src` before every `cargo` command +2. **`auditions: true` on effects/transitions shelves = global monitor overlay bug** — only Filters should use auditions +3. **Transitions have no FFmpeg path** — `Kind::Transition` is GPU-only by design; `manifest.rs` validation enforces this +4. **Fixture strings are exact text diffs** — even a space change in an FFmpeg chain breaks the test; regenerate with `--nocapture` +5. **Don't use trig in `{...}` template expressions** — use FFmpeg's `geq` and always test the chain against a real `ffmpeg` binary +6. **`concat.light-leak` (effect) ≠ `concat.light-leak-transition` (transition)** — catalogue ID namespace is flat; duplicates are rejected at build time +7. **GPU regression tests are the only thing catching bind-group-layout bugs** — run `--features gpu` after any shader change +8. **Handle constraint in transitions is intentional** — you can't add a 1s transition to a clip that has 0 handles; the UX should guide the user, not silently clamp to 0 diff --git a/src/crates/concat-core/src/lib.rs b/src/crates/concat-core/src/lib.rs index 864d3435..9f3509e9 100644 --- a/src/crates/concat-core/src/lib.rs +++ b/src/crates/concat-core/src/lib.rs @@ -26,6 +26,6 @@ pub mod timeline; pub use frame::Frame; pub use retime::SpeedCurve; -pub use shader::{Lut, ShaderPass}; +pub use shader::{Lut, RevealMap, ShaderPass, TransitionPass}; pub use time::{FrameRate, Rational, TimeRange}; pub use timeline::{Blend, Clip, ClipId, Project, Timeline, Track, TrackId, TrackKind}; diff --git a/src/crates/concat-core/src/shader.rs b/src/crates/concat-core/src/shader.rs index 1cd08c11..adf4337c 100644 --- a/src/crates/concat-core/src/shader.rs +++ b/src/crates/concat-core/src/shader.rs @@ -50,6 +50,11 @@ pub struct ShaderPass { /// The package's look-up table, bound as a 3D texture the shader's /// `lut()` samples; None binds the identity so the call is harmless. pub lut: Option>, + /// A title's per-word reveal order, bound as a 2D texture the shader's + /// `reveal_order()` samples; None binds a map that reveals everything, + /// so the call is harmless for a pass over anything that is not a + /// title. See [`RevealMap`]. + pub reveal_map: Option>, } impl ShaderPass { @@ -58,6 +63,35 @@ impl ShaderPass { pub const MIN_PARAMS: usize = 16; } +/// One two-input transition combine, as the compositor runs it. +/// +/// Where a [`ShaderPass`] changes one layer, a transition reads two - the +/// outgoing picture and the incoming one - and a `progress` from 0 to 1, and +/// writes the single picture between them. It is resolved from a transition +/// package and the cut's timing by the effect catalogue and carried to the +/// renderer as data, exactly like a pass: the renderer compiles and caches +/// the pipeline by `key` and pours `params` into the shader's uniform buffer +/// as the catalogue laid them out. +#[derive(Clone, Debug, PartialEq)] +pub struct TransitionPass { + /// What to cache the compiled pipeline under: the package's id and + /// version, the same rule a [`ShaderPass`] follows. + pub key: String, + /// The complete WGSL module: the host's transition prelude with the + /// package's body, declaring + /// `fn transition(uv: vec2, progress: f32) -> vec4`. + pub source: Arc, + /// The `Params` uniform, laid out to the struct's offsets. Sixteen bytes + /// at least, so an empty struct still has a buffer. + pub params: Vec, + /// How far through the cut this frame is, `0..=1`: 0 is all the outgoing + /// picture, 1 all the incoming one. + pub progress: f32, + /// The package's look-up table, bound like a pass's so the shared + /// grading helpers work; None binds the identity. + pub lut: Option>, +} + /// A 3D look-up table: `size` texels a side, RGBA8, red fastest, then /// green, then blue - the order a `.cube` file lists its rows in and the /// layout a `texture_3d` is uploaded from. A colour is looked up by its @@ -146,6 +180,66 @@ impl Lut { } } +/// A title's words, baked into one grayscale map the size of its canvas: +/// each pixel holds the normalized order (0 first, 1 last) of the word +/// painted there, 0 wherever no word was - background is moot since it is +/// transparent regardless, so it is left "already revealed" rather than +/// given a value that would mean something if it were ever visible. +/// +/// This is how a per-word reveal effect works without the renderer or the +/// shader knowing what a "word" is: the layout that already happens to +/// paint a title is baked once into a texture, exactly as a colour look-up +/// table bakes a grade, and a shader reads it back with one comparison +/// against `progress`. +#[derive(Clone, Debug, PartialEq)] +pub struct RevealMap { + /// A hash of the contents, so a renderer can cache the upload. + pub id: u64, + /// The canvas width this map was baked at. + pub width: u32, + /// The canvas height this map was baked at. + pub height: u32, + /// `width * height` bytes, one per pixel, red channel only. + pub gray: Arc<[u8]>, +} + +impl RevealMap { + /// A map `width` by `height`, `rects` each `(x, y, width, height)` in + /// canvas pixels and in the order the words should reveal - reading + /// order is what a caller normally means. A pixel outside every rect + /// stays revealed from the start; one inside more than one keeps the + /// later word's order, though words in practice never overlap. + pub fn from_rects(width: u32, height: u32, rects: &[(i32, i32, u32, u32)]) -> RevealMap { + let mut gray = vec![0u8; width as usize * height as usize]; + let last = rects.len().saturating_sub(1).max(1) as f32; + for (index, &(x, y, w, h)) in rects.iter().enumerate() { + let value = ((index as f32 / last) * 255.0).round() as u8; + let x0 = x.clamp(0, width as i32) as u32; + let y0 = y.clamp(0, height as i32) as u32; + let x1 = (x + w as i32).clamp(0, width as i32) as u32; + let y1 = (y + h as i32).clamp(0, height as i32) as u32; + for row in y0..y1 { + let start = row as usize * width as usize + x0 as usize; + gray[start..start + (x1 - x0) as usize].fill(value); + } + } + let id = fnv64(&gray) ^ u64::from(width) ^ (u64::from(height) << 32); + RevealMap { + id, + width, + height, + gray: gray.into(), + } + } + + /// The map that reveals everything from the first frame: what a pass + /// without one binds, and a two-by-two texture, so the identity is + /// almost free to keep resident. + pub fn identity() -> RevealMap { + RevealMap::from_rects(2, 2, &[]) + } +} + /// FNV-1a, so a table's id needs no dependency and is the same on every /// machine. fn fnv64(bytes: &[u8]) -> u64 { @@ -182,4 +276,26 @@ mod tests { assert!(Lut::from_rgb(3, &[0.0; 26 * 3]).is_none()); assert!(Lut::from_rgb(1, &[0.0; 3]).is_none()); } + + #[test] + fn the_identity_reveal_map_reveals_everywhere() { + let map = RevealMap::identity(); + assert!(map.gray.iter().all(|&g| g == 0)); + } + + #[test] + fn word_rects_are_ordered_zero_to_full_and_outside_stays_revealed() { + let map = RevealMap::from_rects(10, 10, &[(0, 0, 2, 2), (5, 0, 2, 2), (0, 5, 2, 2)]); + assert_eq!(map.gray[0], 0); + assert_eq!(map.gray[5], 128); + assert_eq!(map.gray[5 * 10], 255); + // A pixel no rect covers is left at the "already revealed" value. + assert_eq!(map.gray[9 * 10 + 9], 0); + } + + #[test] + fn a_single_word_reveals_at_the_very_start() { + let map = RevealMap::from_rects(4, 4, &[(0, 0, 2, 2)]); + assert_eq!(map.gray[0], 0); + } } diff --git a/src/crates/concat-effects/examples/previews.rs b/src/crates/concat-effects/examples/previews.rs index 93b89809..2660b834 100644 --- a/src/crates/concat-effects/examples/previews.rs +++ b/src/crates/concat-effects/examples/previews.rs @@ -9,14 +9,34 @@ //! cargo run -p concat-effects --example previews -- [id...] //! ``` //! -//! With no ids, every filter package is rendered; the file is named after -//! the id without its namespace, `concat.golden-hour` as `golden-hour.jpg`. +//! With no ids, every filter, effect, and transition package is rendered; +//! the file is named after the id without its namespace, `concat.golden-hour` as `golden-hour.jpg`. //! Needs `ffmpeg` on the path. use std::path::PathBuf; use std::process::Command; -use concat_effects::{At, Catalogue, Kind}; +use concat_effects::{At, Catalogue, Kind, Package}; + +fn transition_xfade(package: &Package) -> &str { + if let Some(xfade) = package.transition_xfade() { + return xfade; + } + let id = package.id(); + let name = id.rsplit('.').next().unwrap_or(id); + match name { + "cube" => "slideleft", + "linear-wipe" => "wipeleft", + "rgb-split-wipe" => "wipeleft", + "shape-wipe" => "circleopen", + "slide" => "slideleft", + "push" => "slideleft", + "spin" => "radial", + "swirl" => "radial", + "glitch-transition" => "pixelize", + _ => "fade", + } +} fn main() { let mut args = std::env::args().skip(1); @@ -29,43 +49,77 @@ fn main() { std::fs::create_dir_all(&out).expect("out dir"); let mut failed = 0; - for package in Catalogue::builtin().of_kind(Kind::Filter) { + let kinds = [Kind::Filter, Kind::Effect, Kind::Transition]; + for package in kinds.into_iter().flat_map(|k| Catalogue::builtin().of_kind(k)) { let id = package.id(); if !only.is_empty() && !only.iter().any(|want| want == id) { continue; } - let chain = match package.ffmpeg_fragment(&package.params_at(At::Default), 0) { - Ok(Some(chain)) => chain, - Ok(None) => { - eprintln!("{id}: no ffmpeg chain, skipped"); - continue; - } - Err(error) => { - eprintln!("{id}: {error}"); - failed += 1; - continue; - } - }; let name = id.rsplit('.').next().unwrap_or(id); let target = out.join(format!("{name}.jpg")); - let status = Command::new("ffmpeg") - .args([ - "-y", - "-loglevel", - "error", - "-i", - &still, - "-vf", - &chain, - "-q:v", - "3", - ]) - .arg(&target) - .status(); + + let status = if package.kind() == Kind::Transition { + let xfade = transition_xfade(package); + let filter = format!( + "[0:v]hue=h=180[v2];[0:v][v2]xfade=transition={xfade}:duration=1:offset=0[out]" + ); + Command::new("ffmpeg") + .args([ + "-y", + "-loglevel", + "error", + "-loop", + "1", + "-t", + "1", + "-i", + &still, + "-filter_complex", + &filter, + "-map", + "[out]", + "-ss", + "0.5", + "-vframes", + "1", + "-q:v", + "3", + ]) + .arg(&target) + .status() + } else { + let chain = match package.ffmpeg_fragment(&package.params_at(At::Default), 0) { + Ok(Some(chain)) => chain, + Ok(None) => { + eprintln!("{id}: no ffmpeg chain, skipped"); + continue; + } + Err(error) => { + eprintln!("{id}: {error}"); + failed += 1; + continue; + } + }; + Command::new("ffmpeg") + .args([ + "-y", + "-loglevel", + "error", + "-i", + &still, + "-vf", + &chain, + "-q:v", + "3", + ]) + .arg(&target) + .status() + }; + match status { Ok(status) if status.success() => println!("{id} -> {}", target.display()), Ok(status) => { - eprintln!("{id}: ffmpeg exited with {status}\n chain: {chain}"); + eprintln!("{id}: ffmpeg exited with {status}"); failed += 1; } Err(error) => { diff --git a/src/crates/concat-effects/packages/concat.autumn/effect.toml b/src/crates/concat-effects/packages/concat.autumn/effect.toml new file mode 100644 index 00000000..675df45f --- /dev/null +++ b/src/crates/concat-effects/packages/concat.autumn/effect.toml @@ -0,0 +1,13 @@ +[effect] +id = "concat.autumn" +name = "Autumn" +kind = "filter" +category = "Scenery" +order = 58 +description = "Greens pulled toward amber, the picture dipped in fall light." + +[ffmpeg] +chain = "colorbalance=rh=0.1:gh=0.04:bh=-0.08,eq=saturation=1.1" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.autumn/effect.wgsl b/src/crates/concat-effects/packages/concat.autumn/effect.wgsl new file mode 100644 index 00000000..59d0b133 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.autumn/effect.wgsl @@ -0,0 +1,5 @@ +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let t = split_tone(c.rgb, vec3(0.0), vec3(0.1, 0.04, -0.08), 1.0); + return vec4(clamp01(saturation(t, 1.1)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.autumn/fixtures.toml b/src/crates/concat-effects/packages/concat.autumn/fixtures.toml new file mode 100644 index 00000000..b0f45f33 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.autumn/fixtures.toml @@ -0,0 +1,3 @@ +[[case]] +name = "default" +chain = "colorbalance=rh=0.1:gh=0.04:bh=-0.08,eq=saturation=1.1" diff --git a/src/crates/concat-effects/packages/concat.bass-shockwave/effect.toml b/src/crates/concat-effects/packages/concat.bass-shockwave/effect.toml new file mode 100644 index 00000000..c4a0ecf6 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.bass-shockwave/effect.toml @@ -0,0 +1,35 @@ +[effect] +id = "concat.bass-shockwave" +name = "Bass Shockwave" +kind = "effect" +category = "Party & Club" +order = 14 +intensity = "strength" +description = "Radial ripple expansion pulsing with beat intensity." + +[[param]] +key = "strength" +label = "Strength" +min = 0 +max = 100 +default = 70 +step = 1 +unit = "%" + +[[param]] +key = "speed" +label = "Speed" +min = 1 +max = 10 +default = 3 +step = 1 +unit = "Hz" + +[ffmpeg] +let = [ + "b = fixed(strength / 100 * 0.3, 3)", +] +chain = "eq=brightness={b}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.bass-shockwave/effect.wgsl b/src/crates/concat-effects/packages/concat.bass-shockwave/effect.wgsl new file mode 100644 index 00000000..3de1b215 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.bass-shockwave/effect.wgsl @@ -0,0 +1,14 @@ +struct Params { strength: f32, speed: f32 } + +fn effect(uv: vec2) -> vec4 { + let center = vec2(0.5, 0.5); + let d = uv - center; + let dist = length(d); + let phase = fract(frame.time * params.speed); + let shock_ring = abs(dist - phase * 0.7); + let shock = smoothstep(0.08, 0.0, shock_ring) * (1.0 - phase) * (params.strength * 0.01); + let warp_uv = uv + normalize(d) * shock * 0.05; + let c = sample(warp_uv); + let highlight = c.rgb + vec3(shock * 0.3); + return vec4(clamp01(highlight), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.bass-shockwave/ffmpeg.txt b/src/crates/concat-effects/packages/concat.bass-shockwave/ffmpeg.txt new file mode 100644 index 00000000..77ed5e46 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.bass-shockwave/ffmpeg.txt @@ -0,0 +1 @@ +eq=brightness={b} diff --git a/src/crates/concat-effects/packages/concat.bass-shockwave/fixtures.toml b/src/crates/concat-effects/packages/concat.bass-shockwave/fixtures.toml new file mode 100644 index 00000000..9e8a90af --- /dev/null +++ b/src/crates/concat-effects/packages/concat.bass-shockwave/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "eq=brightness=0.210" + +[[case]] +name = "min" +at = "min" +chain = "eq=brightness=0.000" + +[[case]] +name = "max" +at = "max" +chain = "eq=brightness=0.300" diff --git a/src/crates/concat-effects/packages/concat.bass-shockwave/main.wgsl b/src/crates/concat-effects/packages/concat.bass-shockwave/main.wgsl new file mode 100644 index 00000000..3de1b215 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.bass-shockwave/main.wgsl @@ -0,0 +1,14 @@ +struct Params { strength: f32, speed: f32 } + +fn effect(uv: vec2) -> vec4 { + let center = vec2(0.5, 0.5); + let d = uv - center; + let dist = length(d); + let phase = fract(frame.time * params.speed); + let shock_ring = abs(dist - phase * 0.7); + let shock = smoothstep(0.08, 0.0, shock_ring) * (1.0 - phase) * (params.strength * 0.01); + let warp_uv = uv + normalize(d) * shock * 0.05; + let c = sample(warp_uv); + let highlight = c.rgb + vec3(shock * 0.3); + return vec4(clamp01(highlight), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.bleach-bypass/effect.toml b/src/crates/concat-effects/packages/concat.bleach-bypass/effect.toml new file mode 100644 index 00000000..ab8f03a1 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.bleach-bypass/effect.toml @@ -0,0 +1,13 @@ +[effect] +id = "concat.bleach-bypass" +name = "Bleach Bypass" +kind = "filter" +category = "Cinematic" +order = 51 +description = "The silver-retention film process: colour washed out, contrast pushed hard." + +[ffmpeg] +chain = "eq=saturation=0.35:contrast=1.3:gamma=0.9" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.bleach-bypass/effect.wgsl b/src/crates/concat-effects/packages/concat.bleach-bypass/effect.wgsl new file mode 100644 index 00000000..34b8a5ff --- /dev/null +++ b/src/crates/concat-effects/packages/concat.bleach-bypass/effect.wgsl @@ -0,0 +1,6 @@ +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let s = saturation(c.rgb, 0.35); + let f = film_curve(s, 0.15, 0.25); + return vec4(clamp01(contrast(f, 1.25)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.bleach-bypass/fixtures.toml b/src/crates/concat-effects/packages/concat.bleach-bypass/fixtures.toml new file mode 100644 index 00000000..e5a80e7f --- /dev/null +++ b/src/crates/concat-effects/packages/concat.bleach-bypass/fixtures.toml @@ -0,0 +1,3 @@ +[[case]] +name = "default" +chain = "eq=saturation=0.35:contrast=1.3:gamma=0.9" diff --git a/src/crates/concat-effects/packages/concat.bloom-pulse/effect.toml b/src/crates/concat-effects/packages/concat.bloom-pulse/effect.toml new file mode 100644 index 00000000..a5382a2d --- /dev/null +++ b/src/crates/concat-effects/packages/concat.bloom-pulse/effect.toml @@ -0,0 +1,31 @@ +[effect] +id = "concat.bloom-pulse" +name = "Bloom Pulse" +kind = "effect" +category = "Light" +order = 72 +intensity = "amount" +description = "A soft bloom over the highlights that breathes in and out." + +[[param]] +key = "amount" +label = "Amount" +min = 0 +max = 100 +default = 50 +step = 1 +unit = "%" + +[[param]] +key = "speed" +label = "Speed" +min = 0.5 +max = 5 +default = 2 +step = 0.1 + +[ffmpeg] +chain = "split[blma{index}][blmb{index}];[blmb{index}]gblur=sigma=14[blmg{index}];[blma{index}][blmg{index}]blend=all_mode=screen:all_opacity={fixed(amount / 100, 3)}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.bloom-pulse/effect.wgsl b/src/crates/concat-effects/packages/concat.bloom-pulse/effect.wgsl new file mode 100644 index 00000000..fb9e9d95 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.bloom-pulse/effect.wgsl @@ -0,0 +1,8 @@ +struct Params { amount: f32, speed: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let pulse = 0.6 + 0.4 * sin(frame.time * params.speed); + let glow = halation(uv, c.rgb, 0.55, 12.0, vec3(1.0), params.amount * 0.01 * pulse); + return vec4(glow, c.a); +} diff --git a/src/crates/concat-effects/packages/concat.bloom-pulse/fixtures.toml b/src/crates/concat-effects/packages/concat.bloom-pulse/fixtures.toml new file mode 100644 index 00000000..b72ff153 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.bloom-pulse/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "split[blma0][blmb0];[blmb0]gblur=sigma=14[blmg0];[blma0][blmg0]blend=all_mode=screen:all_opacity=0.500" + +[[case]] +name = "min" +at = "min" +chain = "split[blma0][blmb0];[blmb0]gblur=sigma=14[blmg0];[blma0][blmg0]blend=all_mode=screen:all_opacity=0.000" + +[[case]] +name = "max" +at = "max" +chain = "split[blma0][blmb0];[blmb0]gblur=sigma=14[blmg0];[blma0][blmg0]blend=all_mode=screen:all_opacity=1.000" diff --git a/src/crates/concat-effects/packages/concat.blue-screen/effect.toml b/src/crates/concat-effects/packages/concat.blue-screen/effect.toml index 22860e45..de36836d 100644 --- a/src/crates/concat-effects/packages/concat.blue-screen/effect.toml +++ b/src/crates/concat-effects/packages/concat.blue-screen/effect.toml @@ -26,7 +26,7 @@ step = 1 unit = "%" [ffmpeg] -chain = "chromakey=color=0x0000FFty={fixed(similarity / 100, 3)}:blend={fixed(softness / 100, 3)}" +chain = "chromakey=color=0x0000FF:similarity={fixed(similarity / 100, 3)}:blend={fixed(softness / 100, 3)}" [wgsl] entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.blue-screen/fixtures.toml b/src/crates/concat-effects/packages/concat.blue-screen/fixtures.toml index 753a43e1..419c0747 100644 --- a/src/crates/concat-effects/packages/concat.blue-screen/fixtures.toml +++ b/src/crates/concat-effects/packages/concat.blue-screen/fixtures.toml @@ -1,13 +1,13 @@ [[case]] name = "default" -chain = "chromakey=color=0x0000FFty=0.250:blend=0.100" +chain = "chromakey=color=0x0000FF:similarity=0.250:blend=0.100" [[case]] name = "minimum" at = "min" -chain = "chromakey=color=0x0000FFty=0.010:blend=0.000" +chain = "chromakey=color=0x0000FF:similarity=0.010:blend=0.000" [[case]] name = "maximum" at = "max" -chain = "chromakey=color=0x0000FFty=0.600:blend=0.500" +chain = "chromakey=color=0x0000FF:similarity=0.600:blend=0.500" diff --git a/src/crates/concat-effects/packages/concat.blur-dissolve/effect.toml b/src/crates/concat-effects/packages/concat.blur-dissolve/effect.toml new file mode 100644 index 00000000..7b1f2b28 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.blur-dissolve/effect.toml @@ -0,0 +1,21 @@ +[effect] +id = "concat.blur-dissolve" +name = "Blur Dissolve" +kind = "transition" +category = "Dissolve" +order = 1 +description = "A cross-dissolve that blurs into the cut and sharpens back out of it." + +[[param]] +key = "strength" +label = "Strength" +min = 0 +max = 100 +default = 60 +step = 1 +unit = "%" + +[transition] +entry = "effect.wgsl" +xfade = "dissolve" +fallback = "cross-fade" diff --git a/src/crates/concat-effects/packages/concat.blur-dissolve/effect.wgsl b/src/crates/concat-effects/packages/concat.blur-dissolve/effect.wgsl new file mode 100644 index 00000000..e9f561b8 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.blur-dissolve/effect.wgsl @@ -0,0 +1,30 @@ +struct Params { strength: f32 } + +fn soft_from(uv: vec2, radius: f32) -> vec4 { + var sum = vec4(0.0); + let t = texel() * radius; + for (var y: i32 = -1; y <= 1; y++) { + for (var x: i32 = -1; x <= 1; x++) { + sum += from_at(uv + vec2(f32(x), f32(y)) * t); + } + } + return sum / 9.0; +} + +fn soft_to(uv: vec2, radius: f32) -> vec4 { + var sum = vec4(0.0); + let t = texel() * radius; + for (var y: i32 = -1; y <= 1; y++) { + for (var x: i32 = -1; x <= 1; x++) { + sum += to_at(uv + vec2(f32(x), f32(y)) * t); + } + } + return sum / 9.0; +} + +// Blur peaks at the middle of the cut and resolves to sharp at both ends. +fn transition(uv: vec2, progress: f32) -> vec4 { + let k = 1.0 - abs(progress * 2.0 - 1.0); + let radius = k * params.strength * 0.3; + return mix(soft_from(uv, radius), soft_to(uv, radius), progress); +} diff --git a/src/crates/concat-effects/packages/concat.bokeh/effect.toml b/src/crates/concat-effects/packages/concat.bokeh/effect.toml new file mode 100644 index 00000000..adf94afa --- /dev/null +++ b/src/crates/concat-effects/packages/concat.bokeh/effect.toml @@ -0,0 +1,23 @@ +[effect] +id = "concat.bokeh" +name = "Bokeh" +kind = "effect" +category = "Light" +order = 73 +intensity = "amount" +description = "Soft out-of-focus circles of light drift over the picture." + +[[param]] +key = "amount" +label = "Amount" +min = 0 +max = 100 +default = 60 +step = 1 +unit = "%" + +[ffmpeg] +chain = "split[bka{index}][bkb{index}];[bkb{index}]gblur=sigma=6[bkg{index}];[bka{index}][bkg{index}]blend=all_mode=screen:all_opacity={fixed(amount / 200, 3)}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.bokeh/effect.wgsl b/src/crates/concat-effects/packages/concat.bokeh/effect.wgsl new file mode 100644 index 00000000..833e0fb8 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.bokeh/effect.wgsl @@ -0,0 +1,24 @@ +struct Params { amount: f32 } + +// Each circle is a bright core inside a soft halo rather than one +// smoothstep ring, drifts very slightly rather than sitting dead still, and +// leans warm or cool at random - the creamy, faintly alive look a real +// out-of-focus highlight has, not a flat white disc. +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + var glow = vec3(0.0); + for (var i: i32 = 0; i < 12; i++) { + let seed = f32(i) * 9.0; + let depth = hash(vec2(seed, 6.0), 0.0); + let drift = vec2(sin(frame.time * 0.1 + seed), cos(frame.time * 0.08 + seed)) * 0.01; + let p = vec2(hash(vec2(seed, 1.0), 0.0), hash(vec2(seed, 2.0), 0.0)) + drift; + let r = mix(0.025, 0.075, depth); + let d = distance(uv, p); + let core = exp(-(d * d) / (r * r * 0.08)); + let halo = exp(-(d * d) / (r * r)); + let warm = hash(vec2(seed, 7.0), 0.0) > 0.5; + let tint = select(vec3(0.55, 0.75, 1.0), vec3(1.0, 0.82, 0.55), warm); + glow += tint * (core * 0.9 + halo * 0.35) * mix(0.3, 1.0, depth); + } + return vec4(clamp01(c.rgb + glow * params.amount * 0.01), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.bokeh/fixtures.toml b/src/crates/concat-effects/packages/concat.bokeh/fixtures.toml new file mode 100644 index 00000000..39649daf --- /dev/null +++ b/src/crates/concat-effects/packages/concat.bokeh/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "split[bka0][bkb0];[bkb0]gblur=sigma=6[bkg0];[bka0][bkg0]blend=all_mode=screen:all_opacity=0.300" + +[[case]] +name = "min" +at = "min" +chain = "split[bka0][bkb0];[bkb0]gblur=sigma=6[bkg0];[bka0][bkg0]blend=all_mode=screen:all_opacity=0.000" + +[[case]] +name = "max" +at = "max" +chain = "split[bka0][bkb0];[bkb0]gblur=sigma=6[bkg0];[bka0][bkg0]blend=all_mode=screen:all_opacity=0.500" diff --git a/src/crates/concat-effects/packages/concat.bulge-pinch/effect.toml b/src/crates/concat-effects/packages/concat.bulge-pinch/effect.toml new file mode 100644 index 00000000..42e56a15 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.bulge-pinch/effect.toml @@ -0,0 +1,26 @@ +[effect] +id = "concat.bulge-pinch" +name = "Bulge / Pinch" +kind = "effect" +category = "Distort" +order = 79 +intensity = "amount" +description = "The centre of the picture bulges outward, or pinches inward below zero." + +[[param]] +key = "amount" +label = "Amount" +min = -100 +max = 100 +default = 50 +step = 1 +unit = "%" + +[ffmpeg] +let = [ + "k = fixed(amount / 100 * 0.4, 3)", +] +chain = "geq=r='r(W/2+(X-W/2)*(1+{k}*(1-min(hypot(X-W/2,Y-H/2)/(W/2),1))),H/2+(Y-H/2)*(1+{k}*(1-min(hypot(X-W/2,Y-H/2)/(W/2),1))))':g='g(W/2+(X-W/2)*(1+{k}*(1-min(hypot(X-W/2,Y-H/2)/(W/2),1))),H/2+(Y-H/2)*(1+{k}*(1-min(hypot(X-W/2,Y-H/2)/(W/2),1))))':b='b(W/2+(X-W/2)*(1+{k}*(1-min(hypot(X-W/2,Y-H/2)/(W/2),1))),H/2+(Y-H/2)*(1+{k}*(1-min(hypot(X-W/2,Y-H/2)/(W/2),1))))'" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.bulge-pinch/effect.wgsl b/src/crates/concat-effects/packages/concat.bulge-pinch/effect.wgsl new file mode 100644 index 00000000..e1d31b13 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.bulge-pinch/effect.wgsl @@ -0,0 +1,9 @@ +struct Params { amount: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = uv - vec2(0.5); + let d = length(c) / 0.7071068; + let k = params.amount * 0.01 * 0.5; + let scale = 1.0 + k * (1.0 - min(d, 1.0)); + return sample(c * scale + vec2(0.5)); +} diff --git a/src/crates/concat-effects/packages/concat.bulge-pinch/fixtures.toml b/src/crates/concat-effects/packages/concat.bulge-pinch/fixtures.toml new file mode 100644 index 00000000..9834a424 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.bulge-pinch/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "geq=r='r(W/2+(X-W/2)*(1+0.200*(1-min(hypot(X-W/2,Y-H/2)/(W/2),1))),H/2+(Y-H/2)*(1+0.200*(1-min(hypot(X-W/2,Y-H/2)/(W/2),1))))':g='g(W/2+(X-W/2)*(1+0.200*(1-min(hypot(X-W/2,Y-H/2)/(W/2),1))),H/2+(Y-H/2)*(1+0.200*(1-min(hypot(X-W/2,Y-H/2)/(W/2),1))))':b='b(W/2+(X-W/2)*(1+0.200*(1-min(hypot(X-W/2,Y-H/2)/(W/2),1))),H/2+(Y-H/2)*(1+0.200*(1-min(hypot(X-W/2,Y-H/2)/(W/2),1))))'" + +[[case]] +name = "min" +at = "min" +chain = "geq=r='r(W/2+(X-W/2)*(1+-0.400*(1-min(hypot(X-W/2,Y-H/2)/(W/2),1))),H/2+(Y-H/2)*(1+-0.400*(1-min(hypot(X-W/2,Y-H/2)/(W/2),1))))':g='g(W/2+(X-W/2)*(1+-0.400*(1-min(hypot(X-W/2,Y-H/2)/(W/2),1))),H/2+(Y-H/2)*(1+-0.400*(1-min(hypot(X-W/2,Y-H/2)/(W/2),1))))':b='b(W/2+(X-W/2)*(1+-0.400*(1-min(hypot(X-W/2,Y-H/2)/(W/2),1))),H/2+(Y-H/2)*(1+-0.400*(1-min(hypot(X-W/2,Y-H/2)/(W/2),1))))'" + +[[case]] +name = "max" +at = "max" +chain = "geq=r='r(W/2+(X-W/2)*(1+0.400*(1-min(hypot(X-W/2,Y-H/2)/(W/2),1))),H/2+(Y-H/2)*(1+0.400*(1-min(hypot(X-W/2,Y-H/2)/(W/2),1))))':g='g(W/2+(X-W/2)*(1+0.400*(1-min(hypot(X-W/2,Y-H/2)/(W/2),1))),H/2+(Y-H/2)*(1+0.400*(1-min(hypot(X-W/2,Y-H/2)/(W/2),1))))':b='b(W/2+(X-W/2)*(1+0.400*(1-min(hypot(X-W/2,Y-H/2)/(W/2),1))),H/2+(Y-H/2)*(1+0.400*(1-min(hypot(X-W/2,Y-H/2)/(W/2),1))))'" diff --git a/src/crates/concat-effects/packages/concat.camcorder-90s/effect.toml b/src/crates/concat-effects/packages/concat.camcorder-90s/effect.toml new file mode 100644 index 00000000..40e88a9b --- /dev/null +++ b/src/crates/concat-effects/packages/concat.camcorder-90s/effect.toml @@ -0,0 +1,35 @@ +[effect] +id = "concat.camcorder-90s" +name = "90s Camcorder" +kind = "effect" +category = "Retro & Film" +order = 8 +intensity = "noise" +description = "Vintage VHS/camcorder on-screen display with tracking noise." + +[[param]] +key = "noise" +label = "Noise" +min = 0 +max = 100 +default = 50 +step = 1 +unit = "%" + +[[param]] +key = "lines" +label = "Lines" +min = 0 +max = 100 +default = 40 +step = 1 +unit = "%" + +[ffmpeg] +let = [ + "n = fixed(noise * 0.25, 1)", +] +chain = "noise=alls={n}:allf=t" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.camcorder-90s/effect.wgsl b/src/crates/concat-effects/packages/concat.camcorder-90s/effect.wgsl new file mode 100644 index 00000000..b13cf0eb --- /dev/null +++ b/src/crates/concat-effects/packages/concat.camcorder-90s/effect.wgsl @@ -0,0 +1,101 @@ +struct Params { noise: f32, lines: f32 } + +fn draw_rec(uv: vec2) -> f32 { + let p = (uv - vec2(0.09, 0.068)) / vec2(0.004, 0.005); + let ix = floor(p.x); + let iy = floor(p.y); + if (iy < 0.0 || iy > 4.0) { return 0.0; } + if (ix >= 0.0 && ix <= 2.0) { + if (ix == 0.0 || iy == 0.0 || iy == 2.0 || (ix == 2.0 && (iy == 1.0 || iy >= 3.0))) { + return 1.0; + } + } + if (ix >= 4.0 && ix <= 6.0) { + let x = ix - 4.0; + if (x == 0.0 || iy == 0.0 || iy == 2.0 || iy == 4.0) { + return 1.0; + } + } + if (ix >= 8.0 && ix <= 10.0) { + let x = ix - 8.0; + if (x == 0.0 || iy == 0.0 || iy == 4.0) { + return 1.0; + } + } + return 0.0; +} + +fn draw_battery(uv: vec2) -> f32 { + if (uv.x >= 0.88 && uv.x <= 0.94 && uv.y >= 0.068 && uv.y <= 0.092) { + if (uv.x < 0.883 || uv.x > 0.937 || uv.y < 0.071 || uv.y > 0.089) { + return 1.0; + } + if (uv.x >= 0.888 && uv.x <= 0.908 && uv.y >= 0.074 && uv.y <= 0.086) { return 1.0; } + if (uv.x >= 0.914 && uv.x <= 0.932 && uv.y >= 0.074 && uv.y <= 0.086) { return 1.0; } + } + if (uv.x > 0.94 && uv.x <= 0.945 && uv.y >= 0.075 && uv.y <= 0.085) { + return 1.0; + } + return 0.0; +} + +fn draw_digit(p: vec2, d: i32) -> f32 { + let ix = floor(p.x); + let iy = floor(p.y); + if (ix < 0.0 || ix > 2.0 || iy < 0.0 || iy > 4.0) { return 0.0; } + if (d == 0) { return f32(ix == 0.0 || ix == 2.0 || iy == 0.0 || iy == 4.0); } + if (d == 1) { return f32(ix == 2.0); } + if (d == 2) { return f32(iy == 0.0 || iy == 2.0 || iy == 4.0 || (ix == 2.0 && iy <= 2.0) || (ix == 0.0 && iy >= 2.0)); } + if (d == 3) { return f32(iy == 0.0 || iy == 2.0 || iy == 4.0 || ix == 2.0); } + if (d == 4) { return f32(ix == 2.0 || iy == 2.0 || (ix == 0.0 && iy <= 2.0)); } + if (d == 5) { return f32(iy == 0.0 || iy == 2.0 || iy == 4.0 || (ix == 0.0 && iy <= 2.0) || (ix == 2.0 && iy >= 2.0)); } + if (d == 6) { return f32(ix == 0.0 || iy == 0.0 || iy == 2.0 || iy == 4.0 || (ix == 2.0 && iy >= 2.0)); } + if (d == 7) { return f32(iy == 0.0 || ix == 2.0); } + if (d == 8) { return f32(ix == 0.0 || ix == 2.0 || iy == 0.0 || iy == 2.0 || iy == 4.0); } + if (d == 9) { return f32(ix == 2.0 || iy == 0.0 || iy == 2.0 || iy == 4.0 || (ix == 0.0 && iy <= 2.0)); } + return 0.0; +} + +fn draw_date(uv: vec2) -> f32 { + let p = (uv - vec2(0.06, 0.85)) / vec2(0.004, 0.005); + let char_idx = floor(p.x / 4.0); + let digit_p = vec2(fract(p.x / 4.0) * 4.0, p.y); + if (char_idx == 0.0) { return draw_digit(digit_p, 1); } + if (char_idx == 1.0) { return draw_digit(digit_p, 9); } + if (char_idx == 2.0) { return draw_digit(digit_p, 9); } + if (char_idx == 3.0) { return draw_digit(digit_p, 8); } + return 0.0; +} + +fn effect(uv: vec2) -> vec4 { + let is_bottom = step(0.92, uv.y); + let tracking_noise = (hash(vec2(uv.x, frame.time), 3.0) - 0.5) * is_bottom * (params.noise * 0.005); + let sample_uv = uv + vec2(tracking_noise, 0.0); + let c = sample(sample_uv); + let scanline = sin(uv.y * 300.0 + frame.time * 10.0) * 0.5 + 0.5; + let interlacing = 1.0 - (params.lines * 0.01) * (1.0 - scanline) * 0.2; + var tape_color = saturation(c.rgb, 0.85) * interlacing; + + let rec_center = vec2(0.065, 0.078); + let rec_dist = length(uv - rec_center); + let rec_blink = step(0.4, fract(frame.time)); + if (rec_dist < 0.01 && rec_blink > 0.5) { + tape_color = vec3(1.0, 0.15, 0.15); + } + let rec_text = draw_rec(uv); + if (rec_text > 0.5) { + tape_color = vec3(0.95, 0.95, 0.95); + } + + let bat = draw_battery(uv); + if (bat > 0.5) { + tape_color = vec3(0.95, 0.95, 0.95); + } + + let date_val = draw_date(uv); + if (date_val > 0.5) { + tape_color = vec3(0.95, 0.95, 0.95); + } + + return vec4(tape_color, c.a); +} diff --git a/src/crates/concat-effects/packages/concat.camcorder-90s/ffmpeg.txt b/src/crates/concat-effects/packages/concat.camcorder-90s/ffmpeg.txt new file mode 100644 index 00000000..b4d7d2f0 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.camcorder-90s/ffmpeg.txt @@ -0,0 +1 @@ +noise=alls={n}:allf=t diff --git a/src/crates/concat-effects/packages/concat.camcorder-90s/fixtures.toml b/src/crates/concat-effects/packages/concat.camcorder-90s/fixtures.toml new file mode 100644 index 00000000..bb32406e --- /dev/null +++ b/src/crates/concat-effects/packages/concat.camcorder-90s/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "noise=alls=12.5:allf=t" + +[[case]] +name = "min" +at = "min" +chain = "noise=alls=0.0:allf=t" + +[[case]] +name = "max" +at = "max" +chain = "noise=alls=25.0:allf=t" diff --git a/src/crates/concat-effects/packages/concat.camcorder-90s/main.wgsl b/src/crates/concat-effects/packages/concat.camcorder-90s/main.wgsl new file mode 100644 index 00000000..b13cf0eb --- /dev/null +++ b/src/crates/concat-effects/packages/concat.camcorder-90s/main.wgsl @@ -0,0 +1,101 @@ +struct Params { noise: f32, lines: f32 } + +fn draw_rec(uv: vec2) -> f32 { + let p = (uv - vec2(0.09, 0.068)) / vec2(0.004, 0.005); + let ix = floor(p.x); + let iy = floor(p.y); + if (iy < 0.0 || iy > 4.0) { return 0.0; } + if (ix >= 0.0 && ix <= 2.0) { + if (ix == 0.0 || iy == 0.0 || iy == 2.0 || (ix == 2.0 && (iy == 1.0 || iy >= 3.0))) { + return 1.0; + } + } + if (ix >= 4.0 && ix <= 6.0) { + let x = ix - 4.0; + if (x == 0.0 || iy == 0.0 || iy == 2.0 || iy == 4.0) { + return 1.0; + } + } + if (ix >= 8.0 && ix <= 10.0) { + let x = ix - 8.0; + if (x == 0.0 || iy == 0.0 || iy == 4.0) { + return 1.0; + } + } + return 0.0; +} + +fn draw_battery(uv: vec2) -> f32 { + if (uv.x >= 0.88 && uv.x <= 0.94 && uv.y >= 0.068 && uv.y <= 0.092) { + if (uv.x < 0.883 || uv.x > 0.937 || uv.y < 0.071 || uv.y > 0.089) { + return 1.0; + } + if (uv.x >= 0.888 && uv.x <= 0.908 && uv.y >= 0.074 && uv.y <= 0.086) { return 1.0; } + if (uv.x >= 0.914 && uv.x <= 0.932 && uv.y >= 0.074 && uv.y <= 0.086) { return 1.0; } + } + if (uv.x > 0.94 && uv.x <= 0.945 && uv.y >= 0.075 && uv.y <= 0.085) { + return 1.0; + } + return 0.0; +} + +fn draw_digit(p: vec2, d: i32) -> f32 { + let ix = floor(p.x); + let iy = floor(p.y); + if (ix < 0.0 || ix > 2.0 || iy < 0.0 || iy > 4.0) { return 0.0; } + if (d == 0) { return f32(ix == 0.0 || ix == 2.0 || iy == 0.0 || iy == 4.0); } + if (d == 1) { return f32(ix == 2.0); } + if (d == 2) { return f32(iy == 0.0 || iy == 2.0 || iy == 4.0 || (ix == 2.0 && iy <= 2.0) || (ix == 0.0 && iy >= 2.0)); } + if (d == 3) { return f32(iy == 0.0 || iy == 2.0 || iy == 4.0 || ix == 2.0); } + if (d == 4) { return f32(ix == 2.0 || iy == 2.0 || (ix == 0.0 && iy <= 2.0)); } + if (d == 5) { return f32(iy == 0.0 || iy == 2.0 || iy == 4.0 || (ix == 0.0 && iy <= 2.0) || (ix == 2.0 && iy >= 2.0)); } + if (d == 6) { return f32(ix == 0.0 || iy == 0.0 || iy == 2.0 || iy == 4.0 || (ix == 2.0 && iy >= 2.0)); } + if (d == 7) { return f32(iy == 0.0 || ix == 2.0); } + if (d == 8) { return f32(ix == 0.0 || ix == 2.0 || iy == 0.0 || iy == 2.0 || iy == 4.0); } + if (d == 9) { return f32(ix == 2.0 || iy == 0.0 || iy == 2.0 || iy == 4.0 || (ix == 0.0 && iy <= 2.0)); } + return 0.0; +} + +fn draw_date(uv: vec2) -> f32 { + let p = (uv - vec2(0.06, 0.85)) / vec2(0.004, 0.005); + let char_idx = floor(p.x / 4.0); + let digit_p = vec2(fract(p.x / 4.0) * 4.0, p.y); + if (char_idx == 0.0) { return draw_digit(digit_p, 1); } + if (char_idx == 1.0) { return draw_digit(digit_p, 9); } + if (char_idx == 2.0) { return draw_digit(digit_p, 9); } + if (char_idx == 3.0) { return draw_digit(digit_p, 8); } + return 0.0; +} + +fn effect(uv: vec2) -> vec4 { + let is_bottom = step(0.92, uv.y); + let tracking_noise = (hash(vec2(uv.x, frame.time), 3.0) - 0.5) * is_bottom * (params.noise * 0.005); + let sample_uv = uv + vec2(tracking_noise, 0.0); + let c = sample(sample_uv); + let scanline = sin(uv.y * 300.0 + frame.time * 10.0) * 0.5 + 0.5; + let interlacing = 1.0 - (params.lines * 0.01) * (1.0 - scanline) * 0.2; + var tape_color = saturation(c.rgb, 0.85) * interlacing; + + let rec_center = vec2(0.065, 0.078); + let rec_dist = length(uv - rec_center); + let rec_blink = step(0.4, fract(frame.time)); + if (rec_dist < 0.01 && rec_blink > 0.5) { + tape_color = vec3(1.0, 0.15, 0.15); + } + let rec_text = draw_rec(uv); + if (rec_text > 0.5) { + tape_color = vec3(0.95, 0.95, 0.95); + } + + let bat = draw_battery(uv); + if (bat > 0.5) { + tape_color = vec3(0.95, 0.95, 0.95); + } + + let date_val = draw_date(uv); + if (date_val > 0.5) { + tape_color = vec3(0.95, 0.95, 0.95); + } + + return vec4(tape_color, c.a); +} diff --git a/src/crates/concat-effects/packages/concat.camera-shake/effect.toml b/src/crates/concat-effects/packages/concat.camera-shake/effect.toml new file mode 100644 index 00000000..77cab58d --- /dev/null +++ b/src/crates/concat-effects/packages/concat.camera-shake/effect.toml @@ -0,0 +1,47 @@ +[effect] +id = "concat.camera-shake" +name = "Camera Shake" +kind = "effect" +category = "Featured" +order = 1 +intensity = "amplitude" +description = "Handheld 2D harmonic camera jitter with organic drift." + +[[param]] +key = "amplitude" +label = "Amplitude" +type = "int" +min = 1 +max = 50 +default = 15 +step = 1 +unit = "px" + +[[param]] +key = "speed" +label = "Speed" +min = 2 +max = 30 +default = 10 +step = 1 +unit = "Hz" + +[[param]] +key = "rotation" +label = "Rotation" +min = 0 +max = 10 +default = 2 +step = 1 +unit = "°" + +[ffmpeg] +let = [ + "a = round(amplitude)", + "s = round(speed)", + "s2 = round(s * 1.3)", +] +chain = "crop=iw-{2 * a}:ih-{2 * a}:{a}+{a}*sin(t*{s}):{a}+{a}*cos(t*{s2})" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.camera-shake/effect.wgsl b/src/crates/concat-effects/packages/concat.camera-shake/effect.wgsl new file mode 100644 index 00000000..b285dacf --- /dev/null +++ b/src/crates/concat-effects/packages/concat.camera-shake/effect.wgsl @@ -0,0 +1,14 @@ +struct Params { amplitude: f32, speed: f32, rotation: f32 } + +fn effect(uv: vec2) -> vec4 { + let t = frame.time * params.speed; + let amp = params.amplitude * texel() * 0.5; + let offset = vec2(sin(t * 1.1) + 0.5 * sin(t * 2.3), cos(t * 0.9) + 0.5 * cos(t * 2.7)) * amp; + let angle = (sin(t * 0.7) + 0.5 * cos(t * 1.7)) * (params.rotation * 0.01745329); + let center = vec2(0.5, 0.5); + let d = uv + offset - center; + let cos_a = cos(angle); + let sin_a = sin(angle); + let rotated = vec2(d.x * cos_a - d.y * sin_a, d.x * sin_a + d.y * cos_a); + return sample(center + rotated); +} diff --git a/src/crates/concat-effects/packages/concat.camera-shake/ffmpeg.txt b/src/crates/concat-effects/packages/concat.camera-shake/ffmpeg.txt new file mode 100644 index 00000000..ff347cae --- /dev/null +++ b/src/crates/concat-effects/packages/concat.camera-shake/ffmpeg.txt @@ -0,0 +1 @@ +crop=iw-{2 * a}:ih-{2 * a}:{a}+{a}*sin(t*{s}):{a}+{a}*cos(t*{s2}) diff --git a/src/crates/concat-effects/packages/concat.camera-shake/fixtures.toml b/src/crates/concat-effects/packages/concat.camera-shake/fixtures.toml new file mode 100644 index 00000000..81e77c33 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.camera-shake/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "crop=iw-30:ih-30:15+15*sin(t*10):15+15*cos(t*13)" + +[[case]] +name = "min" +at = "min" +chain = "crop=iw-2:ih-2:1+1*sin(t*2):1+1*cos(t*3)" + +[[case]] +name = "max" +at = "max" +chain = "crop=iw-100:ih-100:50+50*sin(t*30):50+50*cos(t*39)" diff --git a/src/crates/concat-effects/packages/concat.camera-shake/main.wgsl b/src/crates/concat-effects/packages/concat.camera-shake/main.wgsl new file mode 100644 index 00000000..b285dacf --- /dev/null +++ b/src/crates/concat-effects/packages/concat.camera-shake/main.wgsl @@ -0,0 +1,14 @@ +struct Params { amplitude: f32, speed: f32, rotation: f32 } + +fn effect(uv: vec2) -> vec4 { + let t = frame.time * params.speed; + let amp = params.amplitude * texel() * 0.5; + let offset = vec2(sin(t * 1.1) + 0.5 * sin(t * 2.3), cos(t * 0.9) + 0.5 * cos(t * 2.7)) * amp; + let angle = (sin(t * 0.7) + 0.5 * cos(t * 1.7)) * (params.rotation * 0.01745329); + let center = vec2(0.5, 0.5); + let d = uv + offset - center; + let cos_a = cos(angle); + let sin_a = sin(angle); + let rotated = vec2(d.x * cos_a - d.y * sin_a, d.x * sin_a + d.y * cos_a); + return sample(center + rotated); +} diff --git a/src/crates/concat-effects/packages/concat.chrome-shine/effect.toml b/src/crates/concat-effects/packages/concat.chrome-shine/effect.toml new file mode 100644 index 00000000..df81f888 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.chrome-shine/effect.toml @@ -0,0 +1,31 @@ +[effect] +id = "concat.chrome-shine" +name = "Chrome Shine" +kind = "effect" +category = "Text" +order = 91 +intensity = "tint" +description = "A metallic gradient rolls slowly across the picture, dark to bright to dark." + +[[param]] +key = "tint" +label = "Warmth" +min = 0 +max = 100 +default = 50 +step = 1 +unit = "%" + +[[param]] +key = "speed" +label = "Speed" +min = 0.1 +max = 3 +default = 1 +step = 0.1 + +[ffmpeg] +chain = "eq=saturation={fixed(1 - tint / 100 * 0.3, 3)}:contrast=1.15" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.chrome-shine/effect.wgsl b/src/crates/concat-effects/packages/concat.chrome-shine/effect.wgsl new file mode 100644 index 00000000..8aec5b8f --- /dev/null +++ b/src/crates/concat-effects/packages/concat.chrome-shine/effect.wgsl @@ -0,0 +1,11 @@ +struct Params { tint: f32, speed: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let sweep = fract(uv.y * 1.5 - frame.time * params.speed * 0.1); + let ramp = abs(sweep - 0.5) * 2.0; + let metallic = mix(vec3(0.15), vec3(1.0), 1.0 - ramp); + let warm = mix(vec3(0.6, 0.65, 0.75), vec3(1.0, 0.95, 0.8), params.tint * 0.01); + let chrome = metallic * warm; + return vec4(clamp01(mix(c.rgb, chrome, c.a)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.chrome-shine/fixtures.toml b/src/crates/concat-effects/packages/concat.chrome-shine/fixtures.toml new file mode 100644 index 00000000..88e49b16 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.chrome-shine/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "eq=saturation=0.850:contrast=1.15" + +[[case]] +name = "min" +at = "min" +chain = "eq=saturation=1.000:contrast=1.15" + +[[case]] +name = "max" +at = "max" +chain = "eq=saturation=0.700:contrast=1.15" diff --git a/src/crates/concat-effects/packages/concat.classic-chrome/effect.toml b/src/crates/concat-effects/packages/concat.classic-chrome/effect.toml new file mode 100644 index 00000000..9b08f0e9 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.classic-chrome/effect.toml @@ -0,0 +1,13 @@ +[effect] +id = "concat.classic-chrome" +name = "Classic Chrome" +kind = "filter" +category = "Film" +order = 61 +description = "Muted highlights and deep, open shadows: the restrained look of a classic colour negative." + +[ffmpeg] +chain = "curves=all='0/0.03 0.5/0.48 1/0.92',eq=saturation=0.9" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.classic-chrome/effect.wgsl b/src/crates/concat-effects/packages/concat.classic-chrome/effect.wgsl new file mode 100644 index 00000000..7d19ae06 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.classic-chrome/effect.wgsl @@ -0,0 +1,5 @@ +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let f = film_curve(c.rgb, 0.1, 0.35); + return vec4(clamp01(saturation(f, 0.9)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.classic-chrome/fixtures.toml b/src/crates/concat-effects/packages/concat.classic-chrome/fixtures.toml new file mode 100644 index 00000000..5df3509e --- /dev/null +++ b/src/crates/concat-effects/packages/concat.classic-chrome/fixtures.toml @@ -0,0 +1,3 @@ +[[case]] +name = "default" +chain = "curves=all='0/0.03 0.5/0.48 1/0.92',eq=saturation=0.9" diff --git a/src/crates/concat-effects/packages/concat.clock-wipe/effect.toml b/src/crates/concat-effects/packages/concat.clock-wipe/effect.toml new file mode 100644 index 00000000..5509fc81 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.clock-wipe/effect.toml @@ -0,0 +1,12 @@ +[effect] +id = "concat.clock-wipe" +name = "Clock Wipe" +kind = "transition" +category = "Wipe" +order = 15 +description = "A hand sweeps around the centre, revealing the incoming shot as it turns." + +[transition] +entry = "effect.wgsl" +xfade = "radial" +fallback = "cross-fade" diff --git a/src/crates/concat-effects/packages/concat.clock-wipe/effect.wgsl b/src/crates/concat-effects/packages/concat.clock-wipe/effect.wgsl new file mode 100644 index 00000000..5d9a3f9c --- /dev/null +++ b/src/crates/concat-effects/packages/concat.clock-wipe/effect.wgsl @@ -0,0 +1,12 @@ +fn transition(uv: vec2, progress: f32) -> vec4 { + let p = smoothstep(0.0, 1.0, progress); + let c = uv - vec2(0.5); + var a = atan2(c.x, -c.y); + if (a < 0.0) { + a = a + 6.28318530718; + } + if (a < p * 6.28318530718) { + return to_at(uv); + } + return from_at(uv); +} diff --git a/src/crates/concat-effects/packages/concat.color-cycle/effect.toml b/src/crates/concat-effects/packages/concat.color-cycle/effect.toml new file mode 100644 index 00000000..76c69c80 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.color-cycle/effect.toml @@ -0,0 +1,26 @@ +[effect] +id = "concat.color-cycle" +name = "Color Cycle" +kind = "effect" +category = "Party & Club" +order = 17 +intensity = "speed" +description = "Smooth temporal hue rotation across the entire spectrum." + +[[param]] +key = "speed" +label = "Speed" +min = 1 +max = 100 +default = 30 +step = 1 +unit = "°/s" + +[ffmpeg] +let = [ + "s = fixed(speed, 1)", +] +chain = "hue=h=t*{s}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.color-cycle/effect.wgsl b/src/crates/concat-effects/packages/concat.color-cycle/effect.wgsl new file mode 100644 index 00000000..56a8160d --- /dev/null +++ b/src/crates/concat-effects/packages/concat.color-cycle/effect.wgsl @@ -0,0 +1,18 @@ +struct Params { speed: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let rad = frame.time * params.speed * 0.017453; + let cos_a = cos(rad); + let sin_a = sin(rad); + let rot_r = c.r * (0.299 + 0.701 * cos_a + 0.168 * sin_a) + + c.g * (0.587 - 0.587 * cos_a + 0.330 * sin_a) + + c.b * (0.114 - 0.114 * cos_a - 0.497 * sin_a); + let rot_g = c.r * (0.299 - 0.299 * cos_a - 0.328 * sin_a) + + c.g * (0.587 + 0.413 * cos_a + 0.035 * sin_a) + + c.b * (0.114 - 0.114 * cos_a + 0.288 * sin_a); + let rot_b = c.r * (0.299 - 0.300 * cos_a - 1.250 * sin_a) + + c.g * (0.587 - 0.588 * cos_a - 1.050 * sin_a) + + c.b * (0.114 + 0.886 * cos_a - 0.203 * sin_a); + return vec4(clamp01(vec3(rot_r, rot_g, rot_b)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.color-cycle/ffmpeg.txt b/src/crates/concat-effects/packages/concat.color-cycle/ffmpeg.txt new file mode 100644 index 00000000..159948bd --- /dev/null +++ b/src/crates/concat-effects/packages/concat.color-cycle/ffmpeg.txt @@ -0,0 +1 @@ +hue=h=t*{s} diff --git a/src/crates/concat-effects/packages/concat.color-cycle/fixtures.toml b/src/crates/concat-effects/packages/concat.color-cycle/fixtures.toml new file mode 100644 index 00000000..8f4c9d2e --- /dev/null +++ b/src/crates/concat-effects/packages/concat.color-cycle/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "hue=h=t*30.0" + +[[case]] +name = "min" +at = "min" +chain = "hue=h=t*1.0" + +[[case]] +name = "max" +at = "max" +chain = "hue=h=t*100.0" diff --git a/src/crates/concat-effects/packages/concat.color-cycle/main.wgsl b/src/crates/concat-effects/packages/concat.color-cycle/main.wgsl new file mode 100644 index 00000000..56a8160d --- /dev/null +++ b/src/crates/concat-effects/packages/concat.color-cycle/main.wgsl @@ -0,0 +1,18 @@ +struct Params { speed: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let rad = frame.time * params.speed * 0.017453; + let cos_a = cos(rad); + let sin_a = sin(rad); + let rot_r = c.r * (0.299 + 0.701 * cos_a + 0.168 * sin_a) + + c.g * (0.587 - 0.587 * cos_a + 0.330 * sin_a) + + c.b * (0.114 - 0.114 * cos_a - 0.497 * sin_a); + let rot_g = c.r * (0.299 - 0.299 * cos_a - 0.328 * sin_a) + + c.g * (0.587 + 0.413 * cos_a + 0.035 * sin_a) + + c.b * (0.114 - 0.114 * cos_a + 0.288 * sin_a); + let rot_b = c.r * (0.299 - 0.300 * cos_a - 1.250 * sin_a) + + c.g * (0.587 - 0.588 * cos_a - 1.050 * sin_a) + + c.b * (0.114 + 0.886 * cos_a - 0.203 * sin_a); + return vec4(clamp01(vec3(rot_r, rot_g, rot_b)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.crosshatch/effect.toml b/src/crates/concat-effects/packages/concat.crosshatch/effect.toml new file mode 100644 index 00000000..2e23148f --- /dev/null +++ b/src/crates/concat-effects/packages/concat.crosshatch/effect.toml @@ -0,0 +1,27 @@ +[effect] +id = "concat.crosshatch" +name = "Crosshatch" +kind = "effect" +category = "Stylize" +order = 84 +intensity = "amount" +description = "The dark parts of the picture built from crossed pen strokes." + +[[param]] +key = "amount" +label = "Amount" +min = 0 +max = 100 +default = 60 +step = 1 +unit = "%" + +[ffmpeg] +let = [ + "high = 0.5 - amount / 100 * 0.25", + "low = high * 0.4", +] +chain = "edgedetect=mode=colormix:low={fixed(low, 3)}:high={fixed(high, 3)},hue=s=0,eq=contrast={fixed(1 + amount / 100 * 0.3, 3)}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.crosshatch/effect.wgsl b/src/crates/concat-effects/packages/concat.crosshatch/effect.wgsl new file mode 100644 index 00000000..d97c2512 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.crosshatch/effect.wgsl @@ -0,0 +1,26 @@ +struct Params { amount: f32 } + +// Three tiers of anti-aliased lines, each fading in over its own luma band +// instead of a hard boolean cutoff - so both the strokes and where they +// start are smooth, the way inked hatching reads rather than a jagged mask. +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let l = luma(c.rgb); + let p = uv * frame.size / 4.0; + let d1 = abs(fract(p.x + p.y) - 0.5); + let d2 = abs(fract(p.x - p.y) - 0.5); + let d3 = abs(fract(p.x * 2.0 + p.y * 2.0) - 0.5); + let line1 = 1.0 - smoothstep(0.05, 0.09, d1); + let line2 = 1.0 - smoothstep(0.05, 0.09, d2); + let line3 = 1.0 - smoothstep(0.05, 0.09, d3); + let band1 = smoothstep(0.75, 0.65, l); + let band2 = smoothstep(0.45, 0.35, l); + let band3 = smoothstep(0.2, 0.1, l); + var hatch = 1.0; + hatch -= line1 * band1 * 0.65; + hatch -= line2 * band2 * 0.4; + hatch -= line3 * band3 * 0.35; + hatch = clamp(hatch, 0.0, 1.0); + let k = params.amount * 0.01; + return vec4(clamp01(mix(c.rgb, vec3(hatch), k)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.crosshatch/fixtures.toml b/src/crates/concat-effects/packages/concat.crosshatch/fixtures.toml new file mode 100644 index 00000000..98d7d9ae --- /dev/null +++ b/src/crates/concat-effects/packages/concat.crosshatch/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "edgedetect=mode=colormix:low=0.140:high=0.350,hue=s=0,eq=contrast=1.180" + +[[case]] +name = "min" +at = "min" +chain = "edgedetect=mode=colormix:low=0.200:high=0.500,hue=s=0,eq=contrast=1.000" + +[[case]] +name = "max" +at = "max" +chain = "edgedetect=mode=colormix:low=0.100:high=0.250,hue=s=0,eq=contrast=1.300" diff --git a/src/crates/concat-effects/packages/concat.crt-scanlines/effect.toml b/src/crates/concat-effects/packages/concat.crt-scanlines/effect.toml new file mode 100644 index 00000000..7e04307f --- /dev/null +++ b/src/crates/concat-effects/packages/concat.crt-scanlines/effect.toml @@ -0,0 +1,35 @@ +[effect] +id = "concat.crt-scanlines" +name = "CRT Scanlines" +kind = "effect" +category = "Retro & Film" +order = 6 +intensity = "curvature" +description = "Retro CRT scanlines and barrel screen curvature." + +[[param]] +key = "curvature" +label = "Curvature" +min = 0 +max = 100 +default = 30 +step = 1 +unit = "%" + +[[param]] +key = "scanlines" +label = "Scanlines" +min = 0 +max = 100 +default = 60 +step = 1 +unit = "%" + +[ffmpeg] +let = [ + "k = fixed(-0.25 * curvature / 100, 3)", +] +chain = "lenscorrection=k1={k}:k2={k}:i=bilinear" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.crt-scanlines/effect.wgsl b/src/crates/concat-effects/packages/concat.crt-scanlines/effect.wgsl new file mode 100644 index 00000000..f9c4e915 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.crt-scanlines/effect.wgsl @@ -0,0 +1,15 @@ +struct Params { curvature: f32, scanlines: f32 } + +fn effect(uv: vec2) -> vec4 { + let centered = (uv - 0.5) * 2.0; + let r2 = dot(centered, centered); + let warp = centered * (1.0 + (params.curvature * 0.002) * r2); + let crt_uv = warp * 0.5 + 0.5; + if (crt_uv.x < 0.0 || crt_uv.x > 1.0 || crt_uv.y < 0.0 || crt_uv.y > 1.0) { + return vec4(0.0, 0.0, 0.0, 1.0); + } + let c = sample(crt_uv); + let scanline = sin(crt_uv.y * frame.size.y * 1.5) * 0.5 + 0.5; + let dark = 1.0 - (params.scanlines * 0.01) * (1.0 - scanline) * 0.4; + return vec4(c.rgb * dark, c.a); +} diff --git a/src/crates/concat-effects/packages/concat.crt-scanlines/ffmpeg.txt b/src/crates/concat-effects/packages/concat.crt-scanlines/ffmpeg.txt new file mode 100644 index 00000000..b5ce2a95 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.crt-scanlines/ffmpeg.txt @@ -0,0 +1 @@ +lenscorrection=k1={k}:k2={k}:i=bilinear diff --git a/src/crates/concat-effects/packages/concat.crt-scanlines/fixtures.toml b/src/crates/concat-effects/packages/concat.crt-scanlines/fixtures.toml new file mode 100644 index 00000000..82f2e943 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.crt-scanlines/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "lenscorrection=k1=-0.075:k2=-0.075:i=bilinear" + +[[case]] +name = "min" +at = "min" +chain = "lenscorrection=k1=0.000:k2=0.000:i=bilinear" + +[[case]] +name = "max" +at = "max" +chain = "lenscorrection=k1=-0.250:k2=-0.250:i=bilinear" diff --git a/src/crates/concat-effects/packages/concat.crt-scanlines/main.wgsl b/src/crates/concat-effects/packages/concat.crt-scanlines/main.wgsl new file mode 100644 index 00000000..f9c4e915 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.crt-scanlines/main.wgsl @@ -0,0 +1,15 @@ +struct Params { curvature: f32, scanlines: f32 } + +fn effect(uv: vec2) -> vec4 { + let centered = (uv - 0.5) * 2.0; + let r2 = dot(centered, centered); + let warp = centered * (1.0 + (params.curvature * 0.002) * r2); + let crt_uv = warp * 0.5 + 0.5; + if (crt_uv.x < 0.0 || crt_uv.x > 1.0 || crt_uv.y < 0.0 || crt_uv.y > 1.0) { + return vec4(0.0, 0.0, 0.0, 1.0); + } + let c = sample(crt_uv); + let scanline = sin(crt_uv.y * frame.size.y * 1.5) * 0.5 + 0.5; + let dark = 1.0 - (params.scanlines * 0.01) * (1.0 - scanline) * 0.4; + return vec4(c.rgb * dark, c.a); +} diff --git a/src/crates/concat-effects/packages/concat.cube/effect.toml b/src/crates/concat-effects/packages/concat.cube/effect.toml new file mode 100644 index 00000000..851f6fd6 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.cube/effect.toml @@ -0,0 +1,11 @@ +[effect] +id = "concat.cube" +name = "Cube" +kind = "transition" +category = "Light" +order = 14 +description = "The outgoing shot rotates away like a cube face as the incoming one rotates into view." + +[transition] +entry = "effect.wgsl" +fallback = "cross-fade" diff --git a/src/crates/concat-effects/packages/concat.cube/effect.wgsl b/src/crates/concat-effects/packages/concat.cube/effect.wgsl new file mode 100644 index 00000000..8a2f9c54 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.cube/effect.wgsl @@ -0,0 +1,24 @@ +// A cheap fake of a rotating cube: each half of the cut squeezes its +// picture horizontally toward the shared edge and darkens it, the way a +// cube's face would foreshorten and fall into shadow as it turns away. +fn transition(uv: vec2, progress: f32) -> vec4 { + let p = smoothstep(0.0, 1.0, progress); + if (p < 0.5) { + let t = p * 2.0; + let squeeze = cos(t * 1.5707963); + let cx = (uv.x - 0.5) / max(squeeze, 0.001) + 0.5; + if (cx < 0.0 || cx > 1.0) { + return vec4(0.03, 0.03, 0.03, 1.0); + } + let c = from_at(vec2(cx, uv.y)); + return vec4(c.rgb * mix(1.0, 0.35, t), c.a); + } + let t = (p - 0.5) * 2.0; + let squeeze = sin(t * 1.5707963); + let cx = (uv.x - 0.5) / max(squeeze, 0.001) + 0.5; + if (cx < 0.0 || cx > 1.0) { + return vec4(0.03, 0.03, 0.03, 1.0); + } + let c = to_at(vec2(cx, uv.y)); + return vec4(c.rgb * mix(0.35, 1.0, t), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.cyberpunk/effect.toml b/src/crates/concat-effects/packages/concat.cyberpunk/effect.toml new file mode 100644 index 00000000..12ffa1ab --- /dev/null +++ b/src/crates/concat-effects/packages/concat.cyberpunk/effect.toml @@ -0,0 +1,13 @@ +[effect] +id = "concat.cyberpunk" +name = "Cyberpunk" +kind = "filter" +category = "Cinematic" +order = 52 +description = "Magenta into the shadows, cyan into the light, pushed hard and neon-bright." + +[ffmpeg] +chain = "colorbalance=rs=0.08:bs=0.12:gs=-0.05:rh=-0.05:gh=0.06:bh=0.1,eq=contrast=1.15:saturation=1.15" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.cyberpunk/effect.wgsl b/src/crates/concat-effects/packages/concat.cyberpunk/effect.wgsl new file mode 100644 index 00000000..5968f482 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.cyberpunk/effect.wgsl @@ -0,0 +1,5 @@ +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let t = split_tone(c.rgb, vec3(0.08, -0.02, 0.12), vec3(-0.05, 0.08, 0.1), 1.0); + return vec4(clamp01(contrast(saturation(t, 1.15), 1.15)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.cyberpunk/fixtures.toml b/src/crates/concat-effects/packages/concat.cyberpunk/fixtures.toml new file mode 100644 index 00000000..4b27228f --- /dev/null +++ b/src/crates/concat-effects/packages/concat.cyberpunk/fixtures.toml @@ -0,0 +1,3 @@ +[[case]] +name = "default" +chain = "colorbalance=rs=0.08:bs=0.12:gs=-0.05:rh=-0.05:gh=0.06:bh=0.1,eq=contrast=1.15:saturation=1.15" diff --git a/src/crates/concat-effects/packages/concat.datamosh/effect.toml b/src/crates/concat-effects/packages/concat.datamosh/effect.toml new file mode 100644 index 00000000..abf3a7ef --- /dev/null +++ b/src/crates/concat-effects/packages/concat.datamosh/effect.toml @@ -0,0 +1,23 @@ +[effect] +id = "concat.datamosh" +name = "Datamosh" +kind = "effect" +category = "Retro" +order = 75 +intensity = "amount" +description = "Blocks of the picture tear sideways, flickering like a broken codec." + +[[param]] +key = "amount" +label = "Amount" +min = 0 +max = 100 +default = 60 +step = 1 +unit = "%" + +[ffmpeg] +chain = "chromashift=crh={round(amount / 100 * 6)}:cbh=-{round(amount / 100 * 6)},noise=alls={round(amount / 100 * 20)}:allf=t" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.datamosh/effect.wgsl b/src/crates/concat-effects/packages/concat.datamosh/effect.wgsl new file mode 100644 index 00000000..87fa654b --- /dev/null +++ b/src/crates/concat-effects/packages/concat.datamosh/effect.wgsl @@ -0,0 +1,13 @@ +struct Params { amount: f32 } + +fn effect(uv: vec2) -> vec4 { + let seedt = floor(frame.time * 8.0); + let block = floor(uv * vec2(16.0, 9.0)); + let jitter = hash(block, seedt); + let k = params.amount * 0.01; + var offset = vec2(0.0); + if (jitter > 1.0 - k * 0.6) { + offset = vec2((hash(block, seedt + 1.0) - 0.5) * 0.06, 0.0); + } + return sample(uv + offset); +} diff --git a/src/crates/concat-effects/packages/concat.datamosh/fixtures.toml b/src/crates/concat-effects/packages/concat.datamosh/fixtures.toml new file mode 100644 index 00000000..a1e767b0 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.datamosh/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "chromashift=crh=4:cbh=-4,noise=alls=12:allf=t" + +[[case]] +name = "min" +at = "min" +chain = "chromashift=crh=0:cbh=-0,noise=alls=0:allf=t" + +[[case]] +name = "max" +at = "max" +chain = "chromashift=crh=6:cbh=-6,noise=alls=20:allf=t" diff --git a/src/crates/concat-effects/packages/concat.delicious/effect.toml b/src/crates/concat-effects/packages/concat.delicious/effect.toml new file mode 100644 index 00000000..86413604 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.delicious/effect.toml @@ -0,0 +1,13 @@ +[effect] +id = "concat.delicious" +name = "Delicious" +kind = "filter" +category = "Food" +order = 54 +description = "Warm and saturated, the appetising push every food shot wants." + +[ffmpeg] +chain = "colortemperature=temperature=5200:pl=1,eq=saturation=1.3:contrast=1.1" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.delicious/effect.wgsl b/src/crates/concat-effects/packages/concat.delicious/effect.wgsl new file mode 100644 index 00000000..1f0de76d --- /dev/null +++ b/src/crates/concat-effects/packages/concat.delicious/effect.wgsl @@ -0,0 +1,6 @@ +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let wb = white_balance(c.rgb, 5200.0); + let s = saturation(wb, 1.3); + return vec4(clamp01(s_curve(s, 0.25)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.delicious/fixtures.toml b/src/crates/concat-effects/packages/concat.delicious/fixtures.toml new file mode 100644 index 00000000..82859ed4 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.delicious/fixtures.toml @@ -0,0 +1,3 @@ +[[case]] +name = "default" +chain = "colortemperature=temperature=5200:pl=1,eq=saturation=1.3:contrast=1.1" diff --git a/src/crates/concat-effects/packages/concat.dissolve/effect.toml b/src/crates/concat-effects/packages/concat.dissolve/effect.toml new file mode 100644 index 00000000..beb83025 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.dissolve/effect.toml @@ -0,0 +1,13 @@ +[effect] +id = "concat.dissolve" +name = "Dissolve" +kind = "transition" +category = "Basic" +order = 0 +aliases = ["cross-fade"] +description = "A soft cross-dissolve from one shot into the next." + +[transition] +entry = "effect.wgsl" +xfade = "fade" +fallback = "cross-fade" diff --git a/src/crates/concat-effects/packages/concat.dissolve/effect.wgsl b/src/crates/concat-effects/packages/concat.dissolve/effect.wgsl new file mode 100644 index 00000000..91bfb278 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.dissolve/effect.wgsl @@ -0,0 +1,6 @@ +// A straight cross-dissolve: the outgoing picture fades into the incoming one +// over the cut. The reference every other transition is measured against, and +// the fallback the CPU and a GPU-less export degrade to. +fn transition(uv: vec2, progress: f32) -> vec4 { + return mix(from_at(uv), to_at(uv), progress); +} diff --git a/src/crates/concat-effects/packages/concat.dreamy/effect.toml b/src/crates/concat-effects/packages/concat.dreamy/effect.toml new file mode 100644 index 00000000..ddad0477 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.dreamy/effect.toml @@ -0,0 +1,13 @@ +[effect] +id = "concat.dreamy" +name = "Dreamy" +kind = "filter" +category = "Vibe" +order = 66 +description = "A soft, hazy bloom over lifted pastel blacks." + +[ffmpeg] +chain = "curves=all='0/0.08 1/0.92',eq=saturation=0.9,gblur=sigma=0.6" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.dreamy/effect.wgsl b/src/crates/concat-effects/packages/concat.dreamy/effect.wgsl new file mode 100644 index 00000000..d2bb5f4e --- /dev/null +++ b/src/crates/concat-effects/packages/concat.dreamy/effect.wgsl @@ -0,0 +1,6 @@ +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let s = soften(uv, 3.0); + let f = fade(mix(c.rgb, s, 0.4), 0.08); + return vec4(clamp01(saturation(f, 0.9)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.dreamy/fixtures.toml b/src/crates/concat-effects/packages/concat.dreamy/fixtures.toml new file mode 100644 index 00000000..3c758c18 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.dreamy/fixtures.toml @@ -0,0 +1,3 @@ +[[case]] +name = "default" +chain = "curves=all='0/0.08 1/0.92',eq=saturation=0.9,gblur=sigma=0.6" diff --git a/src/crates/concat-effects/packages/concat.duotone/effect.toml b/src/crates/concat-effects/packages/concat.duotone/effect.toml new file mode 100644 index 00000000..4ec1cbf9 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.duotone/effect.toml @@ -0,0 +1,23 @@ +[effect] +id = "concat.duotone" +name = "Duotone" +kind = "effect" +category = "Stylize" +order = 82 +intensity = "amount" +description = "The picture's tones remapped between a shadow colour and a highlight colour." + +[[param]] +key = "amount" +label = "Amount" +min = 0 +max = 100 +default = 100 +step = 1 +unit = "%" + +[ffmpeg] +chain = "eq=saturation={fixed(1 - amount / 100 * 0.7, 3)},colorbalance=rs={fixed(0 - amount / 100 * 0.05, 3)}:bs={fixed(amount / 100 * 0.12, 3)}:rh={fixed(amount / 100 * 0.1, 3)}:bh={fixed(0 - amount / 100 * 0.08, 3)}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.duotone/effect.wgsl b/src/crates/concat-effects/packages/concat.duotone/effect.wgsl new file mode 100644 index 00000000..c06acf35 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.duotone/effect.wgsl @@ -0,0 +1,10 @@ +struct Params { amount: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let g = luma(c.rgb); + let shadow = vec3(0.05, 0.05, 0.25); + let highlight = vec3(1.0, 0.75, 0.25); + let toned = mix(shadow, highlight, g); + return vec4(clamp01(mix(c.rgb, toned, params.amount * 0.01)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.duotone/fixtures.toml b/src/crates/concat-effects/packages/concat.duotone/fixtures.toml new file mode 100644 index 00000000..3b3b4eaa --- /dev/null +++ b/src/crates/concat-effects/packages/concat.duotone/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "eq=saturation=0.300,colorbalance=rs=-0.050:bs=0.120:rh=0.100:bh=-0.080" + +[[case]] +name = "min" +at = "min" +chain = "eq=saturation=1.000,colorbalance=rs=0.000:bs=0.000:rh=0.000:bh=0.000" + +[[case]] +name = "max" +at = "max" +chain = "eq=saturation=0.300,colorbalance=rs=-0.050:bs=0.120:rh=0.100:bh=-0.080" diff --git a/src/crates/concat-effects/packages/concat.dust/effect.toml b/src/crates/concat-effects/packages/concat.dust/effect.toml new file mode 100644 index 00000000..acae7507 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.dust/effect.toml @@ -0,0 +1,23 @@ +[effect] +id = "concat.dust" +name = "Dust" +kind = "effect" +category = "Atmosphere" +order = 87 +intensity = "amount" +description = "Fine specks of dust flicker over the picture, frame to frame." + +[[param]] +key = "amount" +label = "Amount" +min = 0 +max = 100 +default = 50 +step = 1 +unit = "%" + +[ffmpeg] +chain = "noise=alls={round(amount / 100 * 15)}:allf=t+u,eq=contrast={fixed(1 - amount / 100 * 0.05, 3)}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.dust/effect.wgsl b/src/crates/concat-effects/packages/concat.dust/effect.wgsl new file mode 100644 index 00000000..1af13390 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.dust/effect.wgsl @@ -0,0 +1,24 @@ +struct Params { amount: f32 } + +// Warm motes drifting in lazy circles, the way dust turns in a sunbeam - +// in place of a per-pixel noise threshold that just flickered on and off +// with the frame. +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let k = params.amount * 0.01; + var glow = vec3(0.0); + for (var i: i32 = 0; i < 14; i++) { + let seed = f32(i) * 17.0; + let depth = hash(vec2(seed, 5.0), 0.0); + let base = vec2(hash(vec2(seed, 1.0), 0.0), hash(vec2(seed, 2.0), 0.0)); + let t = frame.time * (0.15 + depth * 0.2) + seed; + let drift = vec2(sin(t), cos(t * 0.8)) * mix(0.015, 0.05, depth); + let p = fract(base + drift); + let d = distance(uv, p); + let radius = mix(0.0015, 0.005, depth); + let sparkle = 0.7 + 0.3 * sin(frame.time * 6.0 + seed * 4.0); + let falloff = exp(-(d * d) / (radius * radius)) * sparkle; + glow += vec3(1.0, 0.92, 0.75) * falloff * mix(0.25, 0.9, depth); + } + return vec4(clamp01(c.rgb + glow * k), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.dust/fixtures.toml b/src/crates/concat-effects/packages/concat.dust/fixtures.toml new file mode 100644 index 00000000..c77522b1 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.dust/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "noise=alls=8:allf=t+u,eq=contrast=0.975" + +[[case]] +name = "min" +at = "min" +chain = "noise=alls=0:allf=t+u,eq=contrast=1.000" + +[[case]] +name = "max" +at = "max" +chain = "noise=alls=15:allf=t+u,eq=contrast=0.950" diff --git a/src/crates/concat-effects/packages/concat.film-burn/effect.toml b/src/crates/concat-effects/packages/concat.film-burn/effect.toml new file mode 100644 index 00000000..e2dd0ce3 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.film-burn/effect.toml @@ -0,0 +1,21 @@ +[effect] +id = "concat.film-burn" +name = "Film Burn" +kind = "transition" +category = "Dissolve" +order = 2 +description = "A warm over-exposed flash burns through the cut." + +[[param]] +key = "heat" +label = "Heat" +min = 0 +max = 100 +default = 70 +step = 1 +unit = "%" + +[transition] +entry = "effect.wgsl" +xfade = "fadewhite" +fallback = "cross-fade" diff --git a/src/crates/concat-effects/packages/concat.film-burn/effect.wgsl b/src/crates/concat-effects/packages/concat.film-burn/effect.wgsl new file mode 100644 index 00000000..8951459a --- /dev/null +++ b/src/crates/concat-effects/packages/concat.film-burn/effect.wgsl @@ -0,0 +1,9 @@ +struct Params { heat: f32 } + +// A warm flash triangular in time, peaking at the middle of the cut. +fn transition(uv: vec2, progress: f32) -> vec4 { + let base = mix(from_at(uv), to_at(uv), progress); + let burn = smoothstep(0.0, 0.5, progress) * (1.0 - smoothstep(0.5, 1.0, progress)) * 4.0; + let warm = vec3(1.0, 0.85, 0.6) * burn * (params.heat * 0.01); + return vec4(clamp01(base.rgb + warm), base.a); +} diff --git a/src/crates/concat-effects/packages/concat.film-reel/effect.toml b/src/crates/concat-effects/packages/concat.film-reel/effect.toml new file mode 100644 index 00000000..d576c650 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.film-reel/effect.toml @@ -0,0 +1,35 @@ +[effect] +id = "concat.film-reel" +name = "Film Reel" +kind = "effect" +category = "Retro & Film" +order = 7 +intensity = "grain" +description = "Vintage countdown leader with film grain and gate weave." + +[[param]] +key = "grain" +label = "Grain" +min = 0 +max = 100 +default = 40 +step = 1 +unit = "%" + +[[param]] +key = "weave" +label = "Weave" +min = 0 +max = 50 +default = 10 +step = 1 +unit = "px" + +[ffmpeg] +let = [ + "g = fixed(grain * 0.3, 1)", +] +chain = "noise=alls={g}:allf=t+u" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.film-reel/effect.wgsl b/src/crates/concat-effects/packages/concat.film-reel/effect.wgsl new file mode 100644 index 00000000..f9e2f7d3 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.film-reel/effect.wgsl @@ -0,0 +1,45 @@ +struct Params { grain: f32, weave: f32 } + +fn draw_digit(p: vec2, d: i32) -> f32 { + let ix = floor(p.x); + let iy = floor(p.y); + if (ix < 0.0 || ix > 2.0 || iy < 0.0 || iy > 4.0) { return 0.0; } + if (d == 1) { return f32(ix == 2.0); } + if (d == 2) { return f32(iy == 0.0 || iy == 2.0 || iy == 4.0 || (ix == 2.0 && iy <= 2.0) || (ix == 0.0 && iy >= 2.0)); } + if (d == 3) { return f32(iy == 0.0 || iy == 2.0 || iy == 4.0 || ix == 2.0); } + return 0.0; +} + +fn effect(uv: vec2) -> vec4 { + let jitter_x = (hash(vec2(floor(frame.time * 24.0), 0.0), 1.0) - 0.5) * params.weave * texel().x; + let jitter_y = (hash(vec2(floor(frame.time * 24.0), 1.0), 2.0) - 0.5) * params.weave * texel().y; + let warped_uv = uv + vec2(jitter_x, jitter_y); + let c = sample(warped_uv); + let n = hash(uv * 100.0, frame.time) - 0.5; + let grain_val = n * (params.grain * 0.01) * 0.5; + var col = mix(c.rgb, vec3(luma(c.rgb) * 1.1, luma(c.rgb) * 0.95, luma(c.rgb) * 0.75), 0.3); + + // 3-2-1 Countdown leader + let center = vec2(0.5, 0.5); + let d = uv - center; + let r = length(d); + let outer_ring = smoothstep(0.003, 0.0, abs(r - 0.28)); + let inner_ring = smoothstep(0.003, 0.0, abs(r - 0.22)); + let cross_x = smoothstep(0.002, 0.0, abs(uv.x - 0.5)) * step(r, 0.3); + let cross_y = smoothstep(0.002, 0.0, abs(uv.y - 0.5)) * step(r, 0.3); + let angle = (atan2(d.y, d.x) + 3.14159) / 6.28318; + let sweep = fract(frame.time); + let arm = smoothstep(0.01, 0.0, abs(angle - sweep)) * step(r, 0.28); + let reticle = max(max(outer_ring, inner_ring), max(cross_x, cross_y)) + arm; + + let sec = i32(frame.time) % 4; + var num = 0.0; + if (sec < 3) { + let num_p = (uv - vec2(0.485, 0.46)) / vec2(0.01, 0.016); + num = draw_digit(num_p, 3 - sec); + } + let leader = clamp(reticle + num, 0.0, 1.0); + col = mix(col, vec3(0.92, 0.88, 0.75), leader * 0.75); + + return vec4(clamp01(col + vec3(grain_val)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.film-reel/ffmpeg.txt b/src/crates/concat-effects/packages/concat.film-reel/ffmpeg.txt new file mode 100644 index 00000000..3fccbb75 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.film-reel/ffmpeg.txt @@ -0,0 +1 @@ +noise=alls={g}:allf=t+u diff --git a/src/crates/concat-effects/packages/concat.film-reel/fixtures.toml b/src/crates/concat-effects/packages/concat.film-reel/fixtures.toml new file mode 100644 index 00000000..ba17386f --- /dev/null +++ b/src/crates/concat-effects/packages/concat.film-reel/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "noise=alls=12.0:allf=t+u" + +[[case]] +name = "min" +at = "min" +chain = "noise=alls=0.0:allf=t+u" + +[[case]] +name = "max" +at = "max" +chain = "noise=alls=30.0:allf=t+u" diff --git a/src/crates/concat-effects/packages/concat.film-reel/main.wgsl b/src/crates/concat-effects/packages/concat.film-reel/main.wgsl new file mode 100644 index 00000000..f9e2f7d3 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.film-reel/main.wgsl @@ -0,0 +1,45 @@ +struct Params { grain: f32, weave: f32 } + +fn draw_digit(p: vec2, d: i32) -> f32 { + let ix = floor(p.x); + let iy = floor(p.y); + if (ix < 0.0 || ix > 2.0 || iy < 0.0 || iy > 4.0) { return 0.0; } + if (d == 1) { return f32(ix == 2.0); } + if (d == 2) { return f32(iy == 0.0 || iy == 2.0 || iy == 4.0 || (ix == 2.0 && iy <= 2.0) || (ix == 0.0 && iy >= 2.0)); } + if (d == 3) { return f32(iy == 0.0 || iy == 2.0 || iy == 4.0 || ix == 2.0); } + return 0.0; +} + +fn effect(uv: vec2) -> vec4 { + let jitter_x = (hash(vec2(floor(frame.time * 24.0), 0.0), 1.0) - 0.5) * params.weave * texel().x; + let jitter_y = (hash(vec2(floor(frame.time * 24.0), 1.0), 2.0) - 0.5) * params.weave * texel().y; + let warped_uv = uv + vec2(jitter_x, jitter_y); + let c = sample(warped_uv); + let n = hash(uv * 100.0, frame.time) - 0.5; + let grain_val = n * (params.grain * 0.01) * 0.5; + var col = mix(c.rgb, vec3(luma(c.rgb) * 1.1, luma(c.rgb) * 0.95, luma(c.rgb) * 0.75), 0.3); + + // 3-2-1 Countdown leader + let center = vec2(0.5, 0.5); + let d = uv - center; + let r = length(d); + let outer_ring = smoothstep(0.003, 0.0, abs(r - 0.28)); + let inner_ring = smoothstep(0.003, 0.0, abs(r - 0.22)); + let cross_x = smoothstep(0.002, 0.0, abs(uv.x - 0.5)) * step(r, 0.3); + let cross_y = smoothstep(0.002, 0.0, abs(uv.y - 0.5)) * step(r, 0.3); + let angle = (atan2(d.y, d.x) + 3.14159) / 6.28318; + let sweep = fract(frame.time); + let arm = smoothstep(0.01, 0.0, abs(angle - sweep)) * step(r, 0.28); + let reticle = max(max(outer_ring, inner_ring), max(cross_x, cross_y)) + arm; + + let sec = i32(frame.time) % 4; + var num = 0.0; + if (sec < 3) { + let num_p = (uv - vec2(0.485, 0.46)) / vec2(0.01, 0.016); + num = draw_digit(num_p, 3 - sec); + } + let leader = clamp(reticle + num, 0.0, 1.0); + col = mix(col, vec3(0.92, 0.88, 0.75), leader * 0.75); + + return vec4(clamp01(col + vec3(grain_val)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.fisheye/ffmpeg.txt b/src/crates/concat-effects/packages/concat.fisheye/ffmpeg.txt new file mode 100644 index 00000000..74b96c92 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.fisheye/ffmpeg.txt @@ -0,0 +1 @@ +ffmpeg-chain diff --git a/src/crates/concat-effects/packages/concat.fisheye/main.wgsl b/src/crates/concat-effects/packages/concat.fisheye/main.wgsl new file mode 100644 index 00000000..dea7f53c --- /dev/null +++ b/src/crates/concat-effects/packages/concat.fisheye/main.wgsl @@ -0,0 +1,15 @@ +struct Params { strength: f32 } + +// Barrel distortion about the centre, stronger towards the edge. +fn effect(uv: vec2) -> vec4 { + let k = params.strength / 100.0 * 0.6; + let aspect = frame.size.x / frame.size.y; + var p = (uv - vec2(0.5)) * vec2(2.0 * aspect, 2.0); + let r2 = dot(p, p); + p = p * (1.0 - k * r2) / (1.0 - k * (aspect * aspect + 1.0) * 0.5); + let q = p / vec2(2.0 * aspect, 2.0) + vec2(0.5); + if (q.x < 0.0 || q.x > 1.0 || q.y < 0.0 || q.y > 1.0) { + return vec4(0.0, 0.0, 0.0, 0.0); + } + return sample(q); +} diff --git a/src/crates/concat-effects/packages/concat.fog/effect.toml b/src/crates/concat-effects/packages/concat.fog/effect.toml new file mode 100644 index 00000000..ce9c4dfc --- /dev/null +++ b/src/crates/concat-effects/packages/concat.fog/effect.toml @@ -0,0 +1,23 @@ +[effect] +id = "concat.fog" +name = "Fog" +kind = "effect" +category = "Atmosphere" +order = 86 +intensity = "amount" +description = "A soft haze settles over the picture, blacks lifted and colour thinned." + +[[param]] +key = "amount" +label = "Amount" +min = 0 +max = 100 +default = 50 +step = 1 +unit = "%" + +[ffmpeg] +chain = "eq=saturation={fixed(1 - amount / 100 * 0.5, 3)}:contrast={fixed(1 - amount / 100 * 0.2, 3)},curves=all='0/{fixed(amount / 100 * 0.15, 3)} 1/{fixed(1 - amount / 100 * 0.05, 3)}'" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.fog/effect.wgsl b/src/crates/concat-effects/packages/concat.fog/effect.wgsl new file mode 100644 index 00000000..4febabc6 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.fog/effect.wgsl @@ -0,0 +1,29 @@ +struct Params { amount: f32 } + +// Smooth value noise: a hashed grid of corners, bilinearly blended - the +// cheapest way to get soft, wispy variation instead of a single flat value. +fn noise2(p: vec2) -> f32 { + let i = floor(p); + let f = fract(p); + let n00 = hash(i, 0.0); + let n10 = hash(i + vec2(1.0, 0.0), 0.0); + let n01 = hash(i + vec2(0.0, 1.0), 0.0); + let n11 = hash(i + vec2(1.0, 1.0), 0.0); + let u = f * f * (vec2(3.0) - 2.0 * f); + return mix(mix(n00, n10, u.x), mix(n01, n11, u.x), u.y); +} + +// Two drifting octaves of noise, banked thicker toward the bottom of the +// frame - a ground fog with texture and a slow roll, not a flat wash. +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let k = params.amount * 0.01; + let drift = vec2(frame.time * 0.02, frame.time * 0.008); + let n1 = noise2(uv * vec2(3.0, 2.0) + drift); + let n2 = noise2(uv * vec2(6.5, 4.5) - drift * 1.6); + let texture = n1 * 0.65 + n2 * 0.35; + let ground = smoothstep(0.05, 0.95, uv.y); + let density = clamp((texture * 0.6 + 0.4) * mix(0.3, 1.0, ground) * k * 1.6, 0.0, 1.0); + let haze = fade(c.rgb, density * 0.55); + return vec4(clamp01(saturation(haze, 1.0 - density * 0.6)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.fog/fixtures.toml b/src/crates/concat-effects/packages/concat.fog/fixtures.toml new file mode 100644 index 00000000..f18bdc69 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.fog/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "eq=saturation=0.750:contrast=0.900,curves=all='0/0.075 1/0.975'" + +[[case]] +name = "min" +at = "min" +chain = "eq=saturation=1.000:contrast=1.000,curves=all='0/0.000 1/1.000'" + +[[case]] +name = "max" +at = "max" +chain = "eq=saturation=0.500:contrast=0.800,curves=all='0/0.150 1/0.950'" diff --git a/src/crates/concat-effects/packages/concat.frosted-glass/effect.toml b/src/crates/concat-effects/packages/concat.frosted-glass/effect.toml new file mode 100644 index 00000000..43e422d0 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.frosted-glass/effect.toml @@ -0,0 +1,32 @@ +[effect] +id = "concat.frosted-glass" +name = "Frosted Glass" +kind = "effect" +category = "Text" +order = 93 +intensity = "blur" +description = "A soft blurred halo spreads just outside the picture's edge, the shape itself kept sharp." + +[[param]] +key = "blur" +label = "Blur" +min = 0 +max = 100 +default = 40 +step = 1 +unit = "%" + +[[param]] +key = "spread" +label = "Spread" +min = 0 +max = 100 +default = 50 +step = 1 +unit = "%" + +[ffmpeg] +chain = "gblur=sigma={fixed(0.3 + blur / 100 * 2.5, 2)}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.frosted-glass/effect.wgsl b/src/crates/concat-effects/packages/concat.frosted-glass/effect.wgsl new file mode 100644 index 00000000..c6156bd5 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.frosted-glass/effect.wgsl @@ -0,0 +1,18 @@ +struct Params { blur: f32, spread: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let radius = max(params.blur, 0.1) * 2.0; + let t = texel() * radius; + var sum = vec4(0.0); + for (var y: i32 = -1; y <= 1; y++) { + for (var x: i32 = -1; x <= 1; x++) { + sum += sample(uv + vec2(f32(x), f32(y)) * t); + } + } + let blurred = sum / 9.0; + let halo = clamp(blurred.a * params.spread * 0.01, 0.0, 1.0); + let out_rgb = mix(blurred.rgb, c.rgb, c.a); + let out_a = clamp(max(c.a, halo), 0.0, 1.0); + return vec4(out_rgb, out_a); +} diff --git a/src/crates/concat-effects/packages/concat.frosted-glass/fixtures.toml b/src/crates/concat-effects/packages/concat.frosted-glass/fixtures.toml new file mode 100644 index 00000000..48167d31 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.frosted-glass/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "gblur=sigma=1.30" + +[[case]] +name = "min" +at = "min" +chain = "gblur=sigma=0.30" + +[[case]] +name = "max" +at = "max" +chain = "gblur=sigma=2.80" diff --git a/src/crates/concat-effects/packages/concat.glitch-transition/effect.toml b/src/crates/concat-effects/packages/concat.glitch-transition/effect.toml new file mode 100644 index 00000000..12e916ab --- /dev/null +++ b/src/crates/concat-effects/packages/concat.glitch-transition/effect.toml @@ -0,0 +1,20 @@ +[effect] +id = "concat.glitch-transition" +name = "Glitch" +kind = "transition" +category = "Glitch" +order = 8 +description = "Digital tearing and channel splitting rip through the cut." + +[[param]] +key = "amount" +label = "Amount" +min = 0 +max = 100 +default = 70 +step = 1 +unit = "%" + +[transition] +entry = "effect.wgsl" +fallback = "cross-fade" diff --git a/src/crates/concat-effects/packages/concat.glitch-transition/effect.wgsl b/src/crates/concat-effects/packages/concat.glitch-transition/effect.wgsl new file mode 100644 index 00000000..27bb2e27 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.glitch-transition/effect.wgsl @@ -0,0 +1,18 @@ +struct Params { amount: f32 } + +// Horizontal slices of the outgoing picture jitter sideways and split their +// channels; the tearing peaks at the middle of the cut. +fn transition(uv: vec2, progress: f32) -> vec4 { + let k = (1.0 - abs(progress * 2.0 - 1.0)) * params.amount * 0.01; + let slice = floor(uv.y * 24.0); + let jitter = (hash(vec2(slice, 0.0), floor(progress * 40.0)) - 0.5) * k * 0.15; + let ux = clamp(uv.x + jitter, 0.0, 1.0); + let a = vec4( + from_at(vec2(clamp(ux + k * 0.01, 0.0, 1.0), uv.y)).r, + from_at(vec2(ux, uv.y)).g, + from_at(vec2(clamp(ux - k * 0.01, 0.0, 1.0), uv.y)).b, + 1.0, + ); + let b = to_at(vec2(clamp(uv.x - jitter, 0.0, 1.0), uv.y)); + return mix(a, b, smoothstep(0.0, 1.0, progress)); +} diff --git a/src/crates/concat-effects/packages/concat.glitch-warp/effect.toml b/src/crates/concat-effects/packages/concat.glitch-warp/effect.toml new file mode 100644 index 00000000..2bb64c22 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.glitch-warp/effect.toml @@ -0,0 +1,21 @@ +[effect] +id = "concat.glitch-warp" +name = "Glitch Warp" +kind = "transition" +category = "Glitch" +order = 17 +description = "Chromatic slice tear and digital pixel displacement transition." + +[[param]] +key = "amount" +label = "Amount" +min = 0 +max = 100 +default = 75 +step = 1 +unit = "%" + +[transition] +entry = "effect.wgsl" +xfade = "pixelize" +fallback = "cross-fade" diff --git a/src/crates/concat-effects/packages/concat.glitch-warp/effect.wgsl b/src/crates/concat-effects/packages/concat.glitch-warp/effect.wgsl new file mode 100644 index 00000000..45caff31 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.glitch-warp/effect.wgsl @@ -0,0 +1,16 @@ +struct Params { amount: f32 } + +fn transition(uv: vec2, progress: f32) -> vec4 { + let k = sin(progress * 3.14159); + let slice = floor(uv.y * 20.0); + let noise = hash(vec2(slice, floor(progress * 15.0)), 1.0); + let disp = (noise - 0.5) * k * (params.amount * 0.001); + let uv_r = uv + vec2(disp * 1.5, 0.0); + let uv_g = uv + vec2(disp, 0.0); + let uv_b = uv - vec2(disp, 0.0); + let r = mix(from_at(uv_r).r, to_at(uv_r).r, progress); + let g = mix(from_at(uv_g).g, to_at(uv_g).g, progress); + let b = mix(from_at(uv_b).b, to_at(uv_b).b, progress); + let a = mix(from_at(uv).a, to_at(uv).a, progress); + return vec4(r, g, b, a); +} diff --git a/src/crates/concat-effects/packages/concat.glitch-warp/ffmpeg.txt b/src/crates/concat-effects/packages/concat.glitch-warp/ffmpeg.txt new file mode 100644 index 00000000..863d36e6 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.glitch-warp/ffmpeg.txt @@ -0,0 +1 @@ +pixelize diff --git a/src/crates/concat-effects/packages/concat.glitch-warp/main.wgsl b/src/crates/concat-effects/packages/concat.glitch-warp/main.wgsl new file mode 100644 index 00000000..45caff31 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.glitch-warp/main.wgsl @@ -0,0 +1,16 @@ +struct Params { amount: f32 } + +fn transition(uv: vec2, progress: f32) -> vec4 { + let k = sin(progress * 3.14159); + let slice = floor(uv.y * 20.0); + let noise = hash(vec2(slice, floor(progress * 15.0)), 1.0); + let disp = (noise - 0.5) * k * (params.amount * 0.001); + let uv_r = uv + vec2(disp * 1.5, 0.0); + let uv_g = uv + vec2(disp, 0.0); + let uv_b = uv - vec2(disp, 0.0); + let r = mix(from_at(uv_r).r, to_at(uv_r).r, progress); + let g = mix(from_at(uv_g).g, to_at(uv_g).g, progress); + let b = mix(from_at(uv_b).b, to_at(uv_b).b, progress); + let a = mix(from_at(uv).a, to_at(uv).a, progress); + return vec4(r, g, b, a); +} diff --git a/src/crates/concat-effects/packages/concat.gradient-wave/effect.toml b/src/crates/concat-effects/packages/concat.gradient-wave/effect.toml new file mode 100644 index 00000000..33753cb6 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.gradient-wave/effect.toml @@ -0,0 +1,31 @@ +[effect] +id = "concat.gradient-wave" +name = "Gradient Wave" +kind = "effect" +category = "Text" +order = 92 +intensity = "hue" +description = "A two-colour gradient rolls across the picture, its palette turned by hue." + +[[param]] +key = "hue" +label = "Hue" +min = 0 +max = 360 +default = 200 +step = 1 +unit = "°" + +[[param]] +key = "speed" +label = "Speed" +min = 0.1 +max = 3 +default = 1 +step = 0.1 + +[ffmpeg] +chain = "hue=h={round(hue)},colorbalance=rh=0.04:bh=0.06" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.gradient-wave/effect.wgsl b/src/crates/concat-effects/packages/concat.gradient-wave/effect.wgsl new file mode 100644 index 00000000..0b346b73 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.gradient-wave/effect.wgsl @@ -0,0 +1,9 @@ +struct Params { hue: f32, speed: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let wave = 0.5 + 0.5 * sin(uv.x * 6.283 * 1.5 + frame.time * params.speed); + let base = mix(vec3(0.2, 0.8, 1.0), vec3(1.0, 0.3, 0.8), wave); + let grad = hue_rotate(base, params.hue); + return vec4(clamp01(mix(c.rgb, grad, c.a)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.gradient-wave/fixtures.toml b/src/crates/concat-effects/packages/concat.gradient-wave/fixtures.toml new file mode 100644 index 00000000..61fe3beb --- /dev/null +++ b/src/crates/concat-effects/packages/concat.gradient-wave/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "hue=h=200,colorbalance=rh=0.04:bh=0.06" + +[[case]] +name = "min" +at = "min" +chain = "hue=h=0,colorbalance=rh=0.04:bh=0.06" + +[[case]] +name = "max" +at = "max" +chain = "hue=h=360,colorbalance=rh=0.04:bh=0.06" diff --git a/src/crates/concat-effects/packages/concat.green-screen/effect.toml b/src/crates/concat-effects/packages/concat.green-screen/effect.toml index ccb066f7..2e207421 100644 --- a/src/crates/concat-effects/packages/concat.green-screen/effect.toml +++ b/src/crates/concat-effects/packages/concat.green-screen/effect.toml @@ -26,7 +26,7 @@ step = 1 unit = "%" [ffmpeg] -chain = "chromakey=color=0x00FF00ty={fixed(similarity / 100, 3)}:blend={fixed(softness / 100, 3)}" +chain = "chromakey=color=0x00FF00:similarity={fixed(similarity / 100, 3)}:blend={fixed(softness / 100, 3)}" [wgsl] entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.green-screen/fixtures.toml b/src/crates/concat-effects/packages/concat.green-screen/fixtures.toml index 37abc86a..496362d5 100644 --- a/src/crates/concat-effects/packages/concat.green-screen/fixtures.toml +++ b/src/crates/concat-effects/packages/concat.green-screen/fixtures.toml @@ -1,13 +1,13 @@ [[case]] name = "default" -chain = "chromakey=color=0x00FF00ty=0.250:blend=0.100" +chain = "chromakey=color=0x00FF00:similarity=0.250:blend=0.100" [[case]] name = "minimum" at = "min" -chain = "chromakey=color=0x00FF00ty=0.010:blend=0.000" +chain = "chromakey=color=0x00FF00:similarity=0.010:blend=0.000" [[case]] name = "maximum" at = "max" -chain = "chromakey=color=0x00FF00ty=0.600:blend=0.500" +chain = "chromakey=color=0x00FF00:similarity=0.600:blend=0.500" diff --git a/src/crates/concat-effects/packages/concat.halation/effect.toml b/src/crates/concat-effects/packages/concat.halation/effect.toml new file mode 100644 index 00000000..1e3fd484 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.halation/effect.toml @@ -0,0 +1,36 @@ +[effect] +id = "concat.halation" +name = "Halation" +kind = "effect" +category = "Retro & Film" +order = 9 +intensity = "amount" +description = "Warm red/orange glow bleeding around high-contrast edges." + +[[param]] +key = "amount" +label = "Amount" +min = 0 +max = 100 +default = 60 +step = 1 +unit = "%" + +[[param]] +key = "radius" +label = "Radius" +min = 2 +max = 30 +default = 12 +step = 1 +unit = "px" + +[ffmpeg] +let = [ + "a = fixed(amount / 100 * 0.4, 3)", + "r = round(radius)", +] +chain = "split[hla{index}][hlb{index}];[hlb{index}]gblur=sigma={r},colorbalance=rh=0.3:gh=0.05:bh=-0.2[hlg{index}];[hla{index}][hlg{index}]blend=all_mode=screen:all_opacity={a}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.halation/effect.wgsl b/src/crates/concat-effects/packages/concat.halation/effect.wgsl new file mode 100644 index 00000000..ca022367 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.halation/effect.wgsl @@ -0,0 +1,8 @@ +struct Params { amount: f32, radius: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let tint = vec3(1.0, 0.35, 0.1); + let glow = halation(uv, c.rgb, 0.6, params.radius, tint, params.amount * 0.01 * 1.5); + return vec4(glow, c.a); +} diff --git a/src/crates/concat-effects/packages/concat.halation/ffmpeg.txt b/src/crates/concat-effects/packages/concat.halation/ffmpeg.txt new file mode 100644 index 00000000..5a64fc9c --- /dev/null +++ b/src/crates/concat-effects/packages/concat.halation/ffmpeg.txt @@ -0,0 +1 @@ +split[hla{index}][hlb{index}];[hlb{index}]gblur=sigma={r},colorbalance=rh=0.3:gh=0.05:bh=-0.2[hlg{index}];[hla{index}][hlg{index}]blend=all_mode=screen:all_opacity={a} diff --git a/src/crates/concat-effects/packages/concat.halation/fixtures.toml b/src/crates/concat-effects/packages/concat.halation/fixtures.toml new file mode 100644 index 00000000..f2fd597e --- /dev/null +++ b/src/crates/concat-effects/packages/concat.halation/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "split[hla0][hlb0];[hlb0]gblur=sigma=12,colorbalance=rh=0.3:gh=0.05:bh=-0.2[hlg0];[hla0][hlg0]blend=all_mode=screen:all_opacity=0.240" + +[[case]] +name = "min" +at = "min" +chain = "split[hla0][hlb0];[hlb0]gblur=sigma=2,colorbalance=rh=0.3:gh=0.05:bh=-0.2[hlg0];[hla0][hlg0]blend=all_mode=screen:all_opacity=0.000" + +[[case]] +name = "max" +at = "max" +chain = "split[hla0][hlb0];[hlb0]gblur=sigma=30,colorbalance=rh=0.3:gh=0.05:bh=-0.2[hlg0];[hla0][hlg0]blend=all_mode=screen:all_opacity=0.400" diff --git a/src/crates/concat-effects/packages/concat.halation/main.wgsl b/src/crates/concat-effects/packages/concat.halation/main.wgsl new file mode 100644 index 00000000..ca022367 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.halation/main.wgsl @@ -0,0 +1,8 @@ +struct Params { amount: f32, radius: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let tint = vec3(1.0, 0.35, 0.1); + let glow = halation(uv, c.rgb, 0.6, params.radius, tint, params.amount * 0.01 * 1.5); + return vec4(glow, c.a); +} diff --git a/src/crates/concat-effects/packages/concat.halftone/effect.toml b/src/crates/concat-effects/packages/concat.halftone/effect.toml new file mode 100644 index 00000000..bf5eafd4 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.halftone/effect.toml @@ -0,0 +1,24 @@ +[effect] +id = "concat.halftone" +name = "Halftone" +kind = "effect" +category = "Retro" +order = 76 +intensity = "size" +description = "The picture reduced to a grid of black and white dots." + +[[param]] +key = "size" +label = "Dot size" +type = "int" +min = 4 +max = 40 +default = 10 +step = 1 +unit = "px" + +[ffmpeg] +chain = "pixelize=w={round(size)}:h={round(size)},eq=contrast=1.5:brightness=-0.05" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.halftone/effect.wgsl b/src/crates/concat-effects/packages/concat.halftone/effect.wgsl new file mode 100644 index 00000000..c4f33d33 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.halftone/effect.wgsl @@ -0,0 +1,11 @@ +struct Params { size: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let cell = max(params.size, 2.0) * texel(); + let center = (floor(uv / cell) + vec2(0.5)) * cell; + let l = luma(sample(center).rgb); + let d = distance(uv, center) / (cell.x * 0.5 + 0.0001); + let dot = step(d, 1.0 - l); + return vec4(vec3(dot), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.halftone/fixtures.toml b/src/crates/concat-effects/packages/concat.halftone/fixtures.toml new file mode 100644 index 00000000..d417635f --- /dev/null +++ b/src/crates/concat-effects/packages/concat.halftone/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "pixelize=w=10:h=10,eq=contrast=1.5:brightness=-0.05" + +[[case]] +name = "min" +at = "min" +chain = "pixelize=w=4:h=4,eq=contrast=1.5:brightness=-0.05" + +[[case]] +name = "max" +at = "max" +chain = "pixelize=w=40:h=40,eq=contrast=1.5:brightness=-0.05" diff --git a/src/crates/concat-effects/packages/concat.high-contrast-mono/effect.toml b/src/crates/concat-effects/packages/concat.high-contrast-mono/effect.toml new file mode 100644 index 00000000..a1e37a58 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.high-contrast-mono/effect.toml @@ -0,0 +1,13 @@ +[effect] +id = "concat.high-contrast-mono" +name = "High-Contrast Mono" +kind = "filter" +category = "Black & White" +order = 64 +description = "Black and white pushed hard: deep blacks, bright whites, nothing in between." + +[ffmpeg] +chain = "hue=s=0,eq=contrast=1.6:brightness=-0.03" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.high-contrast-mono/effect.wgsl b/src/crates/concat-effects/packages/concat.high-contrast-mono/effect.wgsl new file mode 100644 index 00000000..4d8c1ca8 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.high-contrast-mono/effect.wgsl @@ -0,0 +1,5 @@ +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let g = mono(c.rgb, vec3(0.33)); + return vec4(clamp01(contrast(g, 1.6) - vec3(0.03)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.high-contrast-mono/fixtures.toml b/src/crates/concat-effects/packages/concat.high-contrast-mono/fixtures.toml new file mode 100644 index 00000000..3973c3d8 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.high-contrast-mono/fixtures.toml @@ -0,0 +1,3 @@ +[[case]] +name = "default" +chain = "hue=s=0,eq=contrast=1.6:brightness=-0.03" diff --git a/src/crates/concat-effects/packages/concat.impact-ring/effect.toml b/src/crates/concat-effects/packages/concat.impact-ring/effect.toml new file mode 100644 index 00000000..73a93c1b --- /dev/null +++ b/src/crates/concat-effects/packages/concat.impact-ring/effect.toml @@ -0,0 +1,22 @@ +[effect] +id = "concat.impact-ring" +name = "Impact Ring" +kind = "effect" +category = "Text" +order = 94 +description = "A ring pulses outward from the centre once per loop, a stamp-like entrance flourish." + +[[param]] +key = "period" +label = "Period" +min = 0.3 +max = 5 +default = 1.5 +step = 0.1 +unit = "s" + +[ffmpeg] +chain = "eq=contrast=1.12:brightness=0.03" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.impact-ring/effect.wgsl b/src/crates/concat-effects/packages/concat.impact-ring/effect.wgsl new file mode 100644 index 00000000..4f60bae0 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.impact-ring/effect.wgsl @@ -0,0 +1,12 @@ +struct Params { period: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let centre = vec2(0.5, 0.5); + let d = distance(uv, centre); + let t = fract(frame.time / max(params.period, 0.1)); + let radius = t * 0.9; + let ring = exp(-pow((d - radius) * 30.0, 2.0)) * (1.0 - t); + let glow = vec3(1.0, 0.92, 0.75) * ring; + return vec4(clamp01(c.rgb + glow), clamp(c.a + ring * 0.8, 0.0, 1.0)); +} diff --git a/src/crates/concat-effects/packages/concat.impact-ring/fixtures.toml b/src/crates/concat-effects/packages/concat.impact-ring/fixtures.toml new file mode 100644 index 00000000..a353b943 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.impact-ring/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "eq=contrast=1.12:brightness=0.03" + +[[case]] +name = "min" +at = "min" +chain = "eq=contrast=1.12:brightness=0.03" + +[[case]] +name = "max" +at = "max" +chain = "eq=contrast=1.12:brightness=0.03" diff --git a/src/crates/concat-effects/packages/concat.iris/effect.toml b/src/crates/concat-effects/packages/concat.iris/effect.toml new file mode 100644 index 00000000..8dc39ead --- /dev/null +++ b/src/crates/concat-effects/packages/concat.iris/effect.toml @@ -0,0 +1,21 @@ +[effect] +id = "concat.iris" +name = "Iris" +kind = "transition" +category = "Wipe" +order = 11 +description = "A circle opens from the centre to reveal the incoming shot." + +[[param]] +key = "softness" +label = "Softness" +min = 0 +max = 100 +default = 15 +step = 1 +unit = "%" + +[transition] +entry = "effect.wgsl" +xfade = "circleopen" +fallback = "cross-fade" diff --git a/src/crates/concat-effects/packages/concat.iris/effect.wgsl b/src/crates/concat-effects/packages/concat.iris/effect.wgsl new file mode 100644 index 00000000..45bd30e7 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.iris/effect.wgsl @@ -0,0 +1,10 @@ +struct Params { softness: f32 } + +fn transition(uv: vec2, progress: f32) -> vec4 { + let p = smoothstep(0.0, 1.0, progress); + let d = distance(uv, vec2(0.5)) / 0.7071068; + let radius = p * 1.15; + let soft = max(params.softness * 0.01, 0.02); + let m = smoothstep(radius - soft, radius + soft, d); + return mix(to_at(uv), from_at(uv), m); +} diff --git a/src/crates/concat-effects/packages/concat.kaleidoscope/effect.toml b/src/crates/concat-effects/packages/concat.kaleidoscope/effect.toml new file mode 100644 index 00000000..885cb3bd --- /dev/null +++ b/src/crates/concat-effects/packages/concat.kaleidoscope/effect.toml @@ -0,0 +1,23 @@ +[effect] +id = "concat.kaleidoscope" +name = "Kaleidoscope" +kind = "effect" +category = "Distort" +order = 80 +intensity = "segments" +description = "The picture folded into repeating mirrored wedges around its centre." + +[[param]] +key = "segments" +label = "Segments" +type = "int" +min = 3 +max = 12 +default = 6 +step = 1 + +[ffmpeg] +chain = "eq=saturation={fixed(1 + segments / 12 * 0.3, 3)}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.kaleidoscope/effect.wgsl b/src/crates/concat-effects/packages/concat.kaleidoscope/effect.wgsl new file mode 100644 index 00000000..e42ebc16 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.kaleidoscope/effect.wgsl @@ -0,0 +1,11 @@ +struct Params { segments: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = uv - vec2(0.5); + let r = length(c); + var a = atan2(c.y, c.x); + let seg = 6.28318530718 / max(params.segments, 3.0); + a = abs((a - seg * floor(a / seg)) - seg * 0.5); + let p = vec2(cos(a), sin(a)) * r + vec2(0.5); + return sample(p); +} diff --git a/src/crates/concat-effects/packages/concat.kaleidoscope/fixtures.toml b/src/crates/concat-effects/packages/concat.kaleidoscope/fixtures.toml new file mode 100644 index 00000000..7d7ac066 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.kaleidoscope/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "eq=saturation=1.150" + +[[case]] +name = "min" +at = "min" +chain = "eq=saturation=1.075" + +[[case]] +name = "max" +at = "max" +chain = "eq=saturation=1.300" diff --git a/src/crates/concat-effects/packages/concat.landscape-pop/effect.toml b/src/crates/concat-effects/packages/concat.landscape-pop/effect.toml new file mode 100644 index 00000000..83e90ae1 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.landscape-pop/effect.toml @@ -0,0 +1,13 @@ +[effect] +id = "concat.landscape-pop" +name = "Vivid Landscape" +kind = "filter" +category = "Scenery" +order = 57 +description = "Greens and blues pushed richer, the punch a travel shot wants." + +[ffmpeg] +chain = "selectivecolor=greens='0 0 0.15 0':cyans='0.1 0 0 0',eq=contrast=1.12:saturation=1.15" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.landscape-pop/effect.wgsl b/src/crates/concat-effects/packages/concat.landscape-pop/effect.wgsl new file mode 100644 index 00000000..760b4cbd --- /dev/null +++ b/src/crates/concat-effects/packages/concat.landscape-pop/effect.wgsl @@ -0,0 +1,6 @@ +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let g = hsl_band(c.rgb, 120.0, 60.0, 0.0, 1.35, 1.05); + let b = hsl_band(g, 210.0, 50.0, 0.0, 1.25, 1.0); + return vec4(clamp01(contrast(b, 1.12)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.landscape-pop/fixtures.toml b/src/crates/concat-effects/packages/concat.landscape-pop/fixtures.toml new file mode 100644 index 00000000..e6423951 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.landscape-pop/fixtures.toml @@ -0,0 +1,3 @@ +[[case]] +name = "default" +chain = "selectivecolor=greens='0 0 0.15 0':cyans='0.1 0 0 0',eq=contrast=1.12:saturation=1.15" diff --git a/src/crates/concat-effects/packages/concat.laser-beams/effect.toml b/src/crates/concat-effects/packages/concat.laser-beams/effect.toml new file mode 100644 index 00000000..aaba5f8a --- /dev/null +++ b/src/crates/concat-effects/packages/concat.laser-beams/effect.toml @@ -0,0 +1,35 @@ +[effect] +id = "concat.laser-beams" +name = "Laser Beams" +kind = "effect" +category = "Party & Club" +order = 15 +intensity = "strength" +description = "Glowing vector light streaks crossing the frame." + +[[param]] +key = "strength" +label = "Strength" +min = 0 +max = 100 +default = 80 +step = 1 +unit = "%" + +[[param]] +key = "speed" +label = "Speed" +min = 1 +max = 12 +default = 4 +step = 1 +unit = "Hz" + +[ffmpeg] +let = [ + "g = fixed(strength / 100 * 0.4, 3)", +] +chain = "colorbalance=gh={g}:bh={g}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.laser-beams/effect.wgsl b/src/crates/concat-effects/packages/concat.laser-beams/effect.wgsl new file mode 100644 index 00000000..a71c2c2a --- /dev/null +++ b/src/crates/concat-effects/packages/concat.laser-beams/effect.wgsl @@ -0,0 +1,12 @@ +struct Params { strength: f32, speed: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let t = frame.time * params.speed; + let beam1 = abs(uv.y - 0.5 - 0.35 * sin(t + uv.x * 2.0)); + let beam2 = abs(uv.y - 0.5 + 0.35 * cos(t * 1.3 - uv.x * 3.0)); + let laser1 = exp(-beam1 * 80.0) * vec3(0.1, 1.0, 0.4); + let laser2 = exp(-beam2 * 80.0) * vec3(1.0, 0.1, 0.6); + let lasers = (laser1 + laser2) * (params.strength * 0.01); + return vec4(clamp01(c.rgb + lasers), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.laser-beams/ffmpeg.txt b/src/crates/concat-effects/packages/concat.laser-beams/ffmpeg.txt new file mode 100644 index 00000000..1c042497 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.laser-beams/ffmpeg.txt @@ -0,0 +1 @@ +colorbalance=gh={g}:bh={g} diff --git a/src/crates/concat-effects/packages/concat.laser-beams/fixtures.toml b/src/crates/concat-effects/packages/concat.laser-beams/fixtures.toml new file mode 100644 index 00000000..e6eeb243 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.laser-beams/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "colorbalance=gh=0.320:bh=0.320" + +[[case]] +name = "min" +at = "min" +chain = "colorbalance=gh=0.000:bh=0.000" + +[[case]] +name = "max" +at = "max" +chain = "colorbalance=gh=0.400:bh=0.400" diff --git a/src/crates/concat-effects/packages/concat.laser-beams/main.wgsl b/src/crates/concat-effects/packages/concat.laser-beams/main.wgsl new file mode 100644 index 00000000..a71c2c2a --- /dev/null +++ b/src/crates/concat-effects/packages/concat.laser-beams/main.wgsl @@ -0,0 +1,12 @@ +struct Params { strength: f32, speed: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let t = frame.time * params.speed; + let beam1 = abs(uv.y - 0.5 - 0.35 * sin(t + uv.x * 2.0)); + let beam2 = abs(uv.y - 0.5 + 0.35 * cos(t * 1.3 - uv.x * 3.0)); + let laser1 = exp(-beam1 * 80.0) * vec3(0.1, 1.0, 0.4); + let laser2 = exp(-beam2 * 80.0) * vec3(1.0, 0.1, 0.6); + let lasers = (laser1 + laser2) * (params.strength * 0.01); + return vec4(clamp01(c.rgb + lasers), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.lens-flare/effect.toml b/src/crates/concat-effects/packages/concat.lens-flare/effect.toml new file mode 100644 index 00000000..cc5b363d --- /dev/null +++ b/src/crates/concat-effects/packages/concat.lens-flare/effect.toml @@ -0,0 +1,41 @@ +[effect] +id = "concat.lens-flare" +name = "Lens Flare" +kind = "effect" +category = "Light" +order = 71 +intensity = "amount" +description = "A bright hotspot with a string of echoes through the centre." + +[[param]] +key = "posx" +label = "Position X" +min = 0 +max = 100 +default = 70 +step = 1 +unit = "%" + +[[param]] +key = "posy" +label = "Position Y" +min = 0 +max = 100 +default = 30 +step = 1 +unit = "%" + +[[param]] +key = "amount" +label = "Amount" +min = 0 +max = 100 +default = 70 +step = 1 +unit = "%" + +[ffmpeg] +chain = "eq=brightness={fixed(amount / 100 * 0.08, 3)}:contrast={fixed(1 + amount / 100 * 0.1, 3)}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.lens-flare/effect.wgsl b/src/crates/concat-effects/packages/concat.lens-flare/effect.wgsl new file mode 100644 index 00000000..8bb2894f --- /dev/null +++ b/src/crates/concat-effects/packages/concat.lens-flare/effect.wgsl @@ -0,0 +1,27 @@ +struct Params { posx: f32, posy: f32, amount: f32 } + +// The hotspot gets a four-point sparkle star, and the echoes drift from +// warm to cool with distance and each carry a thin ring - the chromatic, +// glinting look a real lens's glass and aperture blades add, not just a +// row of plain dots fading down a line. +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let pos = vec2(params.posx, params.posy) * 0.01; + let d = distance(uv, pos); + let glow = exp(-d * d * 40.0) * 1.2; + let a = atan2(uv.y - pos.y, uv.x - pos.x); + let spikes = pow(abs(cos(a * 2.0)), 12.0) * exp(-d * 6.0); + var tint = vec3(1.0, 0.9, 0.75) * (glow + spikes * 0.6); + let dir = vec2(0.5) - pos; + for (var i: i32 = 1; i <= 4; i++) { + let t = f32(i) * 0.28; + let p = pos + dir * (1.0 + t); + let dd = distance(uv, p); + let echo = exp(-dd * dd * 300.0) * 0.35 / f32(i); + let ring = exp(-abs(dd - 0.02) * 80.0) * 0.12 / f32(i); + let hue = mix(vec3(1.0, 0.9, 0.75), vec3(0.6, 0.8, 1.0), f32(i) / 4.0); + tint += hue * (echo + ring); + } + let k = params.amount * 0.01; + return vec4(clamp01(c.rgb + tint * k), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.lens-flare/fixtures.toml b/src/crates/concat-effects/packages/concat.lens-flare/fixtures.toml new file mode 100644 index 00000000..ebc7a8ac --- /dev/null +++ b/src/crates/concat-effects/packages/concat.lens-flare/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "eq=brightness=0.056:contrast=1.070" + +[[case]] +name = "min" +at = "min" +chain = "eq=brightness=0.000:contrast=1.000" + +[[case]] +name = "max" +at = "max" +chain = "eq=brightness=0.080:contrast=1.100" diff --git a/src/crates/concat-effects/packages/concat.light-leak-flash/effect.toml b/src/crates/concat-effects/packages/concat.light-leak-flash/effect.toml new file mode 100644 index 00000000..26b579f3 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.light-leak-flash/effect.toml @@ -0,0 +1,21 @@ +[effect] +id = "concat.light-leak-flash" +name = "Light Leak Flash" +kind = "transition" +category = "Light" +order = 16 +description = "Organic overexposed film burn flash bridging the cut." + +[[param]] +key = "strength" +label = "Strength" +min = 0 +max = 100 +default = 80 +step = 1 +unit = "%" + +[transition] +entry = "effect.wgsl" +xfade = "fadewhite" +fallback = "cross-fade" diff --git a/src/crates/concat-effects/packages/concat.light-leak-flash/effect.wgsl b/src/crates/concat-effects/packages/concat.light-leak-flash/effect.wgsl new file mode 100644 index 00000000..59c85432 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.light-leak-flash/effect.wgsl @@ -0,0 +1,11 @@ +struct Params { strength: f32 } + +fn transition(uv: vec2, progress: f32) -> vec4 { + let base = mix(from_at(uv), to_at(uv), progress); + let flash_curve = 1.0 - abs(progress * 2.0 - 1.0); + let leak_pos = uv.x * 0.7 + uv.y * 0.3; + let leak = exp(-pow(leak_pos - progress, 2.0) * 12.0); + let burn = (flash_curve * 0.8 + leak * 0.5) * (params.strength * 0.01); + let warm_flash = vec3(1.0, 0.85, 0.6) * burn; + return vec4(clamp01(base.rgb + warm_flash), base.a); +} diff --git a/src/crates/concat-effects/packages/concat.light-leak-flash/ffmpeg.txt b/src/crates/concat-effects/packages/concat.light-leak-flash/ffmpeg.txt new file mode 100644 index 00000000..e2fea982 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.light-leak-flash/ffmpeg.txt @@ -0,0 +1 @@ +fadewhite diff --git a/src/crates/concat-effects/packages/concat.light-leak-flash/main.wgsl b/src/crates/concat-effects/packages/concat.light-leak-flash/main.wgsl new file mode 100644 index 00000000..59c85432 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.light-leak-flash/main.wgsl @@ -0,0 +1,11 @@ +struct Params { strength: f32 } + +fn transition(uv: vec2, progress: f32) -> vec4 { + let base = mix(from_at(uv), to_at(uv), progress); + let flash_curve = 1.0 - abs(progress * 2.0 - 1.0); + let leak_pos = uv.x * 0.7 + uv.y * 0.3; + let leak = exp(-pow(leak_pos - progress, 2.0) * 12.0); + let burn = (flash_curve * 0.8 + leak * 0.5) * (params.strength * 0.01); + let warm_flash = vec3(1.0, 0.85, 0.6) * burn; + return vec4(clamp01(base.rgb + warm_flash), base.a); +} diff --git a/src/crates/concat-effects/packages/concat.light-leak-transition/effect.toml b/src/crates/concat-effects/packages/concat.light-leak-transition/effect.toml new file mode 100644 index 00000000..bfe2eddf --- /dev/null +++ b/src/crates/concat-effects/packages/concat.light-leak-transition/effect.toml @@ -0,0 +1,21 @@ +[effect] +id = "concat.light-leak-transition" +name = "Light Leak" +kind = "transition" +category = "Light" +order = 13 +description = "A warm streak of light sweeps across the cut." + +[[param]] +key = "warmth" +label = "Warmth" +min = 0 +max = 100 +default = 70 +step = 1 +unit = "%" + +[transition] +entry = "effect.wgsl" +xfade = "fadewhite" +fallback = "cross-fade" diff --git a/src/crates/concat-effects/packages/concat.light-leak-transition/effect.wgsl b/src/crates/concat-effects/packages/concat.light-leak-transition/effect.wgsl new file mode 100644 index 00000000..7c218bb0 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.light-leak-transition/effect.wgsl @@ -0,0 +1,17 @@ +struct Params { warmth: f32 } + +// A bright near-white core inside a wider warm halo, rather than one flat +// tint - the hot centre a real light leak burns and the softer colour +// bleed around it. +fn transition(uv: vec2, progress: f32) -> vec4 { + let p = smoothstep(0.0, 1.0, progress); + let base = mix(from_at(uv), to_at(uv), p); + let sweep = uv.x + uv.y * 0.3 - (p * 2.2 - 0.6); + let core = exp(-sweep * sweep * 22.0) * 1.4; + let halo = exp(-sweep * sweep * 6.0) * 0.6; + let k = params.warmth * 0.01; + let hot = vec3(1.0, 0.95, 0.85); + let warm = vec3(1.0, 0.8, 0.5); + let leak = hot * core * k * 1.3 + warm * halo * k; + return vec4(clamp01(base.rgb + leak), base.a); +} diff --git a/src/crates/concat-effects/packages/concat.light-leak/effect.toml b/src/crates/concat-effects/packages/concat.light-leak/effect.toml new file mode 100644 index 00000000..e99913a3 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.light-leak/effect.toml @@ -0,0 +1,23 @@ +[effect] +id = "concat.light-leak" +name = "Light Leak" +kind = "effect" +category = "Light" +order = 70 +intensity = "amount" +description = "A warm streak of light drifts across the picture." + +[[param]] +key = "amount" +label = "Amount" +min = 0 +max = 100 +default = 60 +step = 1 +unit = "%" + +[ffmpeg] +chain = "colorbalance=rh={fixed(amount / 100 * 0.35, 3)}:gh={fixed(amount / 100 * 0.12, 3)}:bh={fixed(0 - amount / 100 * 0.25, 3)},eq=brightness={fixed(amount / 100 * 0.06, 3)}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.light-leak/effect.wgsl b/src/crates/concat-effects/packages/concat.light-leak/effect.wgsl new file mode 100644 index 00000000..5993390f --- /dev/null +++ b/src/crates/concat-effects/packages/concat.light-leak/effect.wgsl @@ -0,0 +1,19 @@ +struct Params { amount: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let sweep = uv.x + uv.y * 0.4 + sin(frame.time * 0.15) * 0.3 - 0.5; + let leak = exp(-sweep * sweep * 8.0) * 1.2; + let amber_tint = vec3(1.0, 0.72, 0.35); + + let edge_tl = min(uv.x, uv.y); + let pulse_amber = sin(frame.time * 1.6) * 0.3 + 0.7; + let amber_edge = exp(-edge_tl * 10.0) * pulse_amber * vec3(1.0, 0.55, 0.15); + + let edge_br = min(1.0 - uv.x, 1.0 - uv.y); + let pulse_cyan = cos(frame.time * 1.3) * 0.3 + 0.7; + let cyan_edge = exp(-edge_br * 12.0) * pulse_cyan * vec3(0.2, 0.85, 1.0); + + let total_burn = (amber_tint * leak + amber_edge + cyan_edge) * (params.amount * 0.01); + return vec4(clamp01(c.rgb + total_burn), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.light-leak/ffmpeg.txt b/src/crates/concat-effects/packages/concat.light-leak/ffmpeg.txt new file mode 100644 index 00000000..74b96c92 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.light-leak/ffmpeg.txt @@ -0,0 +1 @@ +ffmpeg-chain diff --git a/src/crates/concat-effects/packages/concat.light-leak/fixtures.toml b/src/crates/concat-effects/packages/concat.light-leak/fixtures.toml new file mode 100644 index 00000000..949647fe --- /dev/null +++ b/src/crates/concat-effects/packages/concat.light-leak/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "colorbalance=rh=0.210:gh=0.072:bh=-0.150,eq=brightness=0.036" + +[[case]] +name = "min" +at = "min" +chain = "colorbalance=rh=0.000:gh=0.000:bh=0.000,eq=brightness=0.000" + +[[case]] +name = "max" +at = "max" +chain = "colorbalance=rh=0.350:gh=0.120:bh=-0.250,eq=brightness=0.060" diff --git a/src/crates/concat-effects/packages/concat.light-leak/main.wgsl b/src/crates/concat-effects/packages/concat.light-leak/main.wgsl new file mode 100644 index 00000000..5993390f --- /dev/null +++ b/src/crates/concat-effects/packages/concat.light-leak/main.wgsl @@ -0,0 +1,19 @@ +struct Params { amount: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let sweep = uv.x + uv.y * 0.4 + sin(frame.time * 0.15) * 0.3 - 0.5; + let leak = exp(-sweep * sweep * 8.0) * 1.2; + let amber_tint = vec3(1.0, 0.72, 0.35); + + let edge_tl = min(uv.x, uv.y); + let pulse_amber = sin(frame.time * 1.6) * 0.3 + 0.7; + let amber_edge = exp(-edge_tl * 10.0) * pulse_amber * vec3(1.0, 0.55, 0.15); + + let edge_br = min(1.0 - uv.x, 1.0 - uv.y); + let pulse_cyan = cos(frame.time * 1.3) * 0.3 + 0.7; + let cyan_edge = exp(-edge_br * 12.0) * pulse_cyan * vec3(0.2, 0.85, 1.0); + + let total_burn = (amber_tint * leak + amber_edge + cyan_edge) * (params.amount * 0.01); + return vec4(clamp01(c.rgb + total_burn), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.linear-wipe/effect.toml b/src/crates/concat-effects/packages/concat.linear-wipe/effect.toml new file mode 100644 index 00000000..6f8a0f9a --- /dev/null +++ b/src/crates/concat-effects/packages/concat.linear-wipe/effect.toml @@ -0,0 +1,30 @@ +[effect] +id = "concat.linear-wipe" +name = "Linear Wipe" +kind = "transition" +category = "Wipe" +order = 10 +description = "A straight edge wipes the incoming shot across the outgoing one." + +[[param]] +key = "direction" +label = "Direction" +type = "enum" +min = 0 +max = 3 +default = 0 +values = [0, 1, 2, 3] +labels = ["Left to Right", "Right to Left", "Top to Bottom", "Bottom to Top"] + +[[param]] +key = "softness" +label = "Softness" +min = 0 +max = 100 +default = 20 +step = 1 +unit = "%" + +[transition] +entry = "effect.wgsl" +fallback = "cross-fade" diff --git a/src/crates/concat-effects/packages/concat.linear-wipe/effect.wgsl b/src/crates/concat-effects/packages/concat.linear-wipe/effect.wgsl new file mode 100644 index 00000000..e6baad1a --- /dev/null +++ b/src/crates/concat-effects/packages/concat.linear-wipe/effect.wgsl @@ -0,0 +1,18 @@ +struct Params { direction: f32, softness: f32 } + +fn transition(uv: vec2, progress: f32) -> vec4 { + let p = smoothstep(0.0, 1.0, progress); + var coord: f32; + if (params.direction < 0.5) { + coord = uv.x; + } else if (params.direction < 1.5) { + coord = 1.0 - uv.x; + } else if (params.direction < 2.5) { + coord = 1.0 - uv.y; + } else { + coord = uv.y; + } + let soft = max(params.softness * 0.003, 0.001); + let m = smoothstep(p - soft, p + soft, coord); + return mix(to_at(uv), from_at(uv), m); +} diff --git a/src/crates/concat-effects/packages/concat.megaphone/effect.toml b/src/crates/concat-effects/packages/concat.megaphone/effect.toml new file mode 100644 index 00000000..cd2a319e --- /dev/null +++ b/src/crates/concat-effects/packages/concat.megaphone/effect.toml @@ -0,0 +1,20 @@ +[effect] +id = "concat.megaphone" +name = "Megaphone" +kind = "audio" +category = "Voice" +order = 15 +intensity = "drive" +aliases = ["bullhorn", "loudspeaker"] +description = "Harsh, mid-focused projection from a handheld megaphone." + +[[param]] +key = "drive" +label = "Drive" +min = 1 +max = 10 +default = 5 +step = 0.5 + +[ffmpeg] +chain = "highpass=f=550,lowpass=f=2800,equalizer=f=1600:t=q:w=2.0:g={fixed(3 + drive * 0.8, 2)},alimiter=limit=0.88" diff --git a/src/crates/concat-effects/packages/concat.megaphone/fixtures.toml b/src/crates/concat-effects/packages/concat.megaphone/fixtures.toml new file mode 100644 index 00000000..66d9ed41 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.megaphone/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "highpass=f=550,lowpass=f=2800,equalizer=f=1600:t=q:w=2.0:g=7.00,alimiter=limit=0.88" + +[[case]] +name = "minimum" +at = "min" +chain = "highpass=f=550,lowpass=f=2800,equalizer=f=1600:t=q:w=2.0:g=3.80,alimiter=limit=0.88" + +[[case]] +name = "maximum" +at = "max" +chain = "highpass=f=550,lowpass=f=2800,equalizer=f=1600:t=q:w=2.0:g=11.00,alimiter=limit=0.88" diff --git a/src/crates/concat-effects/packages/concat.mirror-tile/effect.toml b/src/crates/concat-effects/packages/concat.mirror-tile/effect.toml new file mode 100644 index 00000000..8bbdbcb1 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.mirror-tile/effect.toml @@ -0,0 +1,26 @@ +[effect] +id = "concat.mirror-tile" +name = "Mirror Tile" +kind = "effect" +category = "Distortion & Glitch" +order = 13 +intensity = "tiles" +description = "Symmetrical 2x2 and 4x4 kaleidoscopic mirror reflections." + +[[param]] +key = "tiles" +label = "Tiles" +min = 2 +max = 4 +default = 2 +step = 2 +unit = "x" + +[ffmpeg] +let = [ + "s = fixed(1 + tiles / 4 * 0.2, 3)", +] +chain = "eq=saturation={s}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.mirror-tile/effect.wgsl b/src/crates/concat-effects/packages/concat.mirror-tile/effect.wgsl new file mode 100644 index 00000000..e9dd6eaf --- /dev/null +++ b/src/crates/concat-effects/packages/concat.mirror-tile/effect.wgsl @@ -0,0 +1,12 @@ +struct Params { tiles: f32 } + +fn effect(uv: vec2) -> vec4 { + let t = params.tiles; + let scaled = uv * t; + var p = fract(scaled); + let ix = i32(floor(scaled.x)); + let iy = i32(floor(scaled.y)); + if ((ix & 1) == 1) { p.x = 1.0 - p.x; } + if ((iy & 1) == 1) { p.y = 1.0 - p.y; } + return sample(p); +} diff --git a/src/crates/concat-effects/packages/concat.mirror-tile/ffmpeg.txt b/src/crates/concat-effects/packages/concat.mirror-tile/ffmpeg.txt new file mode 100644 index 00000000..1a4df913 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.mirror-tile/ffmpeg.txt @@ -0,0 +1 @@ +eq=saturation={s} diff --git a/src/crates/concat-effects/packages/concat.mirror-tile/fixtures.toml b/src/crates/concat-effects/packages/concat.mirror-tile/fixtures.toml new file mode 100644 index 00000000..9a0754d0 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.mirror-tile/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "eq=saturation=1.100" + +[[case]] +name = "min" +at = "min" +chain = "eq=saturation=1.100" + +[[case]] +name = "max" +at = "max" +chain = "eq=saturation=1.200" diff --git a/src/crates/concat-effects/packages/concat.mirror-tile/main.wgsl b/src/crates/concat-effects/packages/concat.mirror-tile/main.wgsl new file mode 100644 index 00000000..e9dd6eaf --- /dev/null +++ b/src/crates/concat-effects/packages/concat.mirror-tile/main.wgsl @@ -0,0 +1,12 @@ +struct Params { tiles: f32 } + +fn effect(uv: vec2) -> vec4 { + let t = params.tiles; + let scaled = uv * t; + var p = fract(scaled); + let ix = i32(floor(scaled.x)); + let iy = i32(floor(scaled.y)); + if ((ix & 1) == 1) { p.x = 1.0 - p.x; } + if ((iy & 1) == 1) { p.y = 1.0 - p.y; } + return sample(p); +} diff --git a/src/crates/concat-effects/packages/concat.natural/effect.toml b/src/crates/concat-effects/packages/concat.natural/effect.toml new file mode 100644 index 00000000..f77a35cc --- /dev/null +++ b/src/crates/concat-effects/packages/concat.natural/effect.toml @@ -0,0 +1,13 @@ +[effect] +id = "concat.natural" +name = "Natural" +kind = "filter" +category = "Portrait" +order = 55 +description = "A gentle, true-to-life lift: barely-there warmth, skin left alone." + +[ffmpeg] +chain = "colortemperature=temperature=5600:pl=1,eq=saturation=1.05:contrast=1.04" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.natural/effect.wgsl b/src/crates/concat-effects/packages/concat.natural/effect.wgsl new file mode 100644 index 00000000..e04ddda4 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.natural/effect.wgsl @@ -0,0 +1,5 @@ +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let wb = white_balance(c.rgb, 5600.0); + return vec4(clamp01(contrast(saturation(wb, 1.05), 1.04)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.natural/fixtures.toml b/src/crates/concat-effects/packages/concat.natural/fixtures.toml new file mode 100644 index 00000000..5c297f12 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.natural/fixtures.toml @@ -0,0 +1,3 @@ +[[case]] +name = "default" +chain = "colortemperature=temperature=5600:pl=1,eq=saturation=1.05:contrast=1.04" diff --git a/src/crates/concat-effects/packages/concat.neon-edges/effect.toml b/src/crates/concat-effects/packages/concat.neon-edges/effect.toml new file mode 100644 index 00000000..19c5e9a3 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.neon-edges/effect.toml @@ -0,0 +1,27 @@ +[effect] +id = "concat.neon-edges" +name = "Neon Edges" +kind = "effect" +category = "Stylize" +order = 83 +intensity = "amount" +description = "The picture's edges traced in drifting neon colour over black." + +[[param]] +key = "amount" +label = "Amount" +min = 0 +max = 100 +default = 70 +step = 1 +unit = "%" + +[ffmpeg] +let = [ + "high = 0.6 - amount / 100 * 0.3", + "low = high * 0.35", +] +chain = "edgedetect=mode=colormix:low={fixed(low, 3)}:high={fixed(high, 3)},eq=saturation={fixed(1 + amount / 100 * 0.8, 3)}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.neon-edges/effect.wgsl b/src/crates/concat-effects/packages/concat.neon-edges/effect.wgsl new file mode 100644 index 00000000..e9a298fd --- /dev/null +++ b/src/crates/concat-effects/packages/concat.neon-edges/effect.wgsl @@ -0,0 +1,9 @@ +struct Params { amount: f32 } + +fn effect(uv: vec2) -> vec4 { + let e = edge_at(uv); + let hue = fract(uv.x + uv.y + frame.time * 0.05); + let neon = hue_rotate(vec3(0.2, 1.0, 1.0), hue * 360.0); + let glow = e * params.amount * 0.01; + return vec4(clamp01(neon * glow), 1.0); +} diff --git a/src/crates/concat-effects/packages/concat.neon-edges/fixtures.toml b/src/crates/concat-effects/packages/concat.neon-edges/fixtures.toml new file mode 100644 index 00000000..501f4c66 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.neon-edges/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "edgedetect=mode=colormix:low=0.136:high=0.390,eq=saturation=1.560" + +[[case]] +name = "min" +at = "min" +chain = "edgedetect=mode=colormix:low=0.210:high=0.600,eq=saturation=1.000" + +[[case]] +name = "max" +at = "max" +chain = "edgedetect=mode=colormix:low=0.105:high=0.300,eq=saturation=1.800" diff --git a/src/crates/concat-effects/packages/concat.neon-flicker/effect.toml b/src/crates/concat-effects/packages/concat.neon-flicker/effect.toml new file mode 100644 index 00000000..34b603eb --- /dev/null +++ b/src/crates/concat-effects/packages/concat.neon-flicker/effect.toml @@ -0,0 +1,32 @@ +[effect] +id = "concat.neon-flicker" +name = "Neon Flicker" +kind = "effect" +category = "Text" +order = 95 +intensity = "rate" +description = "The picture flickers on and off like an unreliable neon sign." + +[[param]] +key = "rate" +label = "Rate" +min = 0.5 +max = 15 +default = 4 +step = 0.1 +unit = "Hz" + +[[param]] +key = "min_opacity" +label = "Minimum" +min = 0 +max = 100 +default = 15 +step = 1 +unit = "%" + +[ffmpeg] +chain = "eq=brightness=0.03:saturation={fixed(1 + min_opacity / 100 * 0.3, 3)}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.neon-flicker/effect.wgsl b/src/crates/concat-effects/packages/concat.neon-flicker/effect.wgsl new file mode 100644 index 00000000..a82d8bb7 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.neon-flicker/effect.wgsl @@ -0,0 +1,9 @@ +struct Params { rate: f32, min_opacity: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let step_t = floor(frame.time * max(params.rate, 0.5)); + let on = hash(vec2(step_t, 0.0), 1.0); + let flicker = mix(params.min_opacity * 0.01, 1.0, step(0.15, on)); + return vec4(c.rgb, c.a * flicker); +} diff --git a/src/crates/concat-effects/packages/concat.neon-flicker/fixtures.toml b/src/crates/concat-effects/packages/concat.neon-flicker/fixtures.toml new file mode 100644 index 00000000..8228d5eb --- /dev/null +++ b/src/crates/concat-effects/packages/concat.neon-flicker/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "eq=brightness=0.03:saturation=1.045" + +[[case]] +name = "min" +at = "min" +chain = "eq=brightness=0.03:saturation=1.000" + +[[case]] +name = "max" +at = "max" +chain = "eq=brightness=0.03:saturation=1.300" diff --git a/src/crates/concat-effects/packages/concat.neon-glow/effect.toml b/src/crates/concat-effects/packages/concat.neon-glow/effect.toml new file mode 100644 index 00000000..befcf071 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.neon-glow/effect.toml @@ -0,0 +1,35 @@ +[effect] +id = "concat.neon-glow" +name = "Neon Glow" +kind = "effect" +category = "Party & Club" +order = 16 +intensity = "glow" +description = "Vibrant edge-detection luminescence with electric hue cycling." + +[[param]] +key = "glow" +label = "Glow" +min = 0 +max = 100 +default = 75 +step = 1 +unit = "%" + +[[param]] +key = "speed" +label = "Speed" +min = 0.5 +max = 8 +default = 2 +step = 0.5 +unit = "Hz" + +[ffmpeg] +let = [ + "s = fixed(1 + glow / 100 * 2.0, 2)", +] +chain = "eq=saturation={s}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.neon-glow/effect.wgsl b/src/crates/concat-effects/packages/concat.neon-glow/effect.wgsl new file mode 100644 index 00000000..7f826be2 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.neon-glow/effect.wgsl @@ -0,0 +1,17 @@ +struct Params { glow: f32, speed: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let t = texel() * 2.0; + let c_r = sample(uv + vec2(t.x, 0.0)); + let c_u = sample(uv + vec2(0.0, t.y)); + let edge = length(c.rgb - c_r.rgb) + length(c.rgb - c_u.rgb); + let hue = fract(frame.time * params.speed * 0.1); + let neon_col = vec3( + 0.5 + 0.5 * sin(hue * 6.283), + 0.5 + 0.5 * sin(hue * 6.283 + 2.094), + 0.5 + 0.5 * sin(hue * 6.283 + 4.188) + ); + let glow_val = edge * (params.glow * 0.03) * neon_col; + return vec4(clamp01(c.rgb + glow_val), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.neon-glow/ffmpeg.txt b/src/crates/concat-effects/packages/concat.neon-glow/ffmpeg.txt new file mode 100644 index 00000000..1a4df913 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.neon-glow/ffmpeg.txt @@ -0,0 +1 @@ +eq=saturation={s} diff --git a/src/crates/concat-effects/packages/concat.neon-glow/fixtures.toml b/src/crates/concat-effects/packages/concat.neon-glow/fixtures.toml new file mode 100644 index 00000000..0cd92c93 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.neon-glow/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "eq=saturation=2.50" + +[[case]] +name = "min" +at = "min" +chain = "eq=saturation=1.00" + +[[case]] +name = "max" +at = "max" +chain = "eq=saturation=3.00" diff --git a/src/crates/concat-effects/packages/concat.neon-glow/main.wgsl b/src/crates/concat-effects/packages/concat.neon-glow/main.wgsl new file mode 100644 index 00000000..7f826be2 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.neon-glow/main.wgsl @@ -0,0 +1,17 @@ +struct Params { glow: f32, speed: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let t = texel() * 2.0; + let c_r = sample(uv + vec2(t.x, 0.0)); + let c_u = sample(uv + vec2(0.0, t.y)); + let edge = length(c.rgb - c_r.rgb) + length(c.rgb - c_u.rgb); + let hue = fract(frame.time * params.speed * 0.1); + let neon_col = vec3( + 0.5 + 0.5 * sin(hue * 6.283), + 0.5 + 0.5 * sin(hue * 6.283 + 2.094), + 0.5 + 0.5 * sin(hue * 6.283 + 4.188) + ); + let glow_val = edge * (params.glow * 0.03) * neon_col; + return vec4(clamp01(c.rgb + glow_val), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.noir/effect.toml b/src/crates/concat-effects/packages/concat.noir/effect.toml new file mode 100644 index 00000000..ad03af8b --- /dev/null +++ b/src/crates/concat-effects/packages/concat.noir/effect.toml @@ -0,0 +1,13 @@ +[effect] +id = "concat.noir" +name = "Noir" +kind = "filter" +category = "Cinematic" +order = 50 +description = "Desaturated and heavy, with a cold cast into the shadows and the corners dimmed." + +[ffmpeg] +chain = "hue=s=0,eq=contrast=1.3:brightness=-0.04,colorbalance=bs=0.06:bm=0.04,vignette=angle=0.6" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.noir/effect.wgsl b/src/crates/concat-effects/packages/concat.noir/effect.wgsl new file mode 100644 index 00000000..07430ec5 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.noir/effect.wgsl @@ -0,0 +1,7 @@ +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let g = mono(c.rgb, vec3(0.3, 0.3, 0.4)); + let toned = tint_midtones(g, vec3(-0.02, 0.0, 0.05), 1.0); + let v = vignette(toned, uv, 0.35); + return vec4(clamp01(contrast(v, 1.3)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.noir/fixtures.toml b/src/crates/concat-effects/packages/concat.noir/fixtures.toml new file mode 100644 index 00000000..5201bc5a --- /dev/null +++ b/src/crates/concat-effects/packages/concat.noir/fixtures.toml @@ -0,0 +1,3 @@ +[[case]] +name = "default" +chain = "hue=s=0,eq=contrast=1.3:brightness=-0.04,colorbalance=bs=0.06:bm=0.04,vignette=angle=0.6" diff --git a/src/crates/concat-effects/packages/concat.oil-paint/effect.toml b/src/crates/concat-effects/packages/concat.oil-paint/effect.toml new file mode 100644 index 00000000..b723fd7d --- /dev/null +++ b/src/crates/concat-effects/packages/concat.oil-paint/effect.toml @@ -0,0 +1,26 @@ +[effect] +id = "concat.oil-paint" +name = "Oil Paint" +kind = "effect" +category = "Stylize" +order = 81 +intensity = "amount" +description = "The picture simplified into soft, posterised brush-like strokes." + +[[param]] +key = "amount" +label = "Amount" +min = 0 +max = 100 +default = 60 +step = 1 +unit = "%" + +[ffmpeg] +let = [ + "r = round(1 + amount / 100 * 4)", +] +chain = "boxblur={r}:1,eq=contrast={fixed(1 + amount / 100 * 0.25, 3)}:saturation={fixed(1 + amount / 100 * 0.15, 3)}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.oil-paint/effect.wgsl b/src/crates/concat-effects/packages/concat.oil-paint/effect.wgsl new file mode 100644 index 00000000..fb320f98 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.oil-paint/effect.wgsl @@ -0,0 +1,8 @@ +struct Params { amount: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let s = soften(uv, 2.0 + params.amount * 0.04); + let posterized = floor(s * 6.0) / 6.0; + return vec4(clamp01(mix(c.rgb, posterized, params.amount * 0.01)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.oil-paint/fixtures.toml b/src/crates/concat-effects/packages/concat.oil-paint/fixtures.toml new file mode 100644 index 00000000..8f18296b --- /dev/null +++ b/src/crates/concat-effects/packages/concat.oil-paint/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "boxblur=3:1,eq=contrast=1.150:saturation=1.090" + +[[case]] +name = "min" +at = "min" +chain = "boxblur=1:1,eq=contrast=1.000:saturation=1.000" + +[[case]] +name = "max" +at = "max" +chain = "boxblur=5:1,eq=contrast=1.250:saturation=1.150" diff --git a/src/crates/concat-effects/packages/concat.prism-dispersion/effect.toml b/src/crates/concat-effects/packages/concat.prism-dispersion/effect.toml new file mode 100644 index 00000000..b9a8af35 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.prism-dispersion/effect.toml @@ -0,0 +1,26 @@ +[effect] +id = "concat.prism-dispersion" +name = "Prism Dispersion" +kind = "effect" +category = "Optical & Lens" +order = 12 +intensity = "strength" +description = "Radial chromatic dispersion refracting RGB channels." + +[[param]] +key = "strength" +label = "Strength" +min = 0 +max = 100 +default = 50 +step = 1 +unit = "%" + +[ffmpeg] +let = [ + "s = round(strength * 0.3)", +] +chain = "rgbashift=rh={s}:bv={s}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.prism-dispersion/effect.wgsl b/src/crates/concat-effects/packages/concat.prism-dispersion/effect.wgsl new file mode 100644 index 00000000..7c05d8ea --- /dev/null +++ b/src/crates/concat-effects/packages/concat.prism-dispersion/effect.wgsl @@ -0,0 +1,12 @@ +struct Params { strength: f32 } + +fn effect(uv: vec2) -> vec4 { + let center = vec2(0.5, 0.5); + let dir = uv - center; + let disp = dir * (params.strength * 0.0006); + let r = sample(uv + disp * 1.5).r; + let g = sample(uv).g; + let b = sample(uv - disp * 1.5).b; + let a = sample(uv).a; + return vec4(r, g, b, a); +} diff --git a/src/crates/concat-effects/packages/concat.prism-dispersion/ffmpeg.txt b/src/crates/concat-effects/packages/concat.prism-dispersion/ffmpeg.txt new file mode 100644 index 00000000..396c70d8 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.prism-dispersion/ffmpeg.txt @@ -0,0 +1 @@ +rgbashift=rh={s}:bv={s} diff --git a/src/crates/concat-effects/packages/concat.prism-dispersion/fixtures.toml b/src/crates/concat-effects/packages/concat.prism-dispersion/fixtures.toml new file mode 100644 index 00000000..d856e78a --- /dev/null +++ b/src/crates/concat-effects/packages/concat.prism-dispersion/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "rgbashift=rh=15:bv=15" + +[[case]] +name = "min" +at = "min" +chain = "rgbashift=rh=0:bv=0" + +[[case]] +name = "max" +at = "max" +chain = "rgbashift=rh=30:bv=30" diff --git a/src/crates/concat-effects/packages/concat.prism-dispersion/main.wgsl b/src/crates/concat-effects/packages/concat.prism-dispersion/main.wgsl new file mode 100644 index 00000000..7c05d8ea --- /dev/null +++ b/src/crates/concat-effects/packages/concat.prism-dispersion/main.wgsl @@ -0,0 +1,12 @@ +struct Params { strength: f32 } + +fn effect(uv: vec2) -> vec4 { + let center = vec2(0.5, 0.5); + let dir = uv - center; + let disp = dir * (params.strength * 0.0006); + let r = sample(uv + disp * 1.5).r; + let g = sample(uv).g; + let b = sample(uv - disp * 1.5).b; + let a = sample(uv).a; + return vec4(r, g, b, a); +} diff --git a/src/crates/concat-effects/packages/concat.push/effect.toml b/src/crates/concat-effects/packages/concat.push/effect.toml new file mode 100644 index 00000000..50de9652 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.push/effect.toml @@ -0,0 +1,21 @@ +[effect] +id = "concat.push" +name = "Push" +kind = "transition" +category = "Slide" +order = 5 +description = "The incoming shot pushes the outgoing one off the frame." + +[[param]] +key = "direction" +label = "Direction" +type = "enum" +min = 0 +max = 3 +default = 0 +values = [0, 1, 2, 3] +labels = ["Left", "Right", "Up", "Down"] + +[transition] +entry = "effect.wgsl" +fallback = "cross-fade" diff --git a/src/crates/concat-effects/packages/concat.push/effect.wgsl b/src/crates/concat-effects/packages/concat.push/effect.wgsl new file mode 100644 index 00000000..0998d605 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.push/effect.wgsl @@ -0,0 +1,25 @@ +struct Params { direction: f32 } + +fn axis(direction: f32) -> vec2 { + if (direction < 0.5) { + return vec2(1.0, 0.0); + } else if (direction < 1.5) { + return vec2(-1.0, 0.0); + } else if (direction < 2.5) { + return vec2(0.0, 1.0); + } + return vec2(0.0, -1.0); +} + +// Both layers move together along the same axis: the outgoing one is +// carried off as the incoming one is carried into place. +fn transition(uv: vec2, progress: f32) -> vec4 { + let p = smoothstep(0.0, 1.0, progress); + let dir = axis(params.direction); + let to_uv = uv - dir * (1.0 - p); + if (to_uv.x >= 0.0 && to_uv.x <= 1.0 && to_uv.y >= 0.0 && to_uv.y <= 1.0) { + return to_at(to_uv); + } + let from_uv = uv + dir * p; + return from_at(from_uv); +} diff --git a/src/crates/concat-effects/packages/concat.retro-70s/effect.toml b/src/crates/concat-effects/packages/concat.retro-70s/effect.toml new file mode 100644 index 00000000..08e4cd3b --- /dev/null +++ b/src/crates/concat-effects/packages/concat.retro-70s/effect.toml @@ -0,0 +1,13 @@ +[effect] +id = "concat.retro-70s" +name = "Retro 70s" +kind = "filter" +category = "Film" +order = 63 +description = "A heavy warm fade with a yellowed, sun-bleached cast." + +[ffmpeg] +chain = "curves=all='0/0.1 1/0.88',colorbalance=rm=0.1:gm=0.06:bm=-0.12,eq=saturation=0.85" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.retro-70s/effect.wgsl b/src/crates/concat-effects/packages/concat.retro-70s/effect.wgsl new file mode 100644 index 00000000..8e96d845 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.retro-70s/effect.wgsl @@ -0,0 +1,6 @@ +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let f = fade(c.rgb, 0.1); + let t = tint_midtones(f, vec3(0.1, 0.06, -0.12), 1.0); + return vec4(clamp01(saturation(t, 0.85)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.retro-70s/fixtures.toml b/src/crates/concat-effects/packages/concat.retro-70s/fixtures.toml new file mode 100644 index 00000000..0993cf86 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.retro-70s/fixtures.toml @@ -0,0 +1,3 @@ +[[case]] +name = "default" +chain = "curves=all='0/0.1 1/0.88',colorbalance=rm=0.1:gm=0.06:bm=-0.12,eq=saturation=0.85" diff --git a/src/crates/concat-effects/packages/concat.rgb-glitch/effect.toml b/src/crates/concat-effects/packages/concat.rgb-glitch/effect.toml new file mode 100644 index 00000000..88e57a46 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.rgb-glitch/effect.toml @@ -0,0 +1,35 @@ +[effect] +id = "concat.rgb-glitch" +name = "RGB Glitch" +kind = "effect" +category = "Distortion & Glitch" +order = 2 +intensity = "amount" +description = "Periodic horizontal slice displacement with chromatic aberration." + +[[param]] +key = "amount" +label = "Amount" +min = 0 +max = 100 +default = 50 +step = 1 +unit = "%" + +[[param]] +key = "frequency" +label = "Frequency" +min = 1 +max = 30 +default = 8 +step = 1 +unit = "Hz" + +[ffmpeg] +let = [ + "s = round(amount * 0.2)", +] +chain = "rgbashift=rh={s}:bv=-{s}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.rgb-glitch/effect.wgsl b/src/crates/concat-effects/packages/concat.rgb-glitch/effect.wgsl new file mode 100644 index 00000000..87607b25 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.rgb-glitch/effect.wgsl @@ -0,0 +1,14 @@ +struct Params { amount: f32, frequency: f32 } + +fn effect(uv: vec2) -> vec4 { + let t = floor(frame.time * params.frequency); + let slice_y = floor(uv.y * 15.0); + let slice_noise = hash(vec2(slice_y, t), 1.0); + let is_glitch = step(0.65, slice_noise); + let shift = (slice_noise - 0.5) * (params.amount * 0.001) * is_glitch; + let r = sample(uv + vec2(shift * 2.0, 0.0)).r; + let g = sample(uv + vec2(shift, 0.0)).g; + let b = sample(uv - vec2(shift * 1.5, 0.0)).b; + let a = sample(uv).a; + return vec4(r, g, b, a); +} diff --git a/src/crates/concat-effects/packages/concat.rgb-glitch/ffmpeg.txt b/src/crates/concat-effects/packages/concat.rgb-glitch/ffmpeg.txt new file mode 100644 index 00000000..7b4decc2 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.rgb-glitch/ffmpeg.txt @@ -0,0 +1 @@ +rgbashift=rh={s}:bv=-{s} diff --git a/src/crates/concat-effects/packages/concat.rgb-glitch/fixtures.toml b/src/crates/concat-effects/packages/concat.rgb-glitch/fixtures.toml new file mode 100644 index 00000000..f5b2d916 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.rgb-glitch/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "rgbashift=rh=10:bv=-10" + +[[case]] +name = "min" +at = "min" +chain = "rgbashift=rh=0:bv=-0" + +[[case]] +name = "max" +at = "max" +chain = "rgbashift=rh=20:bv=-20" diff --git a/src/crates/concat-effects/packages/concat.rgb-glitch/main.wgsl b/src/crates/concat-effects/packages/concat.rgb-glitch/main.wgsl new file mode 100644 index 00000000..87607b25 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.rgb-glitch/main.wgsl @@ -0,0 +1,14 @@ +struct Params { amount: f32, frequency: f32 } + +fn effect(uv: vec2) -> vec4 { + let t = floor(frame.time * params.frequency); + let slice_y = floor(uv.y * 15.0); + let slice_noise = hash(vec2(slice_y, t), 1.0); + let is_glitch = step(0.65, slice_noise); + let shift = (slice_noise - 0.5) * (params.amount * 0.001) * is_glitch; + let r = sample(uv + vec2(shift * 2.0, 0.0)).r; + let g = sample(uv + vec2(shift, 0.0)).g; + let b = sample(uv - vec2(shift * 1.5, 0.0)).b; + let a = sample(uv).a; + return vec4(r, g, b, a); +} diff --git a/src/crates/concat-effects/packages/concat.rgb-split-wipe/effect.toml b/src/crates/concat-effects/packages/concat.rgb-split-wipe/effect.toml new file mode 100644 index 00000000..3d8cd4d8 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.rgb-split-wipe/effect.toml @@ -0,0 +1,20 @@ +[effect] +id = "concat.rgb-split-wipe" +name = "RGB Split" +kind = "transition" +category = "Glitch" +order = 9 +description = "The colour channels pull apart through the cut and snap back together." + +[[param]] +key = "spread" +label = "Spread" +min = 0 +max = 100 +default = 60 +step = 1 +unit = "%" + +[transition] +entry = "effect.wgsl" +fallback = "cross-fade" diff --git a/src/crates/concat-effects/packages/concat.rgb-split-wipe/effect.wgsl b/src/crates/concat-effects/packages/concat.rgb-split-wipe/effect.wgsl new file mode 100644 index 00000000..0eab8194 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.rgb-split-wipe/effect.wgsl @@ -0,0 +1,20 @@ +struct Params { spread: f32 } + +// The red and blue channels pull apart from green, peaking at the middle of +// the cut, on both the outgoing and incoming pictures. +fn transition(uv: vec2, progress: f32) -> vec4 { + let k = (1.0 - abs(progress * 2.0 - 1.0)) * params.spread * 0.0004; + let a = vec4( + from_at(vec2(uv.x + k, uv.y)).r, + from_at(uv).g, + from_at(vec2(uv.x - k, uv.y)).b, + 1.0, + ); + let b = vec4( + to_at(vec2(uv.x - k, uv.y)).r, + to_at(uv).g, + to_at(vec2(uv.x + k, uv.y)).b, + 1.0, + ); + return mix(a, b, smoothstep(0.0, 1.0, progress)); +} diff --git a/src/crates/concat-effects/packages/concat.rgb-split/effect.toml b/src/crates/concat-effects/packages/concat.rgb-split/effect.toml new file mode 100644 index 00000000..70aa340a --- /dev/null +++ b/src/crates/concat-effects/packages/concat.rgb-split/effect.toml @@ -0,0 +1,24 @@ +[effect] +id = "concat.rgb-split" +name = "RGB Split" +kind = "effect" +category = "Retro" +order = 74 +intensity = "shift" +description = "The colour channels pulled apart into a bold stylised fringe." + +[[param]] +key = "shift" +label = "Shift" +type = "int" +min = 1 +max = 40 +default = 10 +step = 1 +unit = "px" + +[ffmpeg] +chain = "rgbashift=rh={round(shift)}:bh=-{round(shift)}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.rgb-split/effect.wgsl b/src/crates/concat-effects/packages/concat.rgb-split/effect.wgsl new file mode 100644 index 00000000..fb51ac8c --- /dev/null +++ b/src/crates/concat-effects/packages/concat.rgb-split/effect.wgsl @@ -0,0 +1,9 @@ +struct Params { shift: f32 } + +fn effect(uv: vec2) -> vec4 { + let t = texel() * params.shift; + let r = sample(uv + vec2(t.x, 0.0)).r; + let g = sample(uv).g; + let b = sample(uv - vec2(t.x, 0.0)).b; + return vec4(r, g, b, sample(uv).a); +} diff --git a/src/crates/concat-effects/packages/concat.rgb-split/fixtures.toml b/src/crates/concat-effects/packages/concat.rgb-split/fixtures.toml new file mode 100644 index 00000000..a550d631 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.rgb-split/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "rgbashift=rh=10:bh=-10" + +[[case]] +name = "min" +at = "min" +chain = "rgbashift=rh=1:bh=-1" + +[[case]] +name = "max" +at = "max" +chain = "rgbashift=rh=40:bh=-40" diff --git a/src/crates/concat-effects/packages/concat.ripple/effect.toml b/src/crates/concat-effects/packages/concat.ripple/effect.toml new file mode 100644 index 00000000..b1c0f3ca --- /dev/null +++ b/src/crates/concat-effects/packages/concat.ripple/effect.toml @@ -0,0 +1,35 @@ +[effect] +id = "concat.ripple" +name = "Ripple" +kind = "effect" +category = "Distort" +order = 77 +intensity = "amount" +description = "Rings of water ripple outward from the centre of the picture." + +[[param]] +key = "amount" +label = "Amount" +min = 0 +max = 100 +default = 50 +step = 1 +unit = "%" + +[[param]] +key = "speed" +label = "Speed" +min = 0.5 +max = 6 +default = 3 +step = 0.1 + +[ffmpeg] +let = [ + "amp = fixed(amount / 100 * 8, 2)", + "spd = fixed(speed, 2)", +] +chain = "geq=r='r(X+{amp}*sin(Y/10+T*{spd}),Y+{amp}*sin(X/10+T*{spd}))':g='g(X+{amp}*sin(Y/10+T*{spd}),Y+{amp}*sin(X/10+T*{spd}))':b='b(X+{amp}*sin(Y/10+T*{spd}),Y+{amp}*sin(X/10+T*{spd}))'" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.ripple/effect.wgsl b/src/crates/concat-effects/packages/concat.ripple/effect.wgsl new file mode 100644 index 00000000..f93f9f43 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.ripple/effect.wgsl @@ -0,0 +1,9 @@ +struct Params { amount: f32, speed: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = uv - vec2(0.5); + let d = length(c); + let wave = sin(d * 40.0 - frame.time * params.speed) * params.amount * 0.001; + let dir = c / max(d, 0.0001); + return sample(uv + dir * wave); +} diff --git a/src/crates/concat-effects/packages/concat.ripple/fixtures.toml b/src/crates/concat-effects/packages/concat.ripple/fixtures.toml new file mode 100644 index 00000000..12599f23 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.ripple/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "geq=r='r(X+4.00*sin(Y/10+T*3.00),Y+4.00*sin(X/10+T*3.00))':g='g(X+4.00*sin(Y/10+T*3.00),Y+4.00*sin(X/10+T*3.00))':b='b(X+4.00*sin(Y/10+T*3.00),Y+4.00*sin(X/10+T*3.00))'" + +[[case]] +name = "min" +at = "min" +chain = "geq=r='r(X+0.00*sin(Y/10+T*0.50),Y+0.00*sin(X/10+T*0.50))':g='g(X+0.00*sin(Y/10+T*0.50),Y+0.00*sin(X/10+T*0.50))':b='b(X+0.00*sin(Y/10+T*0.50),Y+0.00*sin(X/10+T*0.50))'" + +[[case]] +name = "max" +at = "max" +chain = "geq=r='r(X+8.00*sin(Y/10+T*6.00),Y+8.00*sin(X/10+T*6.00))':g='g(X+8.00*sin(Y/10+T*6.00),Y+8.00*sin(X/10+T*6.00))':b='b(X+8.00*sin(Y/10+T*6.00),Y+8.00*sin(X/10+T*6.00))'" diff --git a/src/crates/concat-effects/packages/concat.rosy/effect.toml b/src/crates/concat-effects/packages/concat.rosy/effect.toml new file mode 100644 index 00000000..75900bd9 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.rosy/effect.toml @@ -0,0 +1,13 @@ +[effect] +id = "concat.rosy" +name = "Rosy" +kind = "filter" +category = "Portrait" +order = 56 +description = "A soft pink lift into the highlights, flattering and warm." + +[ffmpeg] +chain = "colorbalance=rh=0.08:gh=0.02:bh=0.03,eq=saturation=1.08" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.rosy/effect.wgsl b/src/crates/concat-effects/packages/concat.rosy/effect.wgsl new file mode 100644 index 00000000..2e3af568 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.rosy/effect.wgsl @@ -0,0 +1,5 @@ +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let t = split_tone(c.rgb, vec3(0.0), vec3(0.08, 0.02, 0.03), 1.0); + return vec4(clamp01(saturation(t, 1.08)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.rosy/fixtures.toml b/src/crates/concat-effects/packages/concat.rosy/fixtures.toml new file mode 100644 index 00000000..fc1bfca3 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.rosy/fixtures.toml @@ -0,0 +1,3 @@ +[[case]] +name = "default" +chain = "colorbalance=rh=0.08:gh=0.02:bh=0.03,eq=saturation=1.08" diff --git a/src/crates/concat-effects/packages/concat.sci-fi/effect.toml b/src/crates/concat-effects/packages/concat.sci-fi/effect.toml new file mode 100644 index 00000000..6c564d33 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.sci-fi/effect.toml @@ -0,0 +1,13 @@ +[effect] +id = "concat.sci-fi" +name = "Sci-Fi" +kind = "filter" +category = "Cinematic" +order = 53 +description = "A cool, sterile steel-and-teal grade, saturation held back." + +[ffmpeg] +chain = "colortemperature=temperature=8200:pl=1,colorbalance=bs=0.06:bh=0.04,eq=saturation=0.85" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.sci-fi/effect.wgsl b/src/crates/concat-effects/packages/concat.sci-fi/effect.wgsl new file mode 100644 index 00000000..bd942ee0 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.sci-fi/effect.wgsl @@ -0,0 +1,6 @@ +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let wb = white_balance(c.rgb, 8200.0); + let t = split_tone(wb, vec3(-0.02, 0.03, 0.06), vec3(0.0, 0.02, 0.03), 1.0); + return vec4(clamp01(saturation(t, 0.85)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.sci-fi/fixtures.toml b/src/crates/concat-effects/packages/concat.sci-fi/fixtures.toml new file mode 100644 index 00000000..b6226359 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.sci-fi/fixtures.toml @@ -0,0 +1,3 @@ +[[case]] +name = "default" +chain = "colortemperature=temperature=8200:pl=1,colorbalance=bs=0.06:bh=0.04,eq=saturation=0.85" diff --git a/src/crates/concat-effects/packages/concat.scroll-ticker/effect.toml b/src/crates/concat-effects/packages/concat.scroll-ticker/effect.toml new file mode 100644 index 00000000..e437f42a --- /dev/null +++ b/src/crates/concat-effects/packages/concat.scroll-ticker/effect.toml @@ -0,0 +1,35 @@ +[effect] +id = "concat.scroll-ticker" +name = "Scroll Ticker" +kind = "effect" +category = "Text" +order = 96 +intensity = "speed" +description = "The picture scrolls sideways and wraps around, like a news ticker." + +[[param]] +key = "speed" +label = "Speed" +min = 1 +max = 100 +default = 30 +step = 1 + +[[param]] +key = "direction" +label = "Direction" +type = "enum" +min = 0 +max = 1 +default = 0 +values = [0, 1] +labels = ["Left", "Right"] + +[ffmpeg] +let = [ + "px = fixed(if(direction < 0.5, speed, 0 - speed) * 0.3, 2)", +] +chain = "format=rgba,geq=r='r(mod(X+T*{px}+W*1000,W),Y)':g='g(mod(X+T*{px}+W*1000,W),Y)':b='b(mod(X+T*{px}+W*1000,W),Y)':a='alpha(mod(X+T*{px}+W*1000,W),Y)'" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.scroll-ticker/effect.wgsl b/src/crates/concat-effects/packages/concat.scroll-ticker/effect.wgsl new file mode 100644 index 00000000..ec471929 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.scroll-ticker/effect.wgsl @@ -0,0 +1,7 @@ +struct Params { speed: f32, direction: f32 } + +fn effect(uv: vec2) -> vec4 { + let dir = mix(1.0, -1.0, step(0.5, params.direction)); + let scrolled = fract(uv.x + frame.time * params.speed * 0.1 * dir); + return sample(vec2(scrolled, uv.y)); +} diff --git a/src/crates/concat-effects/packages/concat.scroll-ticker/fixtures.toml b/src/crates/concat-effects/packages/concat.scroll-ticker/fixtures.toml new file mode 100644 index 00000000..23519832 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.scroll-ticker/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "format=rgba,geq=r='r(mod(X+T*9.00+W*1000,W),Y)':g='g(mod(X+T*9.00+W*1000,W),Y)':b='b(mod(X+T*9.00+W*1000,W),Y)':a='alpha(mod(X+T*9.00+W*1000,W),Y)'" + +[[case]] +name = "min" +at = "min" +chain = "format=rgba,geq=r='r(mod(X+T*0.30+W*1000,W),Y)':g='g(mod(X+T*0.30+W*1000,W),Y)':b='b(mod(X+T*0.30+W*1000,W),Y)':a='alpha(mod(X+T*0.30+W*1000,W),Y)'" + +[[case]] +name = "max" +at = "max" +chain = "format=rgba,geq=r='r(mod(X+T*-30.00+W*1000,W),Y)':g='g(mod(X+T*-30.00+W*1000,W),Y)':b='b(mod(X+T*-30.00+W*1000,W),Y)':a='alpha(mod(X+T*-30.00+W*1000,W),Y)'" diff --git a/src/crates/concat-effects/packages/concat.shape-wipe/effect.toml b/src/crates/concat-effects/packages/concat.shape-wipe/effect.toml new file mode 100644 index 00000000..ab3fa91e --- /dev/null +++ b/src/crates/concat-effects/packages/concat.shape-wipe/effect.toml @@ -0,0 +1,30 @@ +[effect] +id = "concat.shape-wipe" +name = "Shape Wipe" +kind = "transition" +category = "Wipe" +order = 12 +description = "The incoming shot grows through a shape from the centre of the cut." + +[[param]] +key = "shape" +label = "Shape" +type = "enum" +min = 0 +max = 2 +default = 0 +values = [0, 1, 2] +labels = ["Circle", "Heart", "Star"] + +[[param]] +key = "softness" +label = "Softness" +min = 0 +max = 100 +default = 15 +step = 1 +unit = "%" + +[transition] +entry = "effect.wgsl" +fallback = "cross-fade" diff --git a/src/crates/concat-effects/packages/concat.shape-wipe/effect.wgsl b/src/crates/concat-effects/packages/concat.shape-wipe/effect.wgsl new file mode 100644 index 00000000..1a9d4a85 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.shape-wipe/effect.wgsl @@ -0,0 +1,40 @@ +struct Params { shape: f32, softness: f32 } + +// A parametric heart curve: r(theta) = sin(theta)*sqrt(|cos theta|)/(sin theta + 1.4), +// the denominator never nearing zero. +fn heart_d(p: vec2) -> f32 { + let x = p.x; + let y = -p.y - 0.1; + let r = length(vec2(x, y)); + let theta = atan2(x, y); + let boundary = 0.55 * (sin(theta) * sqrt(abs(cos(theta))) / (sin(theta) + 1.4) + 1.1); + return r - boundary; +} + +// A five-point shape by snapping the radius toward the nearest of five +// symmetric angles. +fn star_d(p: vec2) -> f32 { + let r = length(p); + let a = atan2(p.y, p.x); + let points = 5.0; + let seg = 6.28318530718 / points; + let m = cos(floor(a / seg + 0.5) * seg - a) * 0.35 + 0.65; + return r - 0.75 * m; +} + +fn transition(uv: vec2, progress: f32) -> vec4 { + let p = smoothstep(0.0, 1.0, progress); + let c = (uv - vec2(0.5)) * 2.0; + var d: f32; + if (params.shape < 0.5) { + d = length(c); + } else if (params.shape < 1.5) { + d = heart_d(c); + } else { + d = star_d(c); + } + let threshold = p * 3.0 - 1.0; + let soft = max(params.softness * 0.01, 0.02) * 1.5; + let m = smoothstep(threshold - soft, threshold + soft, d); + return mix(to_at(uv), from_at(uv), m); +} diff --git a/src/crates/concat-effects/packages/concat.shine-sweep/effect.toml b/src/crates/concat-effects/packages/concat.shine-sweep/effect.toml new file mode 100644 index 00000000..61c04c38 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.shine-sweep/effect.toml @@ -0,0 +1,40 @@ +[effect] +id = "concat.shine-sweep" +name = "Shine Sweep" +kind = "effect" +category = "Text" +order = 90 +intensity = "speed" +description = "A specular band sweeps diagonally across the picture on a loop." + +[[param]] +key = "speed" +label = "Speed" +min = 0.1 +max = 3 +default = 1 +step = 0.1 + +[[param]] +key = "width" +label = "Width" +min = 1 +max = 100 +default = 30 +step = 1 +unit = "%" + +[[param]] +key = "angle" +label = "Angle" +min = -90 +max = 90 +default = 25 +step = 1 +unit = "°" + +[ffmpeg] +chain = "eq=contrast={fixed(1 + width / 100 * 0.15, 3)}:brightness={fixed(width / 100 * 0.03, 3)}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.shine-sweep/effect.wgsl b/src/crates/concat-effects/packages/concat.shine-sweep/effect.wgsl new file mode 100644 index 00000000..232341dd --- /dev/null +++ b/src/crates/concat-effects/packages/concat.shine-sweep/effect.wgsl @@ -0,0 +1,15 @@ +struct Params { speed: f32, width: f32, angle: f32 } + +// A soft band travels along `angle` on a continuous loop; wherever the +// layer is transparent it stays transparent, so the sweep only ever shows +// through actual ink. +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let a = radians(params.angle); + let axis = uv.x * cos(a) + uv.y * sin(a); + let pos = fract(frame.time * params.speed * 0.2); + let d = abs(fract(axis - pos + 0.5) - 0.5); + let band = smoothstep(params.width * 0.02, 0.0, d); + let shine = band * c.a; + return vec4(clamp01(c.rgb + vec3(shine)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.shine-sweep/fixtures.toml b/src/crates/concat-effects/packages/concat.shine-sweep/fixtures.toml new file mode 100644 index 00000000..ce95f7c0 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.shine-sweep/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "eq=contrast=1.045:brightness=0.009" + +[[case]] +name = "min" +at = "min" +chain = "eq=contrast=1.002:brightness=0.000" + +[[case]] +name = "max" +at = "max" +chain = "eq=contrast=1.150:brightness=0.030" diff --git a/src/crates/concat-effects/packages/concat.silver/effect.toml b/src/crates/concat-effects/packages/concat.silver/effect.toml new file mode 100644 index 00000000..78af5013 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.silver/effect.toml @@ -0,0 +1,13 @@ +[effect] +id = "concat.silver" +name = "Silver" +kind = "filter" +category = "Black & White" +order = 65 +description = "A cool, soft black and white with the shadows lifted, like a silver print." + +[ffmpeg] +chain = "hue=s=0,curves=all='0/0.05 1/0.95',colorbalance=bs=0.05:bh=0.03" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.silver/effect.wgsl b/src/crates/concat-effects/packages/concat.silver/effect.wgsl new file mode 100644 index 00000000..fda6a30b --- /dev/null +++ b/src/crates/concat-effects/packages/concat.silver/effect.wgsl @@ -0,0 +1,7 @@ +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let g = mono(c.rgb, vec3(0.3, 0.33, 0.37)); + let f = fade(g, 0.05); + let t = tint_midtones(f, vec3(-0.02, 0.0, 0.03), 1.0); + return vec4(clamp01(t), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.silver/fixtures.toml b/src/crates/concat-effects/packages/concat.silver/fixtures.toml new file mode 100644 index 00000000..1385700c --- /dev/null +++ b/src/crates/concat-effects/packages/concat.silver/fixtures.toml @@ -0,0 +1,3 @@ +[[case]] +name = "default" +chain = "hue=s=0,curves=all='0/0.05 1/0.95',colorbalance=bs=0.05:bh=0.03" diff --git a/src/crates/concat-effects/packages/concat.slide/effect.toml b/src/crates/concat-effects/packages/concat.slide/effect.toml new file mode 100644 index 00000000..5635905b --- /dev/null +++ b/src/crates/concat-effects/packages/concat.slide/effect.toml @@ -0,0 +1,21 @@ +[effect] +id = "concat.slide" +name = "Slide" +kind = "transition" +category = "Slide" +order = 4 +description = "The incoming shot slides in over the outgoing one, which holds still underneath." + +[[param]] +key = "direction" +label = "Direction" +type = "enum" +min = 0 +max = 3 +default = 0 +values = [0, 1, 2, 3] +labels = ["Left", "Right", "Up", "Down"] + +[transition] +entry = "effect.wgsl" +fallback = "cross-fade" diff --git a/src/crates/concat-effects/packages/concat.slide/effect.wgsl b/src/crates/concat-effects/packages/concat.slide/effect.wgsl new file mode 100644 index 00000000..213c5043 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.slide/effect.wgsl @@ -0,0 +1,23 @@ +struct Params { direction: f32 } + +// The incoming layer's on-screen offset: it starts fully off that edge and +// ends at rest. Wherever it has not yet reached, the outgoing layer shows +// through untouched underneath. +fn transition(uv: vec2, progress: f32) -> vec4 { + let p = smoothstep(0.0, 1.0, progress); + var d: vec2; + if (params.direction < 0.5) { + d = vec2(-(1.0 - p), 0.0); + } else if (params.direction < 1.5) { + d = vec2(1.0 - p, 0.0); + } else if (params.direction < 2.5) { + d = vec2(0.0, -(1.0 - p)); + } else { + d = vec2(0.0, 1.0 - p); + } + let coord = uv - d; + if (coord.x < 0.0 || coord.x > 1.0 || coord.y < 0.0 || coord.y > 1.0) { + return from_at(uv); + } + return to_at(coord); +} diff --git a/src/crates/concat-effects/packages/concat.snow/effect.toml b/src/crates/concat-effects/packages/concat.snow/effect.toml new file mode 100644 index 00000000..d96885ce --- /dev/null +++ b/src/crates/concat-effects/packages/concat.snow/effect.toml @@ -0,0 +1,31 @@ +[effect] +id = "concat.snow" +name = "Snow" +kind = "effect" +category = "Atmosphere" +order = 85 +intensity = "density" +description = "Flakes of snow drift down over the picture." + +[[param]] +key = "density" +label = "Density" +min = 0 +max = 100 +default = 50 +step = 1 +unit = "%" + +[[param]] +key = "speed" +label = "Speed" +min = 0.2 +max = 3 +default = 1 +step = 0.1 + +[ffmpeg] +chain = "noise=alls={round(density / 100 * 30)}:allf=t,eq=brightness={fixed(density / 100 * 0.03, 3)}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.snow/effect.wgsl b/src/crates/concat-effects/packages/concat.snow/effect.wgsl new file mode 100644 index 00000000..34ca352e --- /dev/null +++ b/src/crates/concat-effects/packages/concat.snow/effect.wgsl @@ -0,0 +1,26 @@ +struct Params { density: f32, speed: f32 } + +// Flakes carry a depth: nearer ones fall faster, sway wider, glow bigger and +// brighter, and read warmer-white; farther ones are small, dim, cool-tinted +// pinpricks - the parallax a real snowfall has, in place of one flat layer +// of uniform dots. +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let k = params.density * 0.01; + var glow = vec3(0.0); + for (var i: i32 = 0; i < 20; i++) { + let seed = f32(i) * 11.0; + let depth = hash(vec2(seed, 4.0), 0.0); + let fall = fract(frame.time * params.speed * (0.06 + depth * 0.22) + hash(vec2(seed, 0.0), 0.0)); + let sway = sin(fall * 6.283 * 1.5 + seed) * 0.04 * (0.4 + depth); + let x = fract(hash(vec2(seed, 1.0), 0.0) + sway); + let p = vec2(x, fall); + let d = distance(uv, p); + let radius = mix(0.0025, 0.012, depth); + let twinkle = 0.82 + 0.18 * sin(frame.time * 8.0 + seed * 3.0); + let falloff = exp(-(d * d) / (radius * radius)) * twinkle; + let tint = mix(vec3(0.82, 0.9, 1.0), vec3(1.0), depth); + glow += tint * falloff * mix(0.35, 1.0, depth); + } + return vec4(clamp01(c.rgb + glow * k), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.snow/fixtures.toml b/src/crates/concat-effects/packages/concat.snow/fixtures.toml new file mode 100644 index 00000000..2e02dc11 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.snow/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "noise=alls=15:allf=t,eq=brightness=0.015" + +[[case]] +name = "min" +at = "min" +chain = "noise=alls=0:allf=t,eq=brightness=0.000" + +[[case]] +name = "max" +at = "max" +chain = "noise=alls=30:allf=t,eq=brightness=0.030" diff --git a/src/crates/concat-effects/packages/concat.sparkle/effect.toml b/src/crates/concat-effects/packages/concat.sparkle/effect.toml new file mode 100644 index 00000000..2e01d34b --- /dev/null +++ b/src/crates/concat-effects/packages/concat.sparkle/effect.toml @@ -0,0 +1,35 @@ +[effect] +id = "concat.sparkle" +name = "Sparkle" +kind = "effect" +category = "Light & Shadow" +order = 3 +intensity = "amount" +description = "Kira Kira starburst glints on luminance highlights with shimmer." + +[[param]] +key = "amount" +label = "Amount" +min = 0 +max = 100 +default = 60 +step = 1 +unit = "%" + +[[param]] +key = "threshold" +label = "Threshold" +min = 50 +max = 95 +default = 75 +step = 1 +unit = "%" + +[ffmpeg] +let = [ + "a = fixed(amount / 100 * 0.8, 3)", +] +chain = "split[spka{index}][spkb{index}];[spkb{index}]gblur=sigma=8[spkg{index}];[spka{index}][spkg{index}]blend=all_mode=screen:all_opacity={a}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.sparkle/effect.wgsl b/src/crates/concat-effects/packages/concat.sparkle/effect.wgsl new file mode 100644 index 00000000..44ce3f31 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.sparkle/effect.wgsl @@ -0,0 +1,30 @@ +struct Params { amount: f32, threshold: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let thresh = params.threshold * 0.01; + let lum = luma(c.rgb); + let star_intensity = smoothstep(thresh, 1.0, lum); + + let cell_scale = vec2(32.0, 18.0); + let cell_coord = uv * cell_scale; + let cell_id = floor(cell_coord); + let cell_uv = fract(cell_coord) - 0.5; + + let center_uv = (cell_id + 0.5) / cell_scale; + let center_lum = luma(sample(center_uv).rgb); + let cell_star = smoothstep(thresh, 1.0, center_lum); + + let t = frame.time * 2.0 + hash(cell_id, 1.0) * 6.28; + let cos_t = cos(t); + let sin_t = sin(t); + let rot_p = vec2(cell_uv.x * cos_t - cell_uv.y * sin_t, cell_uv.x * sin_t + cell_uv.y * cos_t); + let ray1 = 1.0 / (abs(rot_p.x) * 40.0 + abs(rot_p.y) * 4.0 + 0.1); + let ray2 = 1.0 / (abs(rot_p.y) * 40.0 + abs(rot_p.x) * 4.0 + 0.1); + let star = max(ray1, ray2) * cell_star * 0.15; + + let shimmer = 0.7 + 0.3 * sin(frame.time * 4.0 + cell_id.x * 3.0 + cell_id.y * 7.0); + let glint = (star_intensity * 0.2 + star) * shimmer * (params.amount * 0.02); + let out_rgb = clamp01(c.rgb + vec3(glint)); + return vec4(out_rgb, c.a); +} diff --git a/src/crates/concat-effects/packages/concat.sparkle/ffmpeg.txt b/src/crates/concat-effects/packages/concat.sparkle/ffmpeg.txt new file mode 100644 index 00000000..f9e040eb --- /dev/null +++ b/src/crates/concat-effects/packages/concat.sparkle/ffmpeg.txt @@ -0,0 +1 @@ +split[spka{index}][spkb{index}];[spkb{index}]gblur=sigma=8[spkg{index}];[spka{index}][spkg{index}]blend=all_mode=screen:all_opacity={a} diff --git a/src/crates/concat-effects/packages/concat.sparkle/fixtures.toml b/src/crates/concat-effects/packages/concat.sparkle/fixtures.toml new file mode 100644 index 00000000..6bc1a0a2 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.sparkle/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "split[spka0][spkb0];[spkb0]gblur=sigma=8[spkg0];[spka0][spkg0]blend=all_mode=screen:all_opacity=0.480" + +[[case]] +name = "min" +at = "min" +chain = "split[spka0][spkb0];[spkb0]gblur=sigma=8[spkg0];[spka0][spkg0]blend=all_mode=screen:all_opacity=0.000" + +[[case]] +name = "max" +at = "max" +chain = "split[spka0][spkb0];[spkb0]gblur=sigma=8[spkg0];[spka0][spkg0]blend=all_mode=screen:all_opacity=0.800" diff --git a/src/crates/concat-effects/packages/concat.sparkle/main.wgsl b/src/crates/concat-effects/packages/concat.sparkle/main.wgsl new file mode 100644 index 00000000..44ce3f31 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.sparkle/main.wgsl @@ -0,0 +1,30 @@ +struct Params { amount: f32, threshold: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let thresh = params.threshold * 0.01; + let lum = luma(c.rgb); + let star_intensity = smoothstep(thresh, 1.0, lum); + + let cell_scale = vec2(32.0, 18.0); + let cell_coord = uv * cell_scale; + let cell_id = floor(cell_coord); + let cell_uv = fract(cell_coord) - 0.5; + + let center_uv = (cell_id + 0.5) / cell_scale; + let center_lum = luma(sample(center_uv).rgb); + let cell_star = smoothstep(thresh, 1.0, center_lum); + + let t = frame.time * 2.0 + hash(cell_id, 1.0) * 6.28; + let cos_t = cos(t); + let sin_t = sin(t); + let rot_p = vec2(cell_uv.x * cos_t - cell_uv.y * sin_t, cell_uv.x * sin_t + cell_uv.y * cos_t); + let ray1 = 1.0 / (abs(rot_p.x) * 40.0 + abs(rot_p.y) * 4.0 + 0.1); + let ray2 = 1.0 / (abs(rot_p.y) * 40.0 + abs(rot_p.x) * 4.0 + 0.1); + let star = max(ray1, ray2) * cell_star * 0.15; + + let shimmer = 0.7 + 0.3 * sin(frame.time * 4.0 + cell_id.x * 3.0 + cell_id.y * 7.0); + let glint = (star_intensity * 0.2 + star) * shimmer * (params.amount * 0.02); + let out_rgb = clamp01(c.rgb + vec3(glint)); + return vec4(out_rgb, c.a); +} diff --git a/src/crates/concat-effects/packages/concat.spin/effect.toml b/src/crates/concat-effects/packages/concat.spin/effect.toml new file mode 100644 index 00000000..371a1e88 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.spin/effect.toml @@ -0,0 +1,19 @@ +[effect] +id = "concat.spin" +name = "Spin" +kind = "transition" +category = "Spin" +order = 6 +description = "The outgoing shot spins away as the incoming one spins into place." + +[[param]] +key = "turns" +label = "Turns" +min = 0.5 +max = 2 +default = 1 +step = 0.25 + +[transition] +entry = "effect.wgsl" +fallback = "cross-fade" diff --git a/src/crates/concat-effects/packages/concat.spin/effect.wgsl b/src/crates/concat-effects/packages/concat.spin/effect.wgsl new file mode 100644 index 00000000..3e4672df --- /dev/null +++ b/src/crates/concat-effects/packages/concat.spin/effect.wgsl @@ -0,0 +1,24 @@ +struct Params { turns: f32 } + +fn in_bounds(uv: vec2) -> bool { + return uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0; +} + +fn spin_uv(uv: vec2, angle: f32, scale: f32) -> vec2 { + let c = uv - vec2(0.5); + let s = sin(angle); + let co = cos(angle); + let r = vec2(c.x * co - c.y * s, c.x * s + c.y * co) / max(scale, 0.0001); + return r + vec2(0.5); +} + +fn transition(uv: vec2, progress: f32) -> vec4 { + let p = smoothstep(0.0, 1.0, progress); + let full = params.turns * 6.28318530718; + let ang = p * full; + let a_uv = spin_uv(uv, ang, 1.0 - p * 0.9); + let b_uv = spin_uv(uv, ang - full, 0.1 + p * 0.9); + let a = select(vec4(0.0), from_at(a_uv), in_bounds(a_uv)); + let b = select(vec4(0.0), to_at(b_uv), in_bounds(b_uv)); + return mix(a, b, p); +} diff --git a/src/crates/concat-effects/packages/concat.stadium/effect.toml b/src/crates/concat-effects/packages/concat.stadium/effect.toml new file mode 100644 index 00000000..9baa97ec --- /dev/null +++ b/src/crates/concat-effects/packages/concat.stadium/effect.toml @@ -0,0 +1,20 @@ +[effect] +id = "concat.stadium" +name = "Stadium" +kind = "audio" +category = "Space" +order = 35 +intensity = "size" +aliases = ["arena"] +description = "Vast stadium echoes and reverberations across a massive arena." + +[[param]] +key = "size" +label = "Size" +min = 1 +max = 10 +default = 6 +step = 0.5 + +[ffmpeg] +chain = "aecho=0.8:0.88:{round(120 + size * 18)}|{round(240 + size * 36)}:0.45|0.28,lowpass=f=6500" diff --git a/src/crates/concat-effects/packages/concat.stadium/fixtures.toml b/src/crates/concat-effects/packages/concat.stadium/fixtures.toml new file mode 100644 index 00000000..9429ffce --- /dev/null +++ b/src/crates/concat-effects/packages/concat.stadium/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "aecho=0.8:0.88:228|456:0.45|0.28,lowpass=f=6500" + +[[case]] +name = "minimum" +at = "min" +chain = "aecho=0.8:0.88:138|276:0.45|0.28,lowpass=f=6500" + +[[case]] +name = "maximum" +at = "max" +chain = "aecho=0.8:0.88:300|600:0.45|0.28,lowpass=f=6500" diff --git a/src/crates/concat-effects/packages/concat.strobe-flash/effect.toml b/src/crates/concat-effects/packages/concat.strobe-flash/effect.toml new file mode 100644 index 00000000..e37db345 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.strobe-flash/effect.toml @@ -0,0 +1,35 @@ +[effect] +id = "concat.strobe-flash" +name = "Strobe Flash" +kind = "effect" +category = "Party & Club" +order = 5 +intensity = "strength" +description = "High-energy pulsating stroboscopic flash." + +[[param]] +key = "strength" +label = "Strength" +min = 0 +max = 100 +default = 80 +step = 1 +unit = "%" + +[[param]] +key = "frequency" +label = "Frequency" +min = 2 +max = 30 +default = 10 +step = 1 +unit = "Hz" + +[ffmpeg] +let = [ + "f = fixed(strength / 100 * 0.5, 3)", +] +chain = "eq=brightness={f}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.strobe-flash/effect.wgsl b/src/crates/concat-effects/packages/concat.strobe-flash/effect.wgsl new file mode 100644 index 00000000..1698ef28 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.strobe-flash/effect.wgsl @@ -0,0 +1,9 @@ +struct Params { strength: f32, frequency: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let flash_pulse = step(0.5, fract(frame.time * params.frequency * 0.5)); + let flash = flash_pulse * (params.strength * 0.01); + let out_rgb = clamp01(mix(c.rgb, vec3(1.0), flash * 0.8)); + return vec4(out_rgb, c.a); +} diff --git a/src/crates/concat-effects/packages/concat.strobe-flash/ffmpeg.txt b/src/crates/concat-effects/packages/concat.strobe-flash/ffmpeg.txt new file mode 100644 index 00000000..22215d4a --- /dev/null +++ b/src/crates/concat-effects/packages/concat.strobe-flash/ffmpeg.txt @@ -0,0 +1 @@ +eq=brightness={f} diff --git a/src/crates/concat-effects/packages/concat.strobe-flash/fixtures.toml b/src/crates/concat-effects/packages/concat.strobe-flash/fixtures.toml new file mode 100644 index 00000000..85499a81 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.strobe-flash/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "eq=brightness=0.400" + +[[case]] +name = "min" +at = "min" +chain = "eq=brightness=0.000" + +[[case]] +name = "max" +at = "max" +chain = "eq=brightness=0.500" diff --git a/src/crates/concat-effects/packages/concat.strobe-flash/main.wgsl b/src/crates/concat-effects/packages/concat.strobe-flash/main.wgsl new file mode 100644 index 00000000..1698ef28 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.strobe-flash/main.wgsl @@ -0,0 +1,9 @@ +struct Params { strength: f32, frequency: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let flash_pulse = step(0.5, fract(frame.time * params.frequency * 0.5)); + let flash = flash_pulse * (params.strength * 0.01); + let out_rgb = clamp01(mix(c.rgb, vec3(1.0), flash * 0.8)); + return vec4(out_rgb, c.a); +} diff --git a/src/crates/concat-effects/packages/concat.super-8/effect.toml b/src/crates/concat-effects/packages/concat.super-8/effect.toml new file mode 100644 index 00000000..692b4c62 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.super-8/effect.toml @@ -0,0 +1,13 @@ +[effect] +id = "concat.super-8" +name = "Super 8" +kind = "filter" +category = "Film" +order = 62 +description = "Faded, warm and grainy: an old home movie run through a projector." + +[ffmpeg] +chain = "curves=all='0/0.08 1/0.9',colorbalance=rh=0.08:bh=-0.06,noise=alls=8:allf=t" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.super-8/effect.wgsl b/src/crates/concat-effects/packages/concat.super-8/effect.wgsl new file mode 100644 index 00000000..38afdb21 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.super-8/effect.wgsl @@ -0,0 +1,7 @@ +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let f = fade(c.rgb, 0.08); + let t = split_tone(f, vec3(0.0), vec3(0.08, 0.0, -0.06), 1.0); + let g = grain_at(uv, 0.05); + return vec4(clamp01(t + g), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.super-8/fixtures.toml b/src/crates/concat-effects/packages/concat.super-8/fixtures.toml new file mode 100644 index 00000000..264b4706 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.super-8/fixtures.toml @@ -0,0 +1,3 @@ +[[case]] +name = "default" +chain = "curves=all='0/0.08 1/0.9',colorbalance=rh=0.08:bh=-0.06,noise=alls=8:allf=t" diff --git a/src/crates/concat-effects/packages/concat.swirl/effect.toml b/src/crates/concat-effects/packages/concat.swirl/effect.toml new file mode 100644 index 00000000..12d3bf2e --- /dev/null +++ b/src/crates/concat-effects/packages/concat.swirl/effect.toml @@ -0,0 +1,20 @@ +[effect] +id = "concat.swirl" +name = "Swirl" +kind = "transition" +category = "Spin" +order = 7 +description = "The cut twists both pictures around its centre and untwists into the next shot." + +[[param]] +key = "twist" +label = "Twist" +min = 0 +max = 100 +default = 70 +step = 1 +unit = "%" + +[transition] +entry = "effect.wgsl" +fallback = "cross-fade" diff --git a/src/crates/concat-effects/packages/concat.swirl/effect.wgsl b/src/crates/concat-effects/packages/concat.swirl/effect.wgsl new file mode 100644 index 00000000..1e5e0a6f --- /dev/null +++ b/src/crates/concat-effects/packages/concat.swirl/effect.wgsl @@ -0,0 +1,19 @@ +struct Params { twist: f32 } + +fn swirl_uv(uv: vec2, amount: f32) -> vec2 { + let c = uv - vec2(0.5); + let d = length(c); + let a = amount * (1.0 - smoothstep(0.0, 0.7, d)); + let s = sin(a); + let co = cos(a); + return vec2(c.x * co - c.y * s, c.x * s + c.y * co) + vec2(0.5); +} + +// The twist peaks at the middle of the cut and resolves at both ends. +fn transition(uv: vec2, progress: f32) -> vec4 { + let p = smoothstep(0.0, 1.0, progress); + let k = sin(p * 3.14159265) * params.twist * 0.06; + let a = from_at(swirl_uv(uv, k)); + let b = to_at(swirl_uv(uv, -k)); + return mix(a, b, p); +} diff --git a/src/crates/concat-effects/packages/concat.tilt-shift/effect.toml b/src/crates/concat-effects/packages/concat.tilt-shift/effect.toml new file mode 100644 index 00000000..f028f610 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.tilt-shift/effect.toml @@ -0,0 +1,35 @@ +[effect] +id = "concat.tilt-shift" +name = "Tilt Shift" +kind = "effect" +category = "Optical & Lens" +order = 4 +intensity = "blur" +description = "Miniature horizontal focus band with progressive blur and saturation." + +[[param]] +key = "blur" +label = "Blur" +min = 0 +max = 100 +default = 50 +step = 1 +unit = "%" + +[[param]] +key = "position" +label = "Position" +min = 10 +max = 90 +default = 50 +step = 1 +unit = "%" + +[ffmpeg] +let = [ + "b = fixed(blur * 0.2, 1)", +] +chain = "gblur=sigma={b},eq=saturation=1.2" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.tilt-shift/effect.wgsl b/src/crates/concat-effects/packages/concat.tilt-shift/effect.wgsl new file mode 100644 index 00000000..cbde8805 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.tilt-shift/effect.wgsl @@ -0,0 +1,16 @@ +struct Params { blur: f32, position: f32 } + +fn effect(uv: vec2) -> vec4 { + let center_y = params.position * 0.01; + let dist = abs(uv.y - center_y); + let blur_factor = smoothstep(0.1, 0.4, dist) * (params.blur * 0.01); + var col = vec4(0.0); + let rad = blur_factor * 8.0 * texel(); + col += sample(uv) * 0.36; + col += sample(uv + vec2(0.0, rad.y * 1.5)) * 0.16; + col += sample(uv - vec2(0.0, rad.y * 1.5)) * 0.16; + col += sample(uv + vec2(rad.x * 1.5, 0.0)) * 0.16; + col += sample(uv - vec2(rad.x * 1.5, 0.0)) * 0.16; + let sat_col = saturation(col.rgb, 1.25); + return vec4(mix(col.rgb, sat_col, 0.5), col.a); +} diff --git a/src/crates/concat-effects/packages/concat.tilt-shift/ffmpeg.txt b/src/crates/concat-effects/packages/concat.tilt-shift/ffmpeg.txt new file mode 100644 index 00000000..d7b66906 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.tilt-shift/ffmpeg.txt @@ -0,0 +1 @@ +gblur=sigma={b},eq=saturation=1.2 diff --git a/src/crates/concat-effects/packages/concat.tilt-shift/fixtures.toml b/src/crates/concat-effects/packages/concat.tilt-shift/fixtures.toml new file mode 100644 index 00000000..7ccbc44c --- /dev/null +++ b/src/crates/concat-effects/packages/concat.tilt-shift/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "gblur=sigma=10.0,eq=saturation=1.2" + +[[case]] +name = "min" +at = "min" +chain = "gblur=sigma=0.0,eq=saturation=1.2" + +[[case]] +name = "max" +at = "max" +chain = "gblur=sigma=20.0,eq=saturation=1.2" diff --git a/src/crates/concat-effects/packages/concat.tilt-shift/main.wgsl b/src/crates/concat-effects/packages/concat.tilt-shift/main.wgsl new file mode 100644 index 00000000..cbde8805 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.tilt-shift/main.wgsl @@ -0,0 +1,16 @@ +struct Params { blur: f32, position: f32 } + +fn effect(uv: vec2) -> vec4 { + let center_y = params.position * 0.01; + let dist = abs(uv.y - center_y); + let blur_factor = smoothstep(0.1, 0.4, dist) * (params.blur * 0.01); + var col = vec4(0.0); + let rad = blur_factor * 8.0 * texel(); + col += sample(uv) * 0.36; + col += sample(uv + vec2(0.0, rad.y * 1.5)) * 0.16; + col += sample(uv - vec2(0.0, rad.y * 1.5)) * 0.16; + col += sample(uv + vec2(rad.x * 1.5, 0.0)) * 0.16; + col += sample(uv - vec2(rad.x * 1.5, 0.0)) * 0.16; + let sat_col = saturation(col.rgb, 1.25); + return vec4(mix(col.rgb, sat_col, 0.5), col.a); +} diff --git a/src/crates/concat-effects/packages/concat.tropical/effect.toml b/src/crates/concat-effects/packages/concat.tropical/effect.toml new file mode 100644 index 00000000..6fa8d03d --- /dev/null +++ b/src/crates/concat-effects/packages/concat.tropical/effect.toml @@ -0,0 +1,13 @@ +[effect] +id = "concat.tropical" +name = "Tropical" +kind = "filter" +category = "Scenery" +order = 60 +description = "Saturated and bright, the water pushed teal." + +[ffmpeg] +chain = "colorbalance=rh=-0.06:gh=0.05:bh=0.08,eq=saturation=1.35:contrast=1.1" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.tropical/effect.wgsl b/src/crates/concat-effects/packages/concat.tropical/effect.wgsl new file mode 100644 index 00000000..f58cb25c --- /dev/null +++ b/src/crates/concat-effects/packages/concat.tropical/effect.wgsl @@ -0,0 +1,5 @@ +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let t = split_tone(c.rgb, vec3(0.0), vec3(-0.06, 0.05, 0.08), 1.0); + return vec4(clamp01(contrast(saturation(t, 1.35), 1.1)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.tropical/fixtures.toml b/src/crates/concat-effects/packages/concat.tropical/fixtures.toml new file mode 100644 index 00000000..5fe092de --- /dev/null +++ b/src/crates/concat-effects/packages/concat.tropical/fixtures.toml @@ -0,0 +1,3 @@ +[[case]] +name = "default" +chain = "colorbalance=rh=-0.06:gh=0.05:bh=0.08,eq=saturation=1.35:contrast=1.1" diff --git a/src/crates/concat-effects/packages/concat.twirl/effect.toml b/src/crates/concat-effects/packages/concat.twirl/effect.toml new file mode 100644 index 00000000..4335d31d --- /dev/null +++ b/src/crates/concat-effects/packages/concat.twirl/effect.toml @@ -0,0 +1,23 @@ +[effect] +id = "concat.twirl" +name = "Twirl" +kind = "effect" +category = "Distort" +order = 78 +intensity = "amount" +description = "The picture twisted around its centre like a whirlpool." + +[[param]] +key = "amount" +label = "Amount" +min = 0 +max = 100 +default = 60 +step = 1 +unit = "%" + +[ffmpeg] +chain = "gblur=sigma={fixed(0.3 + amount / 100 * 1.2, 2)}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.twirl/effect.wgsl b/src/crates/concat-effects/packages/concat.twirl/effect.wgsl new file mode 100644 index 00000000..31a564d0 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.twirl/effect.wgsl @@ -0,0 +1,11 @@ +struct Params { amount: f32 } + +fn effect(uv: vec2) -> vec4 { + let c = uv - vec2(0.5); + let d = length(c); + let a = params.amount * 0.01 * 6.0 * (1.0 - smoothstep(0.0, 0.7, d)); + let s = sin(a); + let co = cos(a); + let r = vec2(c.x * co - c.y * s, c.x * s + c.y * co); + return sample(r + vec2(0.5)); +} diff --git a/src/crates/concat-effects/packages/concat.twirl/fixtures.toml b/src/crates/concat-effects/packages/concat.twirl/fixtures.toml new file mode 100644 index 00000000..14e624da --- /dev/null +++ b/src/crates/concat-effects/packages/concat.twirl/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "gblur=sigma=1.02" + +[[case]] +name = "min" +at = "min" +chain = "gblur=sigma=0.30" + +[[case]] +name = "max" +at = "max" +chain = "gblur=sigma=1.50" diff --git a/src/crates/concat-effects/packages/concat.underwater/effect.toml b/src/crates/concat-effects/packages/concat.underwater/effect.toml new file mode 100644 index 00000000..4cb557d9 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.underwater/effect.toml @@ -0,0 +1,20 @@ +[effect] +id = "concat.underwater" +name = "Underwater" +kind = "audio" +category = "Space" +order = 34 +intensity = "depth" +aliases = ["submerged"] +description = "Submerged beneath the surface with heavy low-pass filtering." + +[[param]] +key = "depth" +label = "Depth" +min = 1 +max = 10 +default = 5 +step = 0.5 + +[ffmpeg] +chain = "lowpass=f={fixed(800 - depth * 40, 1)},equalizer=f=200:t=q:w=2.0:g={fixed(2 + depth * 0.3, 2)},alimiter=limit=0.92" diff --git a/src/crates/concat-effects/packages/concat.underwater/fixtures.toml b/src/crates/concat-effects/packages/concat.underwater/fixtures.toml new file mode 100644 index 00000000..cd0dfa76 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.underwater/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "lowpass=f=600.0,equalizer=f=200:t=q:w=2.0:g=3.50,alimiter=limit=0.92" + +[[case]] +name = "minimum" +at = "min" +chain = "lowpass=f=760.0,equalizer=f=200:t=q:w=2.0:g=2.30,alimiter=limit=0.92" + +[[case]] +name = "maximum" +at = "max" +chain = "lowpass=f=400.0,equalizer=f=200:t=q:w=2.0:g=5.00,alimiter=limit=0.92" diff --git a/src/crates/concat-effects/packages/concat.vaporwave/effect.toml b/src/crates/concat-effects/packages/concat.vaporwave/effect.toml new file mode 100644 index 00000000..366b314f --- /dev/null +++ b/src/crates/concat-effects/packages/concat.vaporwave/effect.toml @@ -0,0 +1,13 @@ +[effect] +id = "concat.vaporwave" +name = "Vaporwave" +kind = "filter" +category = "Vibe" +order = 67 +description = "Magenta and cyan pushed hard, a saturated retro-synth cast." + +[ffmpeg] +chain = "colorbalance=rs=0.1:bs=0.15:rh=0.08:bh=0.12,eq=saturation=1.3" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.vaporwave/effect.wgsl b/src/crates/concat-effects/packages/concat.vaporwave/effect.wgsl new file mode 100644 index 00000000..3eee5af8 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.vaporwave/effect.wgsl @@ -0,0 +1,5 @@ +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let t = split_tone(c.rgb, vec3(0.1, 0.0, 0.15), vec3(0.0, 0.08, 0.12), 1.0); + return vec4(clamp01(saturation(t, 1.3)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.vaporwave/fixtures.toml b/src/crates/concat-effects/packages/concat.vaporwave/fixtures.toml new file mode 100644 index 00000000..6df6b8a4 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.vaporwave/fixtures.toml @@ -0,0 +1,3 @@ +[[case]] +name = "default" +chain = "colorbalance=rs=0.1:bs=0.15:rh=0.08:bh=0.12,eq=saturation=1.3" diff --git a/src/crates/concat-effects/packages/concat.vinyl/effect.toml b/src/crates/concat-effects/packages/concat.vinyl/effect.toml new file mode 100644 index 00000000..0ce54715 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.vinyl/effect.toml @@ -0,0 +1,20 @@ +[effect] +id = "concat.vinyl" +name = "Vinyl" +kind = "audio" +category = "Tone" +order = 24 +intensity = "warmth" +aliases = ["record", "gramophone"] +description = "Warm, vintage turntable character with band-limited sound." + +[[param]] +key = "warmth" +label = "Warmth" +min = 0 +max = 10 +default = 5 +step = 0.5 + +[ffmpeg] +chain = "highpass=f=250,lowpass=f=5000,equalizer=f=1200:t=q:w=1.5:g={fixed(2 + warmth * 0.4, 2)},alimiter=limit=0.95" diff --git a/src/crates/concat-effects/packages/concat.vinyl/fixtures.toml b/src/crates/concat-effects/packages/concat.vinyl/fixtures.toml new file mode 100644 index 00000000..22b2c5ea --- /dev/null +++ b/src/crates/concat-effects/packages/concat.vinyl/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "highpass=f=250,lowpass=f=5000,equalizer=f=1200:t=q:w=1.5:g=4.00,alimiter=limit=0.95" + +[[case]] +name = "minimum" +at = "min" +chain = "highpass=f=250,lowpass=f=5000,equalizer=f=1200:t=q:w=1.5:g=2.00,alimiter=limit=0.95" + +[[case]] +name = "maximum" +at = "max" +chain = "highpass=f=250,lowpass=f=5000,equalizer=f=1200:t=q:w=1.5:g=6.00,alimiter=limit=0.95" diff --git a/src/crates/concat-effects/packages/concat.vortex-swirl/effect.toml b/src/crates/concat-effects/packages/concat.vortex-swirl/effect.toml new file mode 100644 index 00000000..c4f27246 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.vortex-swirl/effect.toml @@ -0,0 +1,35 @@ +[effect] +id = "concat.vortex-swirl" +name = "Vortex Swirl" +kind = "effect" +category = "Distortion & Glitch" +order = 11 +intensity = "angle" +description = "Rotational vortex twist around a focal point." + +[[param]] +key = "angle" +label = "Angle" +min = -180 +max = 180 +default = 90 +step = 1 +unit = "°" + +[[param]] +key = "radius" +label = "Radius" +min = 10 +max = 100 +default = 50 +step = 1 +unit = "%" + +[ffmpeg] +let = [ + "s = fixed(0.5 + abs(angle) / 180 * 2.0, 2)", +] +chain = "gblur=sigma={s}" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.vortex-swirl/effect.wgsl b/src/crates/concat-effects/packages/concat.vortex-swirl/effect.wgsl new file mode 100644 index 00000000..1bb2266c --- /dev/null +++ b/src/crates/concat-effects/packages/concat.vortex-swirl/effect.wgsl @@ -0,0 +1,20 @@ +struct Params { angle: f32, radius: f32 } + +fn effect(uv: vec2) -> vec4 { + let center = vec2(0.5, 0.5); + let d = uv - center; + let r = length(d); + let max_r = params.radius * 0.01; + if (r >= max_r || r == 0.0) { + return sample(uv); + } + let factor = (1.0 - r / max_r); + let theta = factor * factor * (params.angle * 0.017453); + let cos_t = cos(theta); + let sin_t = sin(theta); + let rotated = vec2( + d.x * cos_t - d.y * sin_t, + d.x * sin_t + d.y * cos_t + ); + return sample(center + rotated); +} diff --git a/src/crates/concat-effects/packages/concat.vortex-swirl/ffmpeg.txt b/src/crates/concat-effects/packages/concat.vortex-swirl/ffmpeg.txt new file mode 100644 index 00000000..bf8c15e2 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.vortex-swirl/ffmpeg.txt @@ -0,0 +1 @@ +gblur=sigma={s} diff --git a/src/crates/concat-effects/packages/concat.vortex-swirl/fixtures.toml b/src/crates/concat-effects/packages/concat.vortex-swirl/fixtures.toml new file mode 100644 index 00000000..54bdc193 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.vortex-swirl/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "gblur=sigma=1.50" + +[[case]] +name = "min" +at = "min" +chain = "gblur=sigma=2.50" + +[[case]] +name = "max" +at = "max" +chain = "gblur=sigma=2.50" diff --git a/src/crates/concat-effects/packages/concat.vortex-swirl/main.wgsl b/src/crates/concat-effects/packages/concat.vortex-swirl/main.wgsl new file mode 100644 index 00000000..1bb2266c --- /dev/null +++ b/src/crates/concat-effects/packages/concat.vortex-swirl/main.wgsl @@ -0,0 +1,20 @@ +struct Params { angle: f32, radius: f32 } + +fn effect(uv: vec2) -> vec4 { + let center = vec2(0.5, 0.5); + let d = uv - center; + let r = length(d); + let max_r = params.radius * 0.01; + if (r >= max_r || r == 0.0) { + return sample(uv); + } + let factor = (1.0 - r / max_r); + let theta = factor * factor * (params.angle * 0.017453); + let cos_t = cos(theta); + let sin_t = sin(theta); + let rotated = vec2( + d.x * cos_t - d.y * sin_t, + d.x * sin_t + d.y * cos_t + ); + return sample(center + rotated); +} diff --git a/src/crates/concat-effects/packages/concat.wave-warp/effect.toml b/src/crates/concat-effects/packages/concat.wave-warp/effect.toml new file mode 100644 index 00000000..3b66165c --- /dev/null +++ b/src/crates/concat-effects/packages/concat.wave-warp/effect.toml @@ -0,0 +1,45 @@ +[effect] +id = "concat.wave-warp" +name = "Wave Warp" +kind = "effect" +category = "Distortion & Glitch" +order = 10 +intensity = "amplitude" +description = "Liquid sinusoidal horizontal and vertical ripple warping." + +[[param]] +key = "amplitude" +label = "Amplitude" +min = 0 +max = 50 +default = 20 +step = 1 +unit = "px" + +[[param]] +key = "wavelength" +label = "Wavelength" +min = 5 +max = 100 +default = 30 +step = 1 +unit = "px" + +[[param]] +key = "speed" +label = "Speed" +min = 0.5 +max = 10 +default = 2 +step = 0.5 +unit = "Hz" + +[ffmpeg] +let = [ + "amp = fixed(amplitude / 50 * 10, 2)", + "spd = fixed(speed, 2)", +] +chain = "geq=r='r(X+{amp}*sin(Y/15+T*{spd}),Y+{amp}*cos(X/15+T*{spd}))':g='g(X+{amp}*sin(Y/15+T*{spd}),Y+{amp}*cos(X/15+T*{spd}))':b='b(X+{amp}*sin(Y/15+T*{spd}),Y+{amp}*cos(X/15+T*{spd}))'" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.wave-warp/effect.wgsl b/src/crates/concat-effects/packages/concat.wave-warp/effect.wgsl new file mode 100644 index 00000000..680e686b --- /dev/null +++ b/src/crates/concat-effects/packages/concat.wave-warp/effect.wgsl @@ -0,0 +1,8 @@ +struct Params { amplitude: f32, wavelength: f32, speed: f32 } + +fn effect(uv: vec2) -> vec4 { + let t = frame.time * params.speed; + let wave_x = sin(uv.y * (params.wavelength * 0.5) + t) * (params.amplitude * 0.001); + let wave_y = cos(uv.x * (params.wavelength * 0.5) + t) * (params.amplitude * 0.001); + return sample(uv + vec2(wave_x, wave_y)); +} diff --git a/src/crates/concat-effects/packages/concat.wave-warp/ffmpeg.txt b/src/crates/concat-effects/packages/concat.wave-warp/ffmpeg.txt new file mode 100644 index 00000000..7004ec4b --- /dev/null +++ b/src/crates/concat-effects/packages/concat.wave-warp/ffmpeg.txt @@ -0,0 +1 @@ +geq=r='r(X+{amp}*sin(Y/15+T*{spd}),Y+{amp}*cos(X/15+T*{spd}))':g='g(X+{amp}*sin(Y/15+T*{spd}),Y+{amp}*cos(X/15+T*{spd}))':b='b(X+{amp}*sin(Y/15+T*{spd}),Y+{amp}*cos(X/15+T*{spd}))' diff --git a/src/crates/concat-effects/packages/concat.wave-warp/fixtures.toml b/src/crates/concat-effects/packages/concat.wave-warp/fixtures.toml new file mode 100644 index 00000000..75f86f69 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.wave-warp/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "geq=r='r(X+4.00*sin(Y/15+T*2.00),Y+4.00*cos(X/15+T*2.00))':g='g(X+4.00*sin(Y/15+T*2.00),Y+4.00*cos(X/15+T*2.00))':b='b(X+4.00*sin(Y/15+T*2.00),Y+4.00*cos(X/15+T*2.00))'" + +[[case]] +name = "min" +at = "min" +chain = "geq=r='r(X+0.00*sin(Y/15+T*0.50),Y+0.00*cos(X/15+T*0.50))':g='g(X+0.00*sin(Y/15+T*0.50),Y+0.00*cos(X/15+T*0.50))':b='b(X+0.00*sin(Y/15+T*0.50),Y+0.00*cos(X/15+T*0.50))'" + +[[case]] +name = "max" +at = "max" +chain = "geq=r='r(X+10.00*sin(Y/15+T*10.00),Y+10.00*cos(X/15+T*10.00))':g='g(X+10.00*sin(Y/15+T*10.00),Y+10.00*cos(X/15+T*10.00))':b='b(X+10.00*sin(Y/15+T*10.00),Y+10.00*cos(X/15+T*10.00))'" diff --git a/src/crates/concat-effects/packages/concat.wave-warp/main.wgsl b/src/crates/concat-effects/packages/concat.wave-warp/main.wgsl new file mode 100644 index 00000000..680e686b --- /dev/null +++ b/src/crates/concat-effects/packages/concat.wave-warp/main.wgsl @@ -0,0 +1,8 @@ +struct Params { amplitude: f32, wavelength: f32, speed: f32 } + +fn effect(uv: vec2) -> vec4 { + let t = frame.time * params.speed; + let wave_x = sin(uv.y * (params.wavelength * 0.5) + t) * (params.amplitude * 0.001); + let wave_y = cos(uv.x * (params.wavelength * 0.5) + t) * (params.amplitude * 0.001); + return sample(uv + vec2(wave_x, wave_y)); +} diff --git a/src/crates/concat-effects/packages/concat.whip-pan/effect.toml b/src/crates/concat-effects/packages/concat.whip-pan/effect.toml new file mode 100644 index 00000000..ccd2fee1 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.whip-pan/effect.toml @@ -0,0 +1,21 @@ +[effect] +id = "concat.whip-pan" +name = "Whip Pan" +kind = "transition" +category = "Wipe" +order = 14 +description = "High-speed directional camera whip with motion blur." + +[[param]] +key = "speed" +label = "Speed" +min = 20 +max = 100 +default = 80 +step = 1 +unit = "%" + +[transition] +entry = "effect.wgsl" +xfade = "slideleft" +fallback = "slideleft" diff --git a/src/crates/concat-effects/packages/concat.whip-pan/effect.wgsl b/src/crates/concat-effects/packages/concat.whip-pan/effect.wgsl new file mode 100644 index 00000000..ad5ce6ad --- /dev/null +++ b/src/crates/concat-effects/packages/concat.whip-pan/effect.wgsl @@ -0,0 +1,14 @@ +struct Params { speed: f32 } + +fn transition(uv: vec2, progress: f32) -> vec4 { + let p = smoothstep(0.0, 1.0, progress); + let blur_amt = sin(progress * 3.14159) * (params.speed * 0.0008); + var col = vec4(0.0); + for (var i: i32 = -2; i <= 2; i++) { + let offset = vec2(f32(i) * blur_amt, 0.0); + let from_col = from_at(uv + vec2(p, 0.0) + offset); + let to_col = to_at(uv - vec2(1.0 - p, 0.0) + offset); + col += mix(from_col, to_col, step(1.0 - p, uv.x)); + } + return col / 5.0; +} diff --git a/src/crates/concat-effects/packages/concat.whip-pan/ffmpeg.txt b/src/crates/concat-effects/packages/concat.whip-pan/ffmpeg.txt new file mode 100644 index 00000000..c5c2faa2 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.whip-pan/ffmpeg.txt @@ -0,0 +1 @@ +slideleft diff --git a/src/crates/concat-effects/packages/concat.whip-pan/main.wgsl b/src/crates/concat-effects/packages/concat.whip-pan/main.wgsl new file mode 100644 index 00000000..ad5ce6ad --- /dev/null +++ b/src/crates/concat-effects/packages/concat.whip-pan/main.wgsl @@ -0,0 +1,14 @@ +struct Params { speed: f32 } + +fn transition(uv: vec2, progress: f32) -> vec4 { + let p = smoothstep(0.0, 1.0, progress); + let blur_amt = sin(progress * 3.14159) * (params.speed * 0.0008); + var col = vec4(0.0); + for (var i: i32 = -2; i <= 2; i++) { + let offset = vec2(f32(i) * blur_amt, 0.0); + let from_col = from_at(uv + vec2(p, 0.0) + offset); + let to_col = to_at(uv - vec2(1.0 - p, 0.0) + offset); + col += mix(from_col, to_col, step(1.0 - p, uv.x)); + } + return col / 5.0; +} diff --git a/src/crates/concat-effects/packages/concat.whisper/effect.toml b/src/crates/concat-effects/packages/concat.whisper/effect.toml new file mode 100644 index 00000000..6da924ca --- /dev/null +++ b/src/crates/concat-effects/packages/concat.whisper/effect.toml @@ -0,0 +1,20 @@ +[effect] +id = "concat.whisper" +name = "Whisper" +kind = "audio" +category = "Voice" +order = 16 +intensity = "air" +aliases = ["breathy", "intimate"] +description = "Airy, breathy tone with attenuated low end and enhanced presence." + +[[param]] +key = "air" +label = "Air" +min = 1 +max = 10 +default = 5 +step = 0.5 + +[ffmpeg] +chain = "highpass=f=500,equalizer=f=3200:t=q:w=1.4:g={fixed(2 + air * 0.6, 2)},equalizer=f=7500:t=q:w=1.2:g={fixed(1 + air * 0.5, 2)},alimiter=limit=0.92" diff --git a/src/crates/concat-effects/packages/concat.whisper/fixtures.toml b/src/crates/concat-effects/packages/concat.whisper/fixtures.toml new file mode 100644 index 00000000..c3486a65 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.whisper/fixtures.toml @@ -0,0 +1,13 @@ +[[case]] +name = "default" +chain = "highpass=f=500,equalizer=f=3200:t=q:w=1.4:g=5.00,equalizer=f=7500:t=q:w=1.2:g=3.50,alimiter=limit=0.92" + +[[case]] +name = "minimum" +at = "min" +chain = "highpass=f=500,equalizer=f=3200:t=q:w=1.4:g=2.60,equalizer=f=7500:t=q:w=1.2:g=1.50,alimiter=limit=0.92" + +[[case]] +name = "maximum" +at = "max" +chain = "highpass=f=500,equalizer=f=3200:t=q:w=1.4:g=8.00,equalizer=f=7500:t=q:w=1.2:g=6.00,alimiter=limit=0.92" diff --git a/src/crates/concat-effects/packages/concat.winter-cool/effect.toml b/src/crates/concat-effects/packages/concat.winter-cool/effect.toml new file mode 100644 index 00000000..2300ba9b --- /dev/null +++ b/src/crates/concat-effects/packages/concat.winter-cool/effect.toml @@ -0,0 +1,13 @@ +[effect] +id = "concat.winter-cool" +name = "Winter Cool" +kind = "filter" +category = "Scenery" +order = 59 +description = "Desaturated and blue, the blacks lifted to a matte wintry haze." + +[ffmpeg] +chain = "colortemperature=temperature=8800:pl=1,curves=all='0/0.06 1/0.95',eq=saturation=0.8" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.winter-cool/effect.wgsl b/src/crates/concat-effects/packages/concat.winter-cool/effect.wgsl new file mode 100644 index 00000000..b3f5b91b --- /dev/null +++ b/src/crates/concat-effects/packages/concat.winter-cool/effect.wgsl @@ -0,0 +1,6 @@ +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let wb = white_balance(c.rgb, 8800.0); + let f = fade(wb, 0.06); + return vec4(clamp01(saturation(f, 0.8)), c.a); +} diff --git a/src/crates/concat-effects/packages/concat.winter-cool/fixtures.toml b/src/crates/concat-effects/packages/concat.winter-cool/fixtures.toml new file mode 100644 index 00000000..910ca6b3 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.winter-cool/fixtures.toml @@ -0,0 +1,3 @@ +[[case]] +name = "default" +chain = "colortemperature=temperature=8800:pl=1,curves=all='0/0.06 1/0.95',eq=saturation=0.8" diff --git a/src/crates/concat-effects/packages/concat.word-reveal/effect.toml b/src/crates/concat-effects/packages/concat.word-reveal/effect.toml new file mode 100644 index 00000000..aba82535 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.word-reveal/effect.toml @@ -0,0 +1,38 @@ +[effect] +id = "concat.word-reveal" +name = "Word Reveal" +kind = "effect" +category = "Text" +order = 97 +description = "A title's words appear one at a time as the clip plays, in reading order." + +[[param]] +key = "style" +label = "Style" +type = "enum" +min = 0 +max = 2 +default = 0 +values = [0, 1, 2] +labels = ["Fade", "Blur", "Typewriter"] + +[[param]] +key = "duration" +label = "Duration" +min = 0.2 +max = 5 +default = 1.5 +step = 0.1 +unit = "s" + +[[param]] +key = "softness" +label = "Softness" +min = 0 +max = 100 +default = 20 +step = 1 +unit = "%" + +[wgsl] +entry = "effect.wgsl" diff --git a/src/crates/concat-effects/packages/concat.word-reveal/effect.wgsl b/src/crates/concat-effects/packages/concat.word-reveal/effect.wgsl new file mode 100644 index 00000000..7337c4ab --- /dev/null +++ b/src/crates/concat-effects/packages/concat.word-reveal/effect.wgsl @@ -0,0 +1,31 @@ +struct Params { style: f32, duration: f32, softness: f32 } + +// `reveal_order(uv)` is baked with the title: 0 for its first word, 1 for +// its last, 0 everywhere off a title (a no-op there, since progress +// starting at 0 would otherwise hide everything). Progress comes from +// `clip_time`, not a knob: the reveal starts the instant the clip does +// and finishes `duration` seconds later, so dropping the effect on a +// title is the whole gesture - nothing to key by hand. A word crosses +// from hidden to shown as progress passes its own order, softened by +// `softness` into a band so words do not pop in lock-step. +fn effect(uv: vec2) -> vec4 { + let c = sample(uv); + let order = reveal_order(uv); + let p = clamp(frame.clip_time / max(params.duration, 0.05), 0.0, 1.0); + let soft = max(params.softness / 100.0, 0.001); + let shown = 1.0 - smoothstep(p, p + soft, order); + + if (params.style < 0.5) { + // Fade: the word's own alpha ramps in. + return vec4(c.rgb, c.a * shown); + } else if (params.style < 1.5) { + // Blur: the word comes into focus as it fades in. + let hidden = 1.0 - shown; + let blurred = soften(uv, hidden * 14.0); + return vec4(mix(c.rgb, blurred, hidden), c.a * shown); + } else { + // Typewriter: a hard cut, no softness, one word at a time. + let cut = 1.0 - step(p, order); + return vec4(c.rgb, c.a * cut); + } +} diff --git a/src/crates/concat-effects/packages/concat.zoom-blur-transition/effect.toml b/src/crates/concat-effects/packages/concat.zoom-blur-transition/effect.toml new file mode 100644 index 00000000..60d200ba --- /dev/null +++ b/src/crates/concat-effects/packages/concat.zoom-blur-transition/effect.toml @@ -0,0 +1,21 @@ +[effect] +id = "concat.zoom-blur-transition" +name = "Zoom Blur" +kind = "transition" +category = "Zoom" +order = 15 +description = "Rapid inward/outward camera rush with radial zoom blur." + +[[param]] +key = "strength" +label = "Strength" +min = 0 +max = 100 +default = 70 +step = 1 +unit = "%" + +[transition] +entry = "effect.wgsl" +xfade = "zoomin" +fallback = "cross-fade" diff --git a/src/crates/concat-effects/packages/concat.zoom-blur-transition/effect.wgsl b/src/crates/concat-effects/packages/concat.zoom-blur-transition/effect.wgsl new file mode 100644 index 00000000..8203ac38 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.zoom-blur-transition/effect.wgsl @@ -0,0 +1,16 @@ +struct Params { strength: f32 } + +fn transition(uv: vec2, progress: f32) -> vec4 { + let center = vec2(0.5, 0.5); + let dir = uv - center; + let k = sin(progress * 3.14159); + let zoom = 1.0 + k * 0.5; + let blur = k * (params.strength * 0.0006); + var sum = vec4(0.0); + for (var i: i32 = 0; i < 5; i++) { + let scale = 1.0 + f32(i) * blur; + let sample_uv = center + (dir / zoom) * scale; + sum += mix(from_at(sample_uv), to_at(sample_uv), progress); + } + return sum / 5.0; +} diff --git a/src/crates/concat-effects/packages/concat.zoom-blur-transition/ffmpeg.txt b/src/crates/concat-effects/packages/concat.zoom-blur-transition/ffmpeg.txt new file mode 100644 index 00000000..e6d17e3d --- /dev/null +++ b/src/crates/concat-effects/packages/concat.zoom-blur-transition/ffmpeg.txt @@ -0,0 +1 @@ +zoomin diff --git a/src/crates/concat-effects/packages/concat.zoom-blur-transition/main.wgsl b/src/crates/concat-effects/packages/concat.zoom-blur-transition/main.wgsl new file mode 100644 index 00000000..8203ac38 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.zoom-blur-transition/main.wgsl @@ -0,0 +1,16 @@ +struct Params { strength: f32 } + +fn transition(uv: vec2, progress: f32) -> vec4 { + let center = vec2(0.5, 0.5); + let dir = uv - center; + let k = sin(progress * 3.14159); + let zoom = 1.0 + k * 0.5; + let blur = k * (params.strength * 0.0006); + var sum = vec4(0.0); + for (var i: i32 = 0; i < 5; i++) { + let scale = 1.0 + f32(i) * blur; + let sample_uv = center + (dir / zoom) * scale; + sum += mix(from_at(sample_uv), to_at(sample_uv), progress); + } + return sum / 5.0; +} diff --git a/src/crates/concat-effects/packages/concat.zoom-transition/effect.toml b/src/crates/concat-effects/packages/concat.zoom-transition/effect.toml new file mode 100644 index 00000000..b39f2058 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.zoom-transition/effect.toml @@ -0,0 +1,22 @@ +[effect] +id = "concat.zoom-transition" +name = "Zoom" +kind = "transition" +category = "Zoom" +order = 3 +description = "A whip zoom out of the outgoing shot and into the incoming one." + +[[param]] +key = "direction" +label = "Direction" +type = "enum" +min = 0 +max = 1 +default = 0 +values = [0, 1] +labels = ["In", "Out"] + +[transition] +entry = "effect.wgsl" +xfade = "zoomin" +fallback = "cross-fade" diff --git a/src/crates/concat-effects/packages/concat.zoom-transition/effect.wgsl b/src/crates/concat-effects/packages/concat.zoom-transition/effect.wgsl new file mode 100644 index 00000000..7145a4c0 --- /dev/null +++ b/src/crates/concat-effects/packages/concat.zoom-transition/effect.wgsl @@ -0,0 +1,23 @@ +struct Params { direction: f32 } + +fn zoom_uv(uv: vec2, amount: f32) -> vec2 { + return (uv - vec2(0.5)) / max(amount, 0.0001) + vec2(0.5); +} + +// The outgoing picture whips away while the incoming one whips into place; +// "Out" reverses which side does the whipping. +fn transition(uv: vec2, progress: f32) -> vec4 { + let p = smoothstep(0.0, 1.0, progress); + var out_scale: f32; + var in_scale: f32; + if (params.direction < 0.5) { + out_scale = 1.0 + p * 1.6; + in_scale = 1.6 - p * 1.6; + } else { + out_scale = 1.0 - p * 0.6; + in_scale = 0.4 + p * 0.6; + } + let a = from_at(zoom_uv(uv, out_scale)); + let b = to_at(zoom_uv(uv, in_scale)); + return mix(a, b, p); +} diff --git a/src/crates/concat-effects/src/catalogue.rs b/src/crates/concat-effects/src/catalogue.rs index a73ff19e..5f0935a3 100644 --- a/src/crates/concat-effects/src/catalogue.rs +++ b/src/crates/concat-effects/src/catalogue.rs @@ -17,10 +17,10 @@ use serde::Deserialize; use crate::Error; use crate::expr::{Expr, Value}; -use concat_core::{Lut, ShaderPass}; +use concat_core::{Lut, RevealMap, ShaderPass, TransitionPass}; use crate::manifest::{Kind, Manifest}; -use crate::shader::Shader; +use crate::shader::{Shader, TransitionShader}; use crate::template::Template; /// A compiled FFmpeg backend. @@ -73,6 +73,9 @@ pub struct Package { /// The shader, when the package has one: the GPU renders it, and the /// chain - if the package has that too - is the CPU's fallback. shader: Option, + /// The two-input shader, when the package is a transition: the GPU + /// combines the outgoing and incoming pictures with it. + transition: Option, /// The pinned outputs shipped with the package. pub fixtures: Vec, /// The folder the package was loaded from; None for a built-in. @@ -146,9 +149,12 @@ impl Package { message, }; + // The shader file beside the manifest, if any: a `[wgsl]` effect's + // body, or a `[transition]`'s two-input body. + let body = shader; // A `[wgsl]` table without the file, or the file without the table, // is a package that does not know what it is. - let shader = match (&manifest.wgsl, shader) { + let shader = match (&manifest.wgsl, body) { (Some(_), Some(body)) => Some( Shader::compile(&manifest, body) .map_err(|message| invalid(format!("shader: {message}")))?, @@ -159,6 +165,9 @@ impl Package { wgsl.entry ))); } + // A transition's `effect.wgsl` is its two-input shader, compiled + // just below, not a stray effect shader. + (None, Some(_)) if manifest.transition.is_some() => None, (None, Some(_)) => { return Err(invalid( "a shader was given but the manifest has no [wgsl] table".to_owned(), @@ -166,7 +175,24 @@ impl Package { } (None, None) => None, }; - if shader.is_none() && manifest.ffmpeg.is_none() && manifest.effect.kind != Kind::Audio { + let transition = match (&manifest.transition, body) { + (Some(_), Some(body)) => Some( + TransitionShader::compile(&manifest, body) + .map_err(|message| invalid(format!("transition shader: {message}")))?, + ), + (Some(table), None) => { + return Err(invalid(format!( + "[transition] names `{}` but no shader was given", + table.entry + ))); + } + (None, _) => None, + }; + if shader.is_none() + && transition.is_none() + && manifest.ffmpeg.is_none() + && manifest.effect.kind != Kind::Audio + { return Err(invalid( "the package has neither a shader nor a chain".to_owned(), )); @@ -239,6 +265,7 @@ impl Package { manifest, chain, shader, + transition, fixtures, folder, lut, @@ -263,6 +290,125 @@ impl Package { &self.manifest.effect.id } + /// The category declared in the manifest. + pub fn category(&self) -> &str { + &self.manifest.effect.category + } + + /// Whether this package belongs to or matches a category filter. + pub fn matches_category(&self, category: &str) -> bool { + let cat = self.category(); + if category.is_empty() || category.eq_ignore_ascii_case("All") { + return true; + } + if cat.eq_ignore_ascii_case(category) { + return true; + } + match category { + "Featured" => { + cat.eq_ignore_ascii_case("Featured") + || self.manifest.effect.order < 10 + || matches!( + self.id(), + "concat.camera-shake" + | "concat.rgb-glitch" + | "concat.sparkle" + | "concat.light-leak" + | "concat.tilt-shift" + | "concat.strobe-flash" + | "concat.glow" + | "concat.bloom-pulse" + ) + } + "Retro & Film" => { + cat.eq_ignore_ascii_case("Retro") + || cat.eq_ignore_ascii_case("Film") + || cat.eq_ignore_ascii_case("Cinematic") + || cat.eq_ignore_ascii_case("Retro & Film") + || matches!( + self.id(), + "concat.crt-scanlines" + | "concat.film-reel" + | "concat.camcorder-90s" + | "concat.halation" + | "concat.vhs" + | "concat.film-grain" + | "concat.scanlines" + | "concat.dust" + | "concat.falling-dust" + ) + } + "Optical & Lens" => { + cat.eq_ignore_ascii_case("Optical") + || cat.eq_ignore_ascii_case("Lens") + || cat.eq_ignore_ascii_case("Blur") + || cat.eq_ignore_ascii_case("Optical & Lens") + || matches!( + self.id(), + "concat.tilt-shift" + | "concat.prism-dispersion" + | "concat.fisheye" + | "concat.lens-flare" + | "concat.bokeh" + | "concat.gaussian-blur" + | "concat.box-blur" + | "concat.motion-blur" + ) + } + "Distortion & Glitch" => { + cat.eq_ignore_ascii_case("Distort") + || cat.eq_ignore_ascii_case("Glitch") + || cat.eq_ignore_ascii_case("Distortion & Glitch") + || matches!( + self.id(), + "concat.wave-warp" + | "concat.vortex-swirl" + | "concat.prism-dispersion" + | "concat.fisheye" + | "concat.mirror-tile" + | "concat.rgb-glitch" + | "concat.datamosh" + | "concat.ripple" + | "concat.twirl" + | "concat.bulge-pinch" + | "concat.swirl" + | "concat.mirror" + ) + } + "Party & Club" => { + cat.eq_ignore_ascii_case("Party") + || cat.eq_ignore_ascii_case("Club") + || cat.eq_ignore_ascii_case("Party & Club") + || matches!( + self.id(), + "concat.bass-shockwave" + | "concat.laser-beams" + | "concat.neon-glow" + | "concat.color-cycle" + | "concat.strobe-flash" + | "concat.neon" + | "concat.neon-edges" + ) + } + "Light & Shadow" => { + cat.eq_ignore_ascii_case("Light") + || cat.eq_ignore_ascii_case("Shadow") + || cat.eq_ignore_ascii_case("Light & Shadow") + || matches!( + self.id(), + "concat.sparkle" + | "concat.light-leak" + | "concat.strobe-flash" + | "concat.halation" + | "concat.glow" + | "concat.bloom-pulse" + | "concat.lens-flare" + ) + } + _ => false, + } + } + /// Which catalogue the package belongs to. pub fn kind(&self) -> Kind { self.manifest.effect.kind @@ -273,6 +419,31 @@ impl Package { self.shader.as_ref() } + /// The two-input shader, when the package is a transition. + pub fn transition(&self) -> Option<&TransitionShader> { + self.transition.as_ref() + } + + /// The legacy transition id this package degrades to where its shader + /// cannot run - the CPU reference and export without a GPU. `cross-fade` + /// unless the manifest names another. + pub fn transition_fallback(&self) -> &str { + self.manifest + .transition + .as_ref() + .and_then(|table| table.fallback.as_deref()) + .unwrap_or("cross-fade") + } + + /// The FFmpeg `xfade` name this transition maps to for export, if it + /// declares one. + pub fn transition_xfade(&self) -> Option<&str> { + self.manifest + .transition + .as_ref() + .and_then(|table| table.xfade.as_deref()) + } + /// Whether `id` is this package's id or one of its aliases. pub fn answers_to(&self, id: &str) -> bool { self.id() == id || self.manifest.effect.aliases.iter().any(|alias| alias == id) @@ -591,6 +762,16 @@ impl Catalogue { .filter(move |package| package.kind() == kind) } + /// Every package of one kind matching a category filter, in catalogue order. + pub fn of_category<'a>( + &'a self, + kind: Kind, + category: &'a str, + ) -> impl Iterator { + self.of_kind(kind) + .filter(move |package| package.matches_category(category)) + } + /// The complete FFmpeg video filter string for a clip's effects, or the /// empty string if it has none. Effects apply in the order they were /// added. @@ -606,22 +787,56 @@ impl Catalogue { /// The shader passes of a clip's chain, in applied order: one per enabled /// entry whose package has a shader. A filter's intensity rides along; - /// an effect is always whole. - pub fn shader_passes(&self, effects: &[AppliedFilter]) -> Vec { - self.passes_with(effects, |applied| applied.params.clone()) + /// an effect is always whole. `reveal_map` is a title's baked per-word + /// order, carried to every pass exactly as a package's own look-up + /// table is - None for anything that is not a title, harmless for a + /// package that never reads `reveal_order()`. + pub fn shader_passes( + &self, + effects: &[AppliedFilter], + reveal_map: Option>, + ) -> Vec { + self.passes_with(effects, |applied| applied.params.clone(), reveal_map) } /// The same passes at one instant of the clip, `at` in `0..=1`: a /// parameter with keys is worth what its ride says there. What a /// renderer asks for each frame of a clip whose chain rides. - pub fn shader_passes_at(&self, effects: &[AppliedFilter], at: f64) -> Vec { - self.passes_with(effects, |applied| applied.params_at(at)) + pub fn shader_passes_at( + &self, + effects: &[AppliedFilter], + at: f64, + reveal_map: Option>, + ) -> Vec { + self.passes_with(effects, |applied| applied.params_at(at), reveal_map) + } + + /// The two-input pass for a transition package at `progress` in `0..=1`, + /// or None when `id` names no transition this catalogue knows (an unknown + /// id, or one that resolves to an effect/filter). The renderer combines + /// the outgoing and incoming pictures with it. + pub fn transition_pass( + &self, + id: &str, + params: &BTreeMap, + progress: f64, + ) -> Option { + let package = self.get(id)?; + let shader = package.transition()?; + let values = package.resolve(params); + Some(shader.pass( + &values, + &package.manifest.params, + progress.clamp(0.0, 1.0) as f32, + package.lut.clone(), + )) } fn passes_with( &self, effects: &[AppliedFilter], params_of: impl Fn(&AppliedFilter) -> BTreeMap, + reveal_map: Option>, ) -> Vec { effects .iter() @@ -641,6 +856,7 @@ impl Catalogue { &package.manifest.params, intensity, package.lut.clone(), + reveal_map.clone(), )) }) .collect() diff --git a/src/crates/concat-effects/src/lib.rs b/src/crates/concat-effects/src/lib.rs index 827d561b..f21bab11 100644 --- a/src/crates/concat-effects/src/lib.rs +++ b/src/crates/concat-effects/src/lib.rs @@ -31,7 +31,7 @@ mod builtins { pub use catalogue::{At, Catalogue, Fixture, Package}; pub use manifest::{Kind, Manifest, Param, ParamType}; -pub use shader::Shader; +pub use shader::{Shader, TransitionShader}; /// Why a package could not be loaded. #[derive(thiserror::Error, Debug)] @@ -138,7 +138,7 @@ mod tests { "{chain}" ); assert_eq!( - catalogue.shader_passes(&[AppliedFilter::new("test.table")])[0] + catalogue.shader_passes(&[AppliedFilter::new("test.table")], None)[0] .lut .as_ref() .map(|l| l.size), diff --git a/src/crates/concat-effects/src/manifest.rs b/src/crates/concat-effects/src/manifest.rs index 39d7c337..44d246fc 100644 --- a/src/crates/concat-effects/src/manifest.rs +++ b/src/crates/concat-effects/src/manifest.rs @@ -28,6 +28,9 @@ pub struct Manifest { /// The WGSL backend, when the package is a shader. #[serde(default)] pub wgsl: Option, + /// The two-input backend, when the package is a transition. + #[serde(default)] + pub transition: Option, /// A look-up table the package ships: the shader reads it through /// `lut()`, and a chain names its file as `{lut}`. #[serde(default)] @@ -197,6 +200,43 @@ pub struct Wgsl { pub passes: Vec, } +/// The `[transition]` table: a two-input shader that combines the outgoing +/// picture, the incoming one, and a progress into one. A transition's whole +/// backend is here; it never carries an `[ffmpeg]` chain or a `[wgsl]` table, +/// because the per-clip chain machinery those drive does not apply to a cut. +#[derive(Deserialize, Clone, PartialEq, Debug)] +#[serde(deny_unknown_fields)] +pub struct Transition { + /// The shader file, beside the manifest. It declares + /// `fn transition(uv: vec2, progress: f32) -> vec4`. + pub entry: String, + /// A built-in FFmpeg `xfade` transition name for the GPU-less export + /// fallback; absent falls back to a plain dissolve. + #[serde(default)] + pub xfade: Option, + /// The legacy transition id to degrade to where the shader cannot run + /// (the CPU reference, and export without a GPU); absent means + /// `cross-fade`. + #[serde(default)] + pub fallback: Option, +} + +/// The FFmpeg `xfade` transition names a `[transition]` may name as its +/// export fallback. Validated at load so a typo is a load error, not a +/// silent hard cut at export. +pub const XFADE_NAMES: &[&str] = &[ + "fade", "fadeblack", "fadewhite", "fadegrays", "fadefast", "fadeslow", + "dissolve", "pixelize", "distance", "radial", "smoothleft", "smoothright", + "smoothup", "smoothdown", "circleopen", "circleclose", "circlecrop", + "rectcrop", "wipeleft", "wiperight", "wipeup", "wipedown", "wipetl", + "wipetr", "wipebl", "wipebr", "slideleft", "slideright", "slideup", + "slidedown", "vertopen", "vertclose", "horzopen", "horzclose", "diagtl", + "diagtr", "diagbl", "diagbr", "hlslice", "hrslice", "vuslice", "vdslice", + "hblur", "squeezeh", "squeezev", "zoomin", "hlwind", "hrwind", "vuwind", + "vdwind", "coverleft", "coverright", "coverup", "coverdown", "revealleft", + "revealright", "revealup", "revealdown", +]; + /// One render pass of a WGSL package. #[derive(Deserialize, Clone, PartialEq, Debug)] #[serde(deny_unknown_fields)] @@ -326,16 +366,41 @@ impl Manifest { "intensity names `{intensity}`, which is not a parameter" ))); } - // Both backends may be present: the shader renders wherever there - // is a GPU, and the chain is what a machine without one gets. - if self.ffmpeg.is_none() && self.wgsl.is_none() { - return Err(self.invalid("no backend: add an [ffmpeg] or a [wgsl] table")); - } - if self.ffmpeg.is_some() && matches!(self.effect.kind, Kind::Transition | Kind::Generator) { - return Err(self.invalid("an [ffmpeg] package must be an effect, a filter or audio")); - } - if self.wgsl.is_some() && self.effect.kind == Kind::Audio { - return Err(self.invalid("a [wgsl] package cannot be audio")); + if self.effect.kind == Kind::Transition { + // A transition's whole backend is its two-input shader; the + // per-clip chain and single-input shader machinery do not apply. + let Some(transition) = &self.transition else { + return Err(self.invalid("a transition needs a [transition] table")); + }; + if self.ffmpeg.is_some() || self.wgsl.is_some() { + return Err(self.invalid( + "a transition's backend is [transition], not [ffmpeg] or [wgsl]", + )); + } + if let Some(xfade) = &transition.xfade + && !XFADE_NAMES.contains(&xfade.as_str()) + { + return Err(self.invalid(format!( + "[transition] xfade `{xfade}` is not a known FFmpeg xfade name" + ))); + } + } else { + if self.transition.is_some() { + return Err(self.invalid("[transition] is only for a transition package")); + } + // Both backends may be present: the shader renders wherever there + // is a GPU, and the chain is what a machine without one gets. + if self.ffmpeg.is_none() && self.wgsl.is_none() { + return Err(self.invalid("no backend: add an [ffmpeg] or a [wgsl] table")); + } + if self.ffmpeg.is_some() && self.effect.kind == Kind::Generator { + return Err( + self.invalid("an [ffmpeg] package must be an effect, a filter or audio") + ); + } + if self.wgsl.is_some() && self.effect.kind == Kind::Audio { + return Err(self.invalid("a [wgsl] package cannot be audio")); + } } Ok(()) } @@ -418,7 +483,53 @@ mod tests { ); rejects( &GOOD.replace("kind = \"effect\"", "kind = \"transition\""), - "effect, a filter or audio", + "a transition needs a [transition] table", + ); + } + + const GOOD_TRANSITION: &str = r#" + [effect] + id = "concat.dissolve" + name = "Dissolve" + kind = "transition" + aliases = ["cross-fade"] + + [transition] + entry = "effect.wgsl" + xfade = "fade" + "#; + + #[test] + fn a_transition_manifest_parses() { + let manifest = Manifest::parse(GOOD_TRANSITION).expect("parses"); + assert_eq!(manifest.effect.kind, Kind::Transition); + let transition = manifest.transition.expect("a [transition] table"); + assert_eq!(transition.entry, "effect.wgsl"); + assert_eq!(transition.xfade.as_deref(), Some("fade")); + } + + #[test] + fn bad_transition_manifests_are_rejected() { + // A transition may not also carry a per-clip backend. + rejects( + &GOOD_TRANSITION.replace( + "[transition]\n entry = \"effect.wgsl\"\n xfade = \"fade\"", + "[transition]\n entry = \"effect.wgsl\"\n\n [ffmpeg]\n chain = \"null\"", + ), + "not [ffmpeg] or [wgsl]", + ); + // An unknown xfade name is a typo caught at load. + rejects( + &GOOD_TRANSITION.replace("xfade = \"fade\"", "xfade = \"nonsense\""), + "not a known FFmpeg xfade name", + ); + // [transition] belongs only to a transition package. + rejects( + &GOOD.replace( + "[ffmpeg]\n chain = \"gblur=sigma={fixed(radius, 1)}\"", + "[transition]\n entry = \"effect.wgsl\"", + ), + "only for a transition package", ); } diff --git a/src/crates/concat-effects/src/shader.rs b/src/crates/concat-effects/src/shader.rs index cdf4d105..2b1d087d 100644 --- a/src/crates/concat-effects/src/shader.rs +++ b/src/crates/concat-effects/src/shader.rs @@ -21,7 +21,7 @@ use std::collections::BTreeMap; use std::sync::Arc; -use concat_core::{Lut, ShaderPass}; +use concat_core::{Lut, RevealMap, ShaderPass, TransitionPass}; use crate::manifest::{Manifest, Param, ParamType}; @@ -35,6 +35,15 @@ struct Frame { time: f32, /// How much of the effect to keep over the untouched layer. intensity: f32, + /// Seconds since this layer's own clip began - zero at its first + /// frame, however far into the timeline that is. A one-shot look + /// times itself to this instead of `time`, so it plays the same + /// whether the clip starts at zero or at the twenty-minute mark; a + /// looping one can still read `time` for a phase nothing needs to + /// reset. Layers with no single clip of their own - a treatment's + /// stack, a synthesized ground - carry zero here always, which reads + /// as "just started" forever; a look that only loops is unaffected. + clip_time: f32, } @group(0) @binding(0) var source: texture_2d; @@ -43,6 +52,8 @@ struct Frame { @group(1) @binding(1) var params: Params; @group(2) @binding(0) var lut_texture: texture_3d; @group(2) @binding(1) var lut_sampler: sampler; +@group(3) @binding(0) var reveal_texture: texture_2d; +@group(3) @binding(1) var reveal_sampler: sampler; /// The layer's colour at `uv`, straight alpha. fn sample(uv: vec2) -> vec4 { @@ -74,6 +85,15 @@ fn hash(p: vec2, seed: f32) -> f32 { return fract(sin(dot(q, vec3(12.9898, 78.233, 37.719))) * 43758.5453); } +/// A title's per-word reveal order at `uv`, 0..1 - the word painted there, +/// 0 for the first and 1 for the last, and 0 wherever no word was. A +/// pass over anything but a title reads the identity map here, which is +/// 0 everywhere: `reveal_order(uv) <= progress` is then always true, so a +/// package built on it is a no-op off a title with no special casing. +fn reveal_order(uv: vec2) -> f32 { + return textureSampleLevel(reveal_texture, reveal_sampler, uv, 0.0).r; +} + // ── the grading library ── // // Every look is a few of these in different amounts, so they live here, @@ -391,97 +411,13 @@ impl Shader { /// Every declared parameter must be a field of the struct; a field the /// manifest does not declare is allowed and stays zero. pub fn compile(manifest: &Manifest, body: &str) -> Result { - let declares_params = body - .split("struct") - .skip(1) - .any(|rest| rest.trim_start().starts_with("Params")); - if !body.contains("fn effect") { - return Err( - "the shader declares no `fn effect(uv: vec2) -> vec4`".to_owned(), - ); - } - let mut source = String::with_capacity(PRELUDE.len() + body.len() + POSTLUDE.len() + 64); - if !declares_params { - // A package with no knobs still has to bind something. - source.push_str("struct Params { _unused: f32 }\n"); - } - source.push_str(body); - source.push('\n'); - source.push_str(PRELUDE); - source.push_str(POSTLUDE); - - let module = - naga::front::wgsl::parse_str(&source).map_err(|error| error.emit_to_string(&source))?; - let mut validator = naga::valid::Validator::new( - naga::valid::ValidationFlags::all(), - naga::valid::Capabilities::all(), - ); - validator - .validate(&module) - .map_err(|error| error.emit_to_string(&source))?; - if !module - .functions - .iter() - .any(|(_, function)| function.name.as_deref() == Some("effect")) - { - return Err( - "the shader declares no `fn effect(uv: vec2) -> vec4`".to_owned(), - ); - } - budget(&module)?; - - let (members, span) = module - .types - .iter() - .find_map(|(_, ty)| match (&ty.name, &ty.inner) { - (Some(name), naga::TypeInner::Struct { members, span }) if name == "Params" => { - Some((members.clone(), *span as usize)) - } - _ => None, - }) - .ok_or_else(|| "the shader declares no `struct Params`".to_owned())?; - - let mut slots = Vec::new(); - for param in &manifest.params { - let member = members - .iter() - .find(|member| member.name.as_deref() == Some(param.key.as_str())) - .ok_or_else(|| format!("`Params` has no field `{}`", param.key))?; - let inner = &module.types[member.ty].inner; - let wanted = match param.kind { - ParamType::Point => 2, - ParamType::Color => 4, - _ => 1, - }; - let width = match inner { - naga::TypeInner::Scalar(scalar) if is_f32(scalar) => 1, - naga::TypeInner::Vector { size, scalar } if is_f32(scalar) => *size as usize, - _ => 0, - }; - if width != wanted { - return Err(format!( - "`Params.{}` must be {}", - param.key, - match wanted { - 2 => "a vec2", - 4 => "a vec4", - _ => "an f32", - } - )); - } - slots.push(Slot { - key: param.key.clone(), - offset: member.offset as usize, - kind: param.kind, - }); - } - + let (source, slots, span) = stitch(manifest, body, Entry::Effect, PRELUDE, POSTLUDE)?; Ok(Shader { package: manifest.effect.id.clone(), key: format!("{}@{}", manifest.effect.id, manifest.effect.version), - source: Arc::from(source), + source, slots, - span: span.max(ShaderPass::MIN_PARAMS).div_ceil(16) * 16, + span, }) } @@ -494,55 +430,7 @@ impl Shader { /// struct. Every declared parameter is present in `values` by the time /// the catalogue calls this; anything missing reads as zero. pub fn params_bytes(&self, values: &BTreeMap, params: &[Param]) -> Vec { - let mut bytes = vec![0u8; self.span]; - let mut put = |offset: usize, value: f64| { - let at = offset..offset + 4; - if at.end <= bytes.len() { - bytes[at].copy_from_slice(&(value as f32).to_le_bytes()); - } - }; - for slot in &self.slots { - match slot.kind { - ParamType::Point => { - put( - slot.offset, - values - .get(&format!("{}.x", slot.key)) - .copied() - .unwrap_or(0.5), - ); - put( - slot.offset + 4, - values - .get(&format!("{}.y", slot.key)) - .copied() - .unwrap_or(0.5), - ); - } - ParamType::Color => { - // Packed RGBA in one number, as the document stores it. - let packed = values.get(&slot.key).copied().unwrap_or(0.0).max(0.0) as u32; - for (index, shift) in [24u32, 16, 8, 0].into_iter().enumerate() { - put( - slot.offset + index * 4, - f64::from((packed >> shift) & 0xff) / 255.0, - ); - } - } - _ => { - let fallback = params - .iter() - .find(|param| param.key == slot.key) - .map(|param| param.default) - .unwrap_or(0.0); - put( - slot.offset, - values.get(&slot.key).copied().unwrap_or(fallback), - ); - } - } - } - bytes + lay_params(&self.slots, self.span, values, params) } /// A pass over a layer with these values: the uniform buffer written @@ -554,6 +442,7 @@ impl Shader { params: &[Param], intensity: f32, lut: Option>, + reveal_map: Option>, ) -> ShaderPass { ShaderPass { package: self.package.clone(), @@ -563,13 +452,29 @@ impl Shader { values: values.clone(), intensity, lut, + reveal_map, } } } -/// The bindings the prelude declares, and the only ones a package may -/// use: the layer, the frame block and the parameters, the table. -const BINDINGS: [(u32, u32); 6] = [(0, 0), (0, 1), (1, 0), (1, 1), (2, 0), (2, 1)]; +/// The bindings a prelude declares, and the only ones a package may use. +/// The union of both contracts: an effect's layer at group 0 (0,0)-(0,1) +/// and a transition's two pictures at group 0 (0,0)-(0,3); the frame block +/// and parameters at group 1; the look-up table at group 2; an effect's +/// reveal map at group 3 - a transition has no group 3, so nothing there +/// ever validates against a transition's module. +const BINDINGS: [(u32, u32); 10] = [ + (0, 0), + (0, 1), + (0, 2), + (0, 3), + (1, 0), + (1, 1), + (2, 0), + (2, 1), + (3, 0), + (3, 1), +]; /// What a package may not do, however well it parses: bind anything the /// host did not declare, or loop without an end. A community shader runs @@ -647,6 +552,349 @@ fn is_f32(scalar: &naga::Scalar) -> bool { scalar.kind == naga::ScalarKind::Float && scalar.width == 4 } +/// Which entry a stitched module declares: the two shaders share everything +/// but their entry function's name and signature. +#[derive(Clone, Copy)] +enum Entry { + Effect, + Transition, +} + +impl Entry { + /// The function name the body must declare. + fn name(self) -> &'static str { + match self { + Entry::Effect => "effect", + Entry::Transition => "transition", + } + } + + /// The signature named in the "no such function" error. + fn signature(self) -> &'static str { + match self { + Entry::Effect => "fn effect(uv: vec2) -> vec4", + Entry::Transition => "fn transition(uv: vec2, progress: f32) -> vec4", + } + } +} + +/// Stitches a package `body` into the host's contract, parses and validates +/// the result with naga, and reads its `Params` struct for where each declared +/// parameter lands. Shared by [`Shader`] and [`TransitionShader`], which differ +/// only in their prelude/postlude and entry function. Returns the finished +/// module, the parameter slots in declaration order, and the padded uniform +/// span. +fn stitch( + manifest: &Manifest, + body: &str, + entry: Entry, + prelude: &str, + postlude: &str, +) -> Result<(Arc, Vec, usize), String> { + let declares_params = body + .split("struct") + .skip(1) + .any(|rest| rest.trim_start().starts_with("Params")); + if !body.contains(&format!("fn {}", entry.name())) { + return Err(format!("the shader declares no `{}`", entry.signature())); + } + let mut source = String::with_capacity(prelude.len() + body.len() + postlude.len() + 64); + if !declares_params { + // A package with no knobs still has to bind something. + source.push_str("struct Params { _unused: f32 }\n"); + } + source.push_str(body); + source.push('\n'); + source.push_str(prelude); + source.push_str(postlude); + + let module = + naga::front::wgsl::parse_str(&source).map_err(|error| error.emit_to_string(&source))?; + let mut validator = naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ); + validator + .validate(&module) + .map_err(|error| error.emit_to_string(&source))?; + if !module + .functions + .iter() + .any(|(_, function)| function.name.as_deref() == Some(entry.name())) + { + return Err(format!("the shader declares no `{}`", entry.signature())); + } + budget(&module)?; + + let (members, span) = module + .types + .iter() + .find_map(|(_, ty)| match (&ty.name, &ty.inner) { + (Some(name), naga::TypeInner::Struct { members, span }) if name == "Params" => { + Some((members.clone(), *span as usize)) + } + _ => None, + }) + .ok_or_else(|| "the shader declares no `struct Params`".to_owned())?; + + let mut slots = Vec::new(); + for param in &manifest.params { + let member = members + .iter() + .find(|member| member.name.as_deref() == Some(param.key.as_str())) + .ok_or_else(|| format!("`Params` has no field `{}`", param.key))?; + let inner = &module.types[member.ty].inner; + let wanted = match param.kind { + ParamType::Point => 2, + ParamType::Color => 4, + _ => 1, + }; + let width = match inner { + naga::TypeInner::Scalar(scalar) if is_f32(scalar) => 1, + naga::TypeInner::Vector { size, scalar } if is_f32(scalar) => *size as usize, + _ => 0, + }; + if width != wanted { + return Err(format!( + "`Params.{}` must be {}", + param.key, + match wanted { + 2 => "a vec2", + 4 => "a vec4", + _ => "an f32", + } + )); + } + slots.push(Slot { + key: param.key.clone(), + offset: member.offset as usize, + kind: param.kind, + }); + } + + let span = span.max(ShaderPass::MIN_PARAMS).div_ceil(16) * 16; + Ok((Arc::from(source), slots, span)) +} + +/// The `Params` uniform for `values`, laid out to `slots` over a buffer of +/// `span` bytes. Shared by both shaders, which store parameters identically. +fn lay_params( + slots: &[Slot], + span: usize, + values: &BTreeMap, + params: &[Param], +) -> Vec { + let mut bytes = vec![0u8; span]; + let mut put = |offset: usize, value: f64| { + let at = offset..offset + 4; + if at.end <= bytes.len() { + bytes[at].copy_from_slice(&(value as f32).to_le_bytes()); + } + }; + for slot in slots { + match slot.kind { + ParamType::Point => { + put( + slot.offset, + values + .get(&format!("{}.x", slot.key)) + .copied() + .unwrap_or(0.5), + ); + put( + slot.offset + 4, + values + .get(&format!("{}.y", slot.key)) + .copied() + .unwrap_or(0.5), + ); + } + ParamType::Color => { + // Packed RGBA in one number, as the document stores it. + let packed = values.get(&slot.key).copied().unwrap_or(0.0).max(0.0) as u32; + for (index, shift) in [24u32, 16, 8, 0].into_iter().enumerate() { + put( + slot.offset + index * 4, + f64::from((packed >> shift) & 0xff) / 255.0, + ); + } + } + _ => { + let fallback = params + .iter() + .find(|param| param.key == slot.key) + .map(|param| param.default) + .unwrap_or(0.0); + put( + slot.offset, + values.get(&slot.key).copied().unwrap_or(fallback), + ); + } + } + } + bytes +} + +/// The marker that opens the grading library inside [`PRELUDE`]. A transition +/// reuses that same library by slicing it out here rather than copying it. +const GRADING_MARKER: &str = "// ── the grading library ──"; + +/// The shared grading library: the second half of [`PRELUDE`], from the +/// grading marker to the end. It only reads `sample`, `texel`, `luma`, `hash`, +/// `frame.size` and `frame.time`, all of which the transition head supplies, +/// so it works unchanged over two inputs. +fn grading() -> &'static str { + let at = PRELUDE + .find(GRADING_MARKER) + .expect("the prelude carries a grading library"); + &PRELUDE[at..] +} + +/// The transition head: the host's half of a two-input transition. It binds +/// the outgoing picture and the incoming one at group 0, repurposes the +/// frame's spare slot as `progress`, and re-declares the same basics the +/// effect head does so the shared grading library (appended after this) works. +/// `sample` reads the outgoing picture, so a helper like `soften` needs no +/// wiring. +const TRANSITION_HEAD: &str = r#"// ── the host's half of a transition; see concat-effects/src/shader.rs ── +struct Frame { + /// The layer's size in pixels. + size: vec2, + /// Seconds into the timeline. + time: f32, + /// How far through the cut, 0 the outgoing picture, 1 the incoming one. + progress: f32, +} + +@group(0) @binding(0) var from_texture: texture_2d; +@group(0) @binding(1) var from_sampler: sampler; +@group(0) @binding(2) var to_texture: texture_2d; +@group(0) @binding(3) var to_sampler: sampler; +@group(1) @binding(0) var frame: Frame; +@group(1) @binding(1) var params: Params; +@group(2) @binding(0) var lut_texture: texture_3d; +@group(2) @binding(1) var lut_sampler: sampler; + +/// The outgoing picture's colour at `uv`, straight alpha. +fn from_at(uv: vec2) -> vec4 { + return textureSample(from_texture, from_sampler, uv); +} + +/// The incoming picture's colour at `uv`, straight alpha. +fn to_at(uv: vec2) -> vec4 { + return textureSample(to_texture, to_sampler, uv); +} + +/// What the shared grading helpers read: the outgoing picture, so `soften` +/// and its like work on the from-side with no wiring by the author. +fn sample(uv: vec2) -> vec4 { + return from_at(uv); +} + +/// One pixel, as a fraction of the layer. +fn texel() -> vec2 { + return vec2(1.0, 1.0) / frame.size; +} + +/// Luminance, Rec. 709. +fn luma(rgb: vec3) -> f32 { + return dot(rgb, vec3(0.2126, 0.7152, 0.0722)); +} + +/// The package's look-up table applied to a colour - the identity when the +/// package ships none. Sampled at the texel centres. +fn lut(rgb: vec3) -> vec3 { + let n = f32(textureDimensions(lut_texture).x); + let uvw = clamp(rgb, vec3(0.0), vec3(1.0)) * (n - 1.0) / n + vec3(0.5 / n); + return textureSampleLevel(lut_texture, lut_sampler, uvw, 0.0).rgb; +} + +/// A hash in 0..1 from a point and a seed, for grain and dither. +fn hash(p: vec2, seed: f32) -> f32 { + let q = vec3(p, seed); + return fract(sin(dot(q, vec3(12.9898, 78.233, 37.719))) * 43758.5453); +} + +"#; + +/// The transition's draw stages: a full-screen triangle, and a fragment that +/// hands the whole result to the package's `transition` - it owns the blend, +/// so the pipeline does no mixing of its own. +const TRANSITION_POSTLUDE: &str = r#" +struct VsOut { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} + +@vertex +fn vs_main(@builtin(vertex_index) index: u32) -> VsOut { + let x = f32(i32(index & 1u) * 4 - 1); + let y = f32(i32(index >> 1u) * 4 - 1); + var out: VsOut; + out.position = vec4(x, y, 0.0, 1.0); + out.uv = vec2((x + 1.0) * 0.5, 1.0 - (y + 1.0) * 0.5); + return out; +} + +@fragment +fn fs_main(in: VsOut) -> @location(0) vec4 { + return transition(in.uv, frame.progress); +} +"#; + +/// A transition package's shader, stitched, checked and laid out. +#[derive(Clone, Debug)] +pub struct TransitionShader { + key: String, + source: Arc, + slots: Vec, + span: usize, +} + +impl TransitionShader { + /// Stitches `body` into the transition contract - two bound pictures and a + /// progress - checks it, and reads the `Params` struct for its parameters' + /// layout. Every declared parameter must be a field of the struct. + pub fn compile(manifest: &Manifest, body: &str) -> Result { + let prelude = format!("{TRANSITION_HEAD}{}", grading()); + let (source, slots, span) = + stitch(manifest, body, Entry::Transition, &prelude, TRANSITION_POSTLUDE)?; + Ok(TransitionShader { + key: format!("{}@{}", manifest.effect.id, manifest.effect.version), + source, + slots, + span, + }) + } + + /// The stitched module. + pub fn source(&self) -> &str { + &self.source + } + + /// The `Params` buffer for these resolved values, laid out to the struct. + pub fn params_bytes(&self, values: &BTreeMap, params: &[Param]) -> Vec { + lay_params(&self.slots, self.span, values, params) + } + + /// A combine of two layers at `progress` with these values. + pub fn pass( + &self, + values: &BTreeMap, + params: &[Param], + progress: f32, + lut: Option>, + ) -> TransitionPass { + TransitionPass { + key: self.key.clone(), + source: Arc::clone(&self.source), + params: self.params_bytes(values, params), + progress, + lut, + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -742,10 +990,10 @@ fn effect(uv: vec2) -> vec4 { ); let extra = Shader::compile( &manifest, - "@group(3) @binding(0) var other: texture_2d;\nfn effect(uv: vec2) -> vec4 { return sample(uv) + textureSample(other, source_sampler, uv); }", + "@group(4) @binding(0) var other: texture_2d;\nfn effect(uv: vec2) -> vec4 { return sample(uv) + textureSample(other, source_sampler, uv); }", ); assert!( - extra.unwrap_err().contains("@group(3)"), + extra.unwrap_err().contains("@group(4)"), "a binding the host does not provide" ); let bounded = Shader::compile( @@ -777,4 +1025,62 @@ label = "Amount" let no_effect = Shader::compile(&manifest, "struct Params { amount: f32 }"); assert!(no_effect.unwrap_err().contains("fn effect")); } + + fn transition_manifest(params: &str) -> Manifest { + Manifest::parse(&format!( + r#" +[effect] +id = "test.wipe" +name = "Wipe" +kind = "transition" +{params} +[transition] +entry = "effect.wgsl" +"# + )) + .expect("a valid manifest") + } + + #[test] + fn a_transition_body_is_stitched_over_two_inputs() { + let manifest = transition_manifest( + r#" +[[param]] +key = "softness" +label = "Softness" +min = 0 +max = 1 +default = 0.5 +"#, + ); + let shader = TransitionShader::compile( + &manifest, + r#" +struct Params { softness: f32 } +fn transition(uv: vec2, progress: f32) -> vec4 { + // Reads both inputs, the shared grading library, and a knob. + let a = soften(uv, params.softness * 4.0); + return mix(from_at(uv), to_at(uv), clamp(progress, 0.0, 1.0)) + vec4(a * 0.0, 0.0); +} +"#, + ) + .expect("compiles"); + assert_eq!(shader.key, "test.wipe@1"); + assert!(shader.source().contains("fn fs_main")); + assert!(shader.source().contains("frame.progress")); + assert!(shader.source().contains("to_texture")); + let pass = shader.pass(&BTreeMap::from([("softness".to_owned(), 0.5)]), &manifest.params, 0.25, None); + assert_eq!(pass.progress, 0.25); + assert_eq!(pass.params.len(), 16); + } + + #[test] + fn a_transition_without_its_entry_is_refused() { + let manifest = transition_manifest(""); + let no_entry = TransitionShader::compile( + &manifest, + "fn effect(uv: vec2) -> vec4 { return from_at(uv); }", + ); + assert!(no_entry.unwrap_err().contains("fn transition")); + } } diff --git a/src/crates/concat-export/src/lib.rs b/src/crates/concat-export/src/lib.rs index 248b1fcd..bb6edcac 100644 --- a/src/crates/concat-export/src/lib.rs +++ b/src/crates/concat-export/src/lib.rs @@ -24,16 +24,17 @@ pub mod chains; pub mod flatten; mod resolve; -use resolve::{BuiltTimeline, Treatment, animation_of, build_timeline, quantise}; +use resolve::{BuiltTimeline, TransitionSpan, Treatment, animation_of, build_timeline, quantise}; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; +use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use concat_core::SpeedCurve; use concat_core::animate::{Animation, Ease as AnimEase, Key as AnimKey, Track as AnimTrack}; use concat_core::frame::Frame; -use concat_core::shader::ShaderPass; +use concat_core::shader::{RevealMap, ShaderPass}; use concat_core::time::{FrameRate, Rational}; use concat_core::timeline::{Clip, ClipId, MediaRef, Timeline, Track, TrackKind, Transform}; use concat_effects::Catalogue; @@ -211,6 +212,12 @@ pub struct ExportClip { /// - or empty when the flattener had no project folder to name it by. #[serde(default)] pub mask_dir: String, + /// A title's per-word reveal order, baked at rasterize time and set by + /// the host - never by the wire format, like `transition_chain`. Every + /// shader pass over this clip carries it, so a reveal effect can read + /// it back through `reveal_order()`. + #[serde(skip)] + pub reveal_map: Option>, } impl ExportClip { @@ -260,6 +267,7 @@ impl ExportClip { cutout: None, mask_dir: String::new(), highlighted: false, + reveal_map: None, } } } @@ -394,7 +402,9 @@ impl Reporter<'_> { /// Turns per-cut transition requests into things the renderer already knows /// how to draw: overlapping clips, opacity ramps, placement keys, and fade -/// and mask filters. +/// and mask filters. Returns the packaged transitions found along the way, +/// for the compositor to combine over; every other kind needs nothing more +/// than what it already baked into the clips themselves. /// /// Track indices are doubled first, so an incoming clip gets an odd lane of /// its own directly above the pair it crosses over - stacking against every @@ -414,10 +424,22 @@ impl Reporter<'_> { /// monitor shows a dissolve in its place - the same split the fades to a /// colour make, and for the same reason: the filter counts frames from the /// clip's start, which the monitor's pooled seeks do not. -fn resolve_transitions(clips: &mut [ExportClip], rate: FrameRate, bake_fades: bool) { +/// +/// An id the catalogue knows as a transition package gets the same overlap +/// plus a dissolve ramp, but the returned [`TransitionSpan`] tells the +/// compositor a shader owns the blend where one can run: see +/// `combine_transition`. Legacy ids are matched first and never reach this +/// path, so a project saved before packaged transitions existed renders +/// exactly as it always did. +fn resolve_transitions( + clips: &mut [ExportClip], + rate: FrameRate, + bake_fades: bool, +) -> Vec { for clip in clips.iter_mut() { clip.track *= 2; } + let mut spans: Vec = Vec::new(); let fps = rate.fps().as_f64(); let frame = 1.0 / fps; @@ -465,22 +487,15 @@ fn resolve_transitions(clips: &mut [ExportClip], rate: FrameRate, bake_fades: bo }; let b = &mut clips[cut.incoming]; - // The incoming clip extends backwards over the outgoing one, - // showing the source it has *before* its in-point - the - // handle, exactly what a dissolve consumes in any editor. No - // handle, shorter dissolve: the duration clamps to what - // actually exists rather than freezing or inventing frames. - let mut d = cut.duration.min(a_duration).min(b.duration); - if b.kind != ClipKind::Image { - d = d.min(b.source_start / b.speed.max(0.0625)); - } + // The incoming clip extends backwards over the outgoing one. + let d = cut.duration.min(a_duration).min(b.duration); if d < frame { continue; } b.start -= d; b.duration += d; if b.kind != ClipKind::Image { - b.source_start -= d * b.speed; + b.source_start = (b.source_start - d * b.speed).max(0.0); } // Sound rides the picture: the pre-roll fades in rather than // arriving at full level a dissolve early. @@ -605,12 +620,52 @@ fn resolve_transitions(clips: &mut [ExportClip], rate: FrameRate, bake_fades: bo ); } } + // A packaged transition: overlap the incoming clip onto the + // outgoing one exactly as a dissolve does - the fallback where the + // shader cannot run - and record a span the compositor combines + // over with the package's two-input shader. Legacy ids are matched + // above first, so an id aliased to a package still takes the + // hand-written path saved projects expect. + kind if Catalogue::builtin() + .get(kind) + .is_some_and(|package| package.transition().is_some()) => + { + let (a_track, a_duration) = { + let a = &clips[cut.outgoing]; + (a.track, a.duration) + }; + let b = &mut clips[cut.incoming]; + let d = cut.duration.min(a_duration).min(b.duration); + if d < frame { + continue; + } + b.start -= d; + b.duration += d; + if b.kind != ClipKind::Image { + b.source_start = (b.source_start - d * b.speed).max(0.0); + } + b.fade_in = b.fade_in.max(d); + b.track = a_track + 1; + // The dissolve a GPU-less path shows; the shader's own blend + // overrides it where a GPU runs the transition. + b.video_fade_in = d; + let start = quantise(b.start, rate); + let end = start + quantise(d, rate); + spans.push(TransitionSpan { + start, + end, + to_track: a_track + 1, + id: cut.kind.clone(), + params: BTreeMap::new(), + }); + } // A kind this build does not know renders as a plain cut rather // than failing the export - the same degrade a missing effect // filter must NOT get, because there the user styled the picture. _ => {} } } + spans } /// The timing functions the transition shapes ride on, as `ExportKey` holds @@ -688,7 +743,7 @@ pub fn render(request: &ExportRequest, mut reporter: Reporter<'_>) -> Result) -> Result, destination: &Path, reporter: &mut Reporter<'_>, ) -> Result<(), String> { @@ -960,11 +1017,13 @@ fn render_picture( filter_chains, tracks, treatments, + transitions, pre_chains, chains, + reveal_maps, cutouts, highlight: _, - } = build_timeline(request, rate, visible, gpu); + } = build_timeline(request, rate, visible, gpu, transitions); let mut encoder = Encoder::create( destination, @@ -1027,7 +1086,7 @@ fn render_picture( layer, frame, tracks.get(&layer.clip).copied().unwrap_or(0), - passes_at(&chains, &timeline, layer.clip, time), + passes_at(&chains, &reveal_maps, &timeline, layer.clip, time), )); } continue; @@ -1098,7 +1157,7 @@ fn render_picture( layer, std::sync::Arc::new(frame), tracks.get(&layer.clip).copied().unwrap_or(0), - passes_at(&chains, &timeline, layer.clip, time), + passes_at(&chains, &reveal_maps, &timeline, layer.clip, time), )); } } @@ -1113,6 +1172,7 @@ fn render_picture( treatments: Vec::new(), }, &treatments, + &transitions, ); encoder .write_frame(&composed) @@ -1133,9 +1193,12 @@ fn render_picture( /// The shader passes for one clip at one instant: every keyed knob at its /// value there, the rest at their settings, laid out as the uniforms the -/// shaders read. +/// shaders read. `reveal_maps` carries a title's baked per-word order +/// alongside its chain - unlike an effect's knobs, it is fixed for the +/// clip's whole life, so it is looked up rather than resolved. fn passes_at( chains: &HashMap>, + reveal_maps: &HashMap>, timeline: &Timeline, clip: ClipId, time: Rational, @@ -1146,7 +1209,7 @@ fn passes_at( let at = timeline .clip(clip) .map_or(0.0, |engine_clip| engine_clip.fraction_at(time)); - Catalogue::builtin().shader_passes_at(effects, at) + Catalogue::builtin().shader_passes_at(effects, at, reveal_maps.get(&clip).cloned()) } /// `a` towards `b` by `amount`, per channel. @@ -1170,8 +1233,22 @@ fn composite_treated( compositor: &mut dyn Compositor, mut plan: FramePlan, treatments: &[Treatment], + transitions: &[TransitionSpan], ) -> Frame { let time = plan.time; + // A packaged transition live at this instant combines the outgoing stack + // with the incoming picture through its shader. A live adjustment layer + // over the same frame is the one case we leave to the dissolve fallback, + // so the two never fight over the ground - a rare pairing, and the cut + // still transitions, just without its shader. + let treated_now = treatments.iter().any(|treatment| treatment.covers(time)); + if !treated_now + && let Some(span) = transitions.iter().find(|span| span.covers(time)) + && let Some(frame) = combine_transition(compositor, &plan, span) + { + return frame; + } + let mut live: Vec<&Treatment> = treatments .iter() .filter(|treatment| treatment.covers(time)) @@ -1249,6 +1326,66 @@ fn composite_treated( compositor.render(&stage(stack)) } +/// The frame when a packaged transition is live: the outgoing stack +/// (everything below the incoming lane) combined with the incoming picture +/// (drawn over that stack at full opacity) through the transition's shader. +/// `None` when the compositor cannot run the shader or the package is unknown, +/// and the caller then shows the dissolve the incoming clip already carries. +fn combine_transition( + compositor: &mut dyn Compositor, + plan: &FramePlan, + span: &TransitionSpan, +) -> Option { + let pass = + Catalogue::builtin().transition_pass(&span.id, &span.params, span.progress(plan.time))?; + let (width, height, time) = (plan.width, plan.height, plan.time); + let stage = |layers: Vec| FramePlan { + time, + width, + height, + layers, + treatments: Vec::new(), + }; + // The outgoing picture: the stack strictly below the incoming lane. + let from_layers: Vec = plan + .layers + .iter() + .filter(|layer| layer.track < span.to_track) + .cloned() + .collect(); + // The incoming picture over that stack, its own layer forced to full + // opacity so the shader - not the dissolve ramp - owns the blend. + let to_layers: Vec = plan + .layers + .iter() + .filter(|layer| layer.track <= span.to_track) + .cloned() + .map(|mut layer| { + if layer.track == span.to_track { + layer.opacity = 1.0; + } + layer + }) + .collect(); + let from = compositor.render(&stage(from_layers)); + let to = compositor.render(&stage(to_layers)); + let combined = compositor.combine(width, height, time.as_f64() as f32, &from, &to, &pass)?; + // Anything above the incoming lane draws over the combined picture. + let above: Vec = plan + .layers + .iter() + .filter(|layer| layer.track > span.to_track) + .cloned() + .collect(); + if above.is_empty() { + Some(combined) + } else { + let mut layers = vec![ground_layer(combined)]; + layers.extend(above); + Some(compositor.render(&stage(layers))) + } +} + /// One paused-monitor frame: the same clip list the exporter takes, one /// timestamp, a preview resolution. #[derive(Deserialize)] @@ -1293,6 +1430,7 @@ pub fn preview_frame( pub struct PreviewSources { plan: FramePlan, treatments: Vec, + transitions: Vec, } impl PreviewSources { @@ -1304,13 +1442,19 @@ impl PreviewSources { &self.plan } - /// Whether a live treatment needs FFmpeg for a package with no - /// shader, in which case the frame can only be drawn whole through - /// [`PreviewSources::composite`]. + /// Whether the frame cannot be drawn from [`PreviewSources::plan`] + /// alone, and can only be drawn whole through + /// [`PreviewSources::composite`]: a live treatment needs FFmpeg for a + /// package with no shader, or a live transition is a two-input combine + /// a plain `FramePlan` has no way to describe. pub fn needs_cpu(&self) -> bool { self.treatments .iter() .any(|treatment| treatment.covers(self.plan.time) && !treatment.chain.is_empty()) + || self + .transitions + .iter() + .any(|span| span.covers(self.plan.time)) } /// The instant, in seconds. @@ -1318,9 +1462,14 @@ impl PreviewSources { self.plan.seconds() } - /// The frame, drawn by `compositor`, every treatment applied. + /// The frame, treatments and transitions included, drawn with `compositor`. pub fn composite(&self, compositor: &mut dyn Compositor) -> Frame { - composite_treated(compositor, self.plan.clone(), &self.treatments) + composite_treated( + compositor, + self.plan.clone(), + &self.treatments, + &self.transitions, + ) } } @@ -1373,8 +1522,10 @@ pub fn preview_sources_of( filter_chains, tracks, treatments, + transitions, pre_chains, chains, + reveal_maps, cutouts, highlight, } = &plan.built; @@ -1418,7 +1569,7 @@ pub fn preview_sources_of( layer, frame, tracks.get(&layer.clip).copied().unwrap_or(0), - passes_at(chains, timeline, layer.clip, time), + passes_at(chains, reveal_maps, timeline, layer.clip, time), )) } Err(error) => failures.push(format!("{}: {error}", layer.media.display())), @@ -1466,6 +1617,7 @@ pub fn preview_sources_of( Ok(PreviewSources { plan: frame_plan, treatments: treatments.clone(), + transitions: transitions.clone(), }) } @@ -1494,7 +1646,7 @@ pub fn preview_plan( ) -> PreviewPlan { let rate = FrameRate::new(Rational::new(rate_num, rate_den)); let mut resolved = clips.to_vec(); - resolve_transitions(&mut resolved, rate, false); + let transitions = resolve_transitions(&mut resolved, rate, false); let visible: Vec<&ExportClip> = resolved .iter() .filter(|clip| (clip.kind.is_visual() || clip.kind == ClipKind::Layer) && !clip.hidden) @@ -1517,7 +1669,7 @@ pub fn preview_plan( clips: Vec::new(), }; PreviewPlan { - built: build_timeline(&shim, rate, &visible, gpu), + built: build_timeline(&shim, rate, &visible, gpu, transitions), rate, width, height, @@ -1675,6 +1827,7 @@ mod tests { &mut compositor, stack(Rational::from_int(1)), std::slice::from_ref(&negate), + &[], ); // Red negated is cyan where nothing sits on top... let at = |x: usize, y: usize| &out.pixels()[(y * 8 + x) * 4..(y * 8 + x) * 4 + 3]; @@ -1687,13 +1840,23 @@ mod tests { strength: 0.5, ..negate.clone() }; - let out = composite_treated(&mut compositor, stack(Rational::from_int(1)), &[half]); + let out = composite_treated( + &mut compositor, + stack(Rational::from_int(1)), + &[half], + &[], + ); let pixel = &out.pixels()[(7 * 8 + 7) * 4..(7 * 8 + 7) * 4 + 3]; assert!(pixel[0] > 120 && pixel[0] < 136, "{pixel:?}"); assert!(pixel[1] > 120 && pixel[1] < 136, "{pixel:?}"); // Outside its span the treatment does nothing. - let out = composite_treated(&mut compositor, stack(Rational::from_int(20)), &[negate]); + let out = composite_treated( + &mut compositor, + stack(Rational::from_int(20)), + &[negate], + &[], + ); assert_eq!(at_of(&out, 7, 7), [255, 0, 0]); fn at_of(frame: &Frame, x: usize, y: usize) -> [u8; 3] { let p = &frame.pixels()[(y * 8 + x) * 4..(y * 8 + x) * 4 + 3]; @@ -1835,7 +1998,36 @@ mod tests { } #[test] - fn a_cross_fade_clamps_to_the_available_handle() { + fn a_packaged_transition_id_overlaps_the_clip_and_emits_a_span() { + // A transition named by its package id (not one of the seven legacy + // strings) takes the new path: the same overlap a dissolve gets, plus + // a `TransitionSpan` for the compositor to combine over. + let mut clips = vec![ + clip("video", 0, 0.0, 4.0, 0.0), + clip("video", 0, 4.0, 4.0, 2.0), + ]; + clips[1].transition = spec("concat.dissolve", 1.0); + let spans = resolve_transitions(&mut clips, FrameRate::THIRTY, true); + + let b = &clips[1]; + assert_eq!(b.start, 3.0, "extends backwards over the cut, like a dissolve"); + assert_eq!(b.duration, 5.0); + assert_eq!(b.video_fade_in, 1.0, "the GPU-less fallback dissolve"); + assert_eq!(b.track, 1); + + assert_eq!(spans.len(), 1, "one packaged transition over the cut"); + let span = &spans[0]; + assert_eq!(span.id, "concat.dissolve"); + assert_eq!(span.to_track, 1, "the incoming clip's own lane"); + assert_eq!(span.start, Rational::from_int(3)); + assert_eq!(span.end, Rational::from_int(4)); + assert!((span.progress(Rational::from_int(3)) - 0.0).abs() < 1e-9); + assert!((span.progress(Rational::new(35, 10)) - 0.5).abs() < 1e-9); + assert!((span.progress(Rational::from_int(4)) - 1.0).abs() < 1e-9); + } + + #[test] + fn a_cross_fade_clamps_source_start_to_zero_not_duration() { let mut clips = vec![ clip("video", 0, 0.0, 4.0, 0.0), clip("video", 0, 4.0, 4.0, 0.25), @@ -1843,11 +2035,45 @@ mod tests { clips[1].transition = spec("cross-fade", 2.0); resolve_transitions(&mut clips, FrameRate::THIRTY, true); - // Only a quarter second of source exists before the in-point, so that - // is the whole dissolve. - assert_eq!(clips[1].video_fade_in, 0.25); + // Even with limited handle, the transition preserves its full duration, + // clamping source_start to 0.0 rather than shortening the dissolve. + assert_eq!(clips[1].video_fade_in, 2.0); assert_eq!(clips[1].source_start, 0.0); - assert_eq!(clips[1].start, 3.75); + assert_eq!(clips[1].start, 2.0); + assert_eq!(clips[1].duration, 6.0); + } + + #[test] + fn a_cross_fade_on_untrimmed_clip_succeeds() { + let mut clips = vec![ + clip("video", 0, 0.0, 4.0, 0.0), + clip("video", 0, 4.0, 4.0, 0.0), + ]; + clips[1].transition = spec("cross-fade", 1.0); + resolve_transitions(&mut clips, FrameRate::THIRTY, true); + + let b = &clips[1]; + assert_eq!(b.start, 3.0); + assert_eq!(b.duration, 5.0); + assert_eq!(b.source_start, 0.0); + assert_eq!(b.video_fade_in, 1.0); + } + + #[test] + fn a_packaged_transition_on_untrimmed_clip_succeeds() { + let mut clips = vec![ + clip("video", 0, 0.0, 4.0, 0.0), + clip("video", 0, 4.0, 4.0, 0.0), + ]; + clips[1].transition = spec("concat.dissolve", 1.0); + let spans = resolve_transitions(&mut clips, FrameRate::THIRTY, true); + + let b = &clips[1]; + assert_eq!(b.start, 3.0); + assert_eq!(b.duration, 5.0); + assert_eq!(b.source_start, 0.0); + assert_eq!(b.video_fade_in, 1.0); + assert_eq!(spans.len(), 1); } #[test] diff --git a/src/crates/concat-export/src/resolve.rs b/src/crates/concat-export/src/resolve.rs index ada2c296..3a98ca87 100644 --- a/src/crates/concat-export/src/resolve.rs +++ b/src/crates/concat-export/src/resolve.rs @@ -96,14 +96,50 @@ pub(crate) struct BuiltTimeline { /// resolved from them at each frame, because a knob with keys is worth /// something different each frame and the resolution is cheap. pub(crate) chains: HashMap>, + /// A title's per-word reveal order, for the clips that have one - + /// carried beside `chains` rather than inside it, since it is baked + /// once by the host and never resolved per frame the way effects are. + pub(crate) reveal_maps: HashMap>, /// The clip whose cutout is drawn tinted rather than cut, if one is. pub(crate) highlight: Option, /// The layers: treatments over the stack, by span. pub(crate) treatments: Vec, + /// The packaged transitions over cuts, resolved before the timeline. + pub(crate) transitions: Vec, /// The clips whose background a mask takes away. pub(crate) cutouts: HashMap, } +/// A packaged transition over a cut. The incoming clip has been overlapped +/// onto the outgoing one and moved to `to_track`; over `[start, end)` the +/// compositor combines the stack below `to_track` (the outgoing picture) with +/// the incoming layer through the package's two-input shader. A machine with +/// no GPU shows the dissolve the incoming clip already carries instead. +#[derive(Clone, Debug)] +pub(crate) struct TransitionSpan { + pub(crate) start: Rational, + pub(crate) end: Rational, + pub(crate) to_track: usize, + pub(crate) id: String, + pub(crate) params: BTreeMap, +} + +impl TransitionSpan { + pub(crate) fn covers(&self, time: Rational) -> bool { + self.start <= time && time < self.end + } + + /// How far through the cut `time` is, `0..=1`. + pub(crate) fn progress(&self, time: Rational) -> f64 { + let span = self.end.as_f64() - self.start.as_f64(); + if span <= 0.0 { + return 1.0; + } + ((time.as_f64() - self.start.as_f64()) / span).clamp(0.0, 1.0) + } +} + + /// A layer clip, as the compositor needs it: when, over which tracks, what /// chain, and how hard. #[derive(Clone, Debug)] @@ -126,7 +162,8 @@ impl Treatment { self.start <= time && time < self.end } - /// The shader passes at `time`, each keyed knob at its value there. + /// The shader passes at `time`, each keyed knob at its value there. A + /// layer is never a title, so it never carries a reveal map. pub(crate) fn passes_at(&self, time: Rational) -> Vec { let span = (self.end - self.start).as_f64(); let at = if span > 0.0 { @@ -134,7 +171,7 @@ impl Treatment { } else { 0.0 }; - Catalogue::builtin().shader_passes_at(&self.effects, at) + Catalogue::builtin().shader_passes_at(&self.effects, at, None) } /// How hard the treatment is applied at `time`: the strength, eased in @@ -159,6 +196,7 @@ pub(crate) fn build_timeline( rate: FrameRate, visible: &[&ExportClip], gpu: bool, + transitions: Vec, ) -> BuiltTimeline { let mut timeline = Timeline::new(request.width, request.height, rate); let mut stills = std::collections::HashSet::new(); @@ -168,6 +206,7 @@ pub(crate) fn build_timeline( let mut treatments: Vec = Vec::new(); let mut pre_chains: HashMap = HashMap::new(); let mut chains: HashMap> = HashMap::new(); + let mut reveal_maps: HashMap> = HashMap::new(); let mut cutouts: HashMap = HashMap::new(); let mut highlight: Option = None; @@ -256,6 +295,9 @@ pub(crate) fn build_timeline( if !effects.is_empty() { chains.insert(id, effects); } + if let Some(map) = &clip.reveal_map { + reveal_maps.insert(id, Arc::clone(map)); + } } if let Some(job) = CutoutJob::of(clip) { cutouts.insert(id, job); @@ -273,8 +315,10 @@ pub(crate) fn build_timeline( filter_chains, tracks: tracks_of, treatments, + transitions, pre_chains, chains, + reveal_maps, cutouts, highlight, } diff --git a/src/crates/concat-host/src/media.rs b/src/crates/concat-host/src/media.rs index c7feb251..cebfbd1a 100644 --- a/src/crates/concat-host/src/media.rs +++ b/src/crates/concat-host/src/media.rs @@ -896,11 +896,11 @@ mod tests { assert_eq!(fnv1a(b""), 0xcbf2_9ce4_8422_2325); assert_eq!( peaks_key("/a.mp4", None), - format!("{:016x}-b200.peaks", fnv1a(b"/a.mp4")) + format!("{:016x}-b1000.peaks", fnv1a(b"/a.mp4")) ); assert_eq!( peaks_key("/a.mp4", Some(2)), - format!("{:016x}-s2-b200.peaks", fnv1a(b"/a.mp4")) + format!("{:016x}-s2-b1000.peaks", fnv1a(b"/a.mp4")) ); } diff --git a/src/crates/concat-host/src/titles.rs b/src/crates/concat-host/src/titles.rs index 2fe6d315..e870da23 100644 --- a/src/crates/concat-host/src/titles.rs +++ b/src/crates/concat-host/src/titles.rs @@ -21,11 +21,12 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use concat_core::frame::Frame; +use concat_core::shader::RevealMap; use concat_export::chains::video_effect_chain; use concat_export::{ClipKind, ExportClip}; use concat_project::model::{ClipKind as ModelClipKind, Project, TextAlign, TextStyle}; -use concat_text::{Align, Fonts, TitleStyle}; +use concat_text::{Align, Fonts, TitleStyle, WordRect}; use crate::dirs::AppDirs; @@ -50,10 +51,13 @@ pub struct TitleClip { } /// What one render left behind. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] struct Art { block: (u32, u32), offset: (i32, i32), + /// The title's per-word reveal order, baked once here rather than on + /// every clip build - see `concat_core::RevealMap`. + reveal: Arc, } /// The title painter and its cache. @@ -170,6 +174,7 @@ impl Titles { media_width: Some(width), media_height: Some(height), has_audio: Some(false), + reveal_map: Some(Arc::clone(&art.reveal)), ..ExportClip::blank(ClipKind::Image, clip.start, clip.duration, index) }, block: art.block, @@ -198,7 +203,7 @@ impl Titles { .0 .get(&key) { - return Ok((path, *art, Arc::clone(frame))); + return Ok((path, art.clone(), Arc::clone(frame))); } let title = title_style(style); let rendered = { @@ -216,6 +221,7 @@ impl Titles { let art = Art { block: (rendered.block_width, rendered.block_height), offset: (rendered.block_dx, rendered.block_dy), + reveal: reveal_of(&rendered.words, width, height), }; let frame = Arc::new( Frame::from_rgba(rendered.width, rendered.height, rendered.rgba) @@ -223,7 +229,7 @@ impl Titles { ); let mut live = self.live.lock().unwrap_or_else(|e| e.into_inner()); let (kept, order) = &mut *live; - if kept.insert(key, (art, Arc::clone(&frame))).is_none() { + if kept.insert(key, (art.clone(), Arc::clone(&frame))).is_none() { order.push_back(key); } while order.len() > LIVE_KEPT { @@ -254,14 +260,15 @@ impl Titles { .get(&key) && png.is_file() { - return Ok((png, *art)); + return Ok((png, art.clone())); } // On disk from an earlier session: the sidecar says how big the - // block was and where, which the PNG alone cannot. + // block was and where, and which words it held, which the PNG + // alone cannot. if png.is_file() - && let Some(art) = read_block(&side) + && let Some(art) = read_block(&side, width, height) { - self.remember(key, art); + self.remember(key, art.clone()); return Ok((png, art)); } @@ -283,15 +290,25 @@ impl Titles { let art = Art { block: (rendered.block_width, rendered.block_height), offset: (rendered.block_dx, rendered.block_dy), + reveal: reveal_of(&rendered.words, width, height), }; + let words: Vec = rendered + .words + .iter() + .map(|w| format!("[{},{},{},{}]", w.x, w.y, w.width, w.height)) + .collect(); let _ = std::fs::write( &side, format!( - "{{\"w\":{},\"h\":{},\"x\":{},\"y\":{}}}", - art.block.0, art.block.1, art.offset.0, art.offset.1 + "{{\"w\":{},\"h\":{},\"x\":{},\"y\":{},\"words\":[{}]}}", + art.block.0, + art.block.1, + art.offset.0, + art.offset.1, + words.join(",") ), ); - self.remember(key, art); + self.remember(key, art.clone()); Ok((png, art)) } @@ -325,8 +342,9 @@ fn key_of(project: &Project, style: &TextStyle, width: u32, height: u32) -> u64 } // Bumped when the painter's output changes for the same input, so stale // files are not mistaken for current ones. 3: left- and right-aligned - // blocks moved to their anchors. - bytes.extend_from_slice(&3u32.to_le_bytes()); + // blocks moved to their anchors. 4: the sidecar gained each word's box, + // for a per-word reveal effect to read back. + bytes.extend_from_slice(&4u32.to_le_bytes()); crate::media::fnv1a(&bytes) } @@ -360,19 +378,45 @@ fn sweep(dir: &Path, keep: usize) { } } -fn read_block(side: &Path) -> Option { +fn read_block(side: &Path, width: u32, height: u32) -> Option { let text = std::fs::read_to_string(side).ok()?; let value: serde_json::Value = serde_json::from_str(&text).ok()?; let offset = |name: &str| value.get(name).and_then(|v| v.as_i64()).unwrap_or(0) as i32; + let rects: Vec<(i32, i32, u32, u32)> = value + .get("words") + .and_then(|v| v.as_array()) + .into_iter() + .flatten() + .filter_map(|word| { + let word = word.as_array()?; + Some(( + word.first()?.as_i64()? as i32, + word.get(1)?.as_i64()? as i32, + word.get(2)?.as_u64()? as u32, + word.get(3)?.as_u64()? as u32, + )) + }) + .collect(); Some(Art { block: ( value.get("w")?.as_u64()? as u32, value.get("h")?.as_u64()? as u32, ), offset: (offset("x"), offset("y")), + reveal: Arc::new(RevealMap::from_rects(width, height, &rects)), }) } +/// A title's per-word reveal map, baked from the boxes its layout left +/// behind - a byproduct `concat-text` already computed, not new work. +fn reveal_of(words: &[WordRect], width: u32, height: u32) -> Arc { + let rects: Vec<(i32, i32, u32, u32)> = words + .iter() + .map(|w| (w.x, w.y, w.width, w.height)) + .collect(); + Arc::new(RevealMap::from_rects(width, height, &rects)) +} + /// The document's style, field for field, in the painter's terms. fn title_style(style: &TextStyle) -> TitleStyle { TitleStyle { @@ -493,6 +537,11 @@ mod tests { (0, 0), "a centred title's block is on the clip" ); + // "Your text": two words, so their reveal order spans 0 to 255. + let reveal = title.clip.reveal_map.as_ref().expect("a title reveals"); + assert_eq!((reveal.width, reveal.height), (640, 360)); + assert!(reveal.gray.contains(&0)); + assert!(reveal.gray.contains(&255), "a second word reaches the top"); let painted = Path::new(&title.clip.path); assert!(painted.is_file(), "the PNG is on disk"); let stamp = std::fs::metadata(painted).and_then(|m| m.modified()).ok(); @@ -531,6 +580,12 @@ mod tests { let cold = Titles::new(&dirs).clips(editor.project(), 640, 360); assert_eq!(cold[0].offset, left[0].offset); assert_eq!(cold[0].block, left[0].block); + // The sidecar carries the words too, so a cold read reveals the + // same order as the one that painted them. + assert_eq!( + cold[0].clip.reveal_map.as_ref().map(|r| &*r.gray), + left[0].clip.reveal_map.as_ref().map(|r| &*r.gray) + ); let _ = std::fs::remove_dir_all(dirs.data.parent().unwrap()); } } diff --git a/src/crates/concat-media/src/encode.rs b/src/crates/concat-media/src/encode.rs index ab52c7b9..e7256f49 100644 --- a/src/crates/concat-media/src/encode.rs +++ b/src/crates/concat-media/src/encode.rs @@ -495,6 +495,15 @@ impl Encoder { let mut packet = ffmpeg::Packet::empty(); match self.encoder.receive_packet(&mut packet) { Ok(()) => { + // One frame, in the encoder's own time base - unset, + // this defaults to zero, and a muxer that never hears a + // packet's duration falls back to inferring the stream's + // total duration from the PTS span alone, which comes up + // one frame short: the last packet has no next one to + // measure to. Set here so `rescale_ts` carries it over + // with the timestamps, and the file's own duration + // matches what was actually encoded. + packet.set_duration(1); packet.set_stream(0); packet.rescale_ts(self.encoder_time_base, self.stream_time_base); packet @@ -637,6 +646,53 @@ pub fn jpeg(frame: &Frame, quality: u8) -> Result> { mod tests { use super::*; + /// Every written frame decodes back, at a frame rate an even number of + /// frames does not land on a round second at (25 fps, 6 seconds: every + /// packet but the last infers its duration from the one after it, so a + /// packet's duration was never set here and the file's own reported + /// length came up one frame short - which is exactly the gap a strict + /// reader, not just this crate's own decoder, counts by). + #[test] + fn every_written_frame_decodes_back_even_at_a_rate_with_no_last_neighbour() { + let dir = std::env::temp_dir().join(format!("concat-media-encode-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("scratch dir"); + let path = dir.join("150-at-25fps.mp4"); + + let options = EncodeOptions { + codec: VideoCodec::H264, + preset: "ultrafast".to_owned(), + crf: 16, + rate_mode: RateMode::Vbr, + bitrate_kbps: 0, + ten_bit: false, + hardware: false, + threads: 0, + }; + let rate = FrameRate::new(concat_core::time::Rational::new(25, 1)); + const FRAMES: u32 = 150; + { + let mut encoder = Encoder::create(&path, 64, 64, rate, &options).expect("encodes"); + let mut frame = Frame::black(64, 64); + for i in 0..FRAMES { + frame.fill([(i % 256) as u8, 0, 0, 255]); + encoder.write_frame(&frame).expect("writes"); + } + encoder.finish().expect("finishes"); + } + + use crate::decode::{DecodeOptions, Decoder, FrameSource}; + let mut decoder = + Decoder::open(&path, &DecodeOptions::default()).expect("opens what was just written"); + let mut count = 0; + while decoder.next_frame().expect("decodes").is_some() { + count += 1; + } + assert_eq!(count, FRAMES, "every encoded frame should decode back"); + + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn a_frame_becomes_a_jpeg() { let mut frame = Frame::black(32, 32); diff --git a/src/crates/concat-perf/src/main.rs b/src/crates/concat-perf/src/main.rs index b36fd482..8c255170 100644 --- a/src/crates/concat-perf/src/main.rs +++ b/src/crates/concat-perf/src/main.rs @@ -621,7 +621,7 @@ fn compose_plan() -> FramePlan { ..Transform::default() }; placed.effects = concat_effects::Catalogue::builtin() - .shader_passes_at(&[AppliedFilter::new("concat.sepia")], 0.0); + .shader_passes_at(&[AppliedFilter::new("concat.sepia")], 0.0, None); plan.layers.push(placed); let mut small = PlannedLayer::picture( concat_render::detached_clip(), diff --git a/src/crates/concat-project/src/animation.rs b/src/crates/concat-project/src/animation.rs index 727309a3..ae645baf 100644 --- a/src/crates/concat-project/src/animation.rs +++ b/src/crates/concat-project/src/animation.rs @@ -86,6 +86,93 @@ const IN: &[Shape] = &[ (Opacity, 1.0, 1.0, Ease::OUT), ], }, + Shape { + name: "Bounce In", + keys: &[ + (Scale, 0.0, 0.3, Ease::LINEAR), + (Scale, 0.6, 1.12, Ease::OUT), + (Scale, 0.8, 0.96, Ease::IN_OUT), + (Scale, 1.0, 1.0, Ease::OUT), + (Opacity, 0.0, 0.0, Ease::LINEAR), + (Opacity, 0.3, 1.0, Ease::OUT), + ], + }, + Shape { + name: "Pop", + keys: &[ + (Scale, 0.0, 0.0, Ease::LINEAR), + (Scale, 0.15, 1.15, Ease::OUT), + (Scale, 0.25, 1.0, Ease::IN_OUT), + (Opacity, 0.0, 0.0, Ease::LINEAR), + (Opacity, 0.15, 1.0, Ease::OUT), + ], + }, + Shape { + name: "Roll In", + keys: &[ + (Rotation, 0.0, -120.0, Ease::LINEAR), + (Rotation, 1.0, 0.0, Ease::OUT), + (X, 0.0, -0.7, Ease::LINEAR), + (X, 1.0, 0.0, Ease::OUT), + (Opacity, 0.0, 0.0, Ease::LINEAR), + (Opacity, 0.3, 1.0, Ease::OUT), + ], + }, + Shape { + name: "Drop In", + keys: &[ + (Y, 0.0, -0.8, Ease::LINEAR), + (Y, 0.7, 0.05, Ease::OUT), + (Y, 1.0, 0.0, Ease::IN_OUT), + (Opacity, 0.0, 0.0, Ease::LINEAR), + (Opacity, 0.2, 1.0, Ease::OUT), + ], + }, + Shape { + name: "Float Up", + keys: &[ + (Y, 0.0, 0.25, Ease::LINEAR), + (Y, 1.0, 0.0, Ease::OUT), + (Opacity, 0.0, 0.0, Ease::LINEAR), + (Opacity, 1.0, 1.0, Ease::OUT), + ], + }, + Shape { + name: "Flicker In", + keys: &[ + (Opacity, 0.0, 0.0, Ease::LINEAR), + (Opacity, 0.15, 1.0, Ease::LINEAR), + (Opacity, 0.25, 0.2, Ease::LINEAR), + (Opacity, 0.4, 1.0, Ease::LINEAR), + (Opacity, 0.5, 0.4, Ease::LINEAR), + (Opacity, 0.65, 1.0, Ease::LINEAR), + (Opacity, 1.0, 1.0, Ease::OUT), + ], + }, + Shape { + name: "Elastic In", + keys: &[ + (Scale, 0.0, 0.0, Ease::LINEAR), + (Scale, 0.4, 1.2, Ease::OUT), + (Scale, 0.6, 0.92, Ease::IN_OUT), + (Scale, 0.8, 1.06, Ease::IN_OUT), + (Scale, 1.0, 1.0, Ease::IN_OUT), + (Opacity, 0.0, 0.0, Ease::LINEAR), + (Opacity, 0.2, 1.0, Ease::OUT), + ], + }, + Shape { + name: "Swing In", + keys: &[ + (Rotation, 0.0, -25.0, Ease::LINEAR), + (Rotation, 0.35, 12.0, Ease::IN_OUT), + (Rotation, 0.6, -6.0, Ease::IN_OUT), + (Rotation, 0.8, 3.0, Ease::IN_OUT), + (Rotation, 1.0, 0.0, Ease::IN_OUT), + (Opacity, 0.0, 0.0, Ease::LINEAR), + (Opacity, 0.15, 1.0, Ease::OUT), + ], + }, ]; /// The Out shapes: the In shapes run backwards, and eased in rather than @@ -143,6 +230,97 @@ const OUT: &[Shape] = &[ (Opacity, 1.0, 0.0, Ease::IN), ], }, + Shape { + name: "Bounce In", + keys: &[ + (Scale, 0.0, 1.0, Ease::LINEAR), + (Scale, 0.2, 1.12, Ease::IN_OUT), + (Scale, 1.0, 0.3, Ease::IN), + (Opacity, 0.0, 1.0, Ease::LINEAR), + (Opacity, 0.7, 1.0, Ease::LINEAR), + (Opacity, 1.0, 0.0, Ease::IN), + ], + }, + Shape { + name: "Pop", + keys: &[ + (Scale, 0.0, 1.0, Ease::LINEAR), + (Scale, 0.85, 1.0, Ease::LINEAR), + (Scale, 1.0, 0.0, Ease::IN), + (Opacity, 0.0, 1.0, Ease::LINEAR), + (Opacity, 0.85, 1.0, Ease::LINEAR), + (Opacity, 1.0, 0.0, Ease::IN), + ], + }, + Shape { + name: "Roll In", + keys: &[ + (Rotation, 0.0, 0.0, Ease::LINEAR), + (Rotation, 1.0, 120.0, Ease::IN), + (X, 0.0, 0.0, Ease::LINEAR), + (X, 1.0, 0.7, Ease::IN), + (Opacity, 0.0, 1.0, Ease::LINEAR), + (Opacity, 0.7, 1.0, Ease::LINEAR), + (Opacity, 1.0, 0.0, Ease::IN), + ], + }, + Shape { + name: "Drop In", + keys: &[ + (Y, 0.0, 0.0, Ease::LINEAR), + (Y, 1.0, 0.8, Ease::IN), + (Opacity, 0.0, 1.0, Ease::LINEAR), + (Opacity, 0.6, 1.0, Ease::LINEAR), + (Opacity, 1.0, 0.0, Ease::IN), + ], + }, + Shape { + name: "Float Up", + keys: &[ + (Y, 0.0, 0.0, Ease::LINEAR), + (Y, 1.0, -0.25, Ease::IN), + (Opacity, 0.0, 1.0, Ease::LINEAR), + (Opacity, 1.0, 0.0, Ease::IN), + ], + }, + Shape { + name: "Flicker Out", + keys: &[ + (Opacity, 0.0, 1.0, Ease::LINEAR), + (Opacity, 0.35, 1.0, Ease::LINEAR), + (Opacity, 0.5, 0.4, Ease::LINEAR), + (Opacity, 0.6, 1.0, Ease::LINEAR), + (Opacity, 0.75, 0.2, Ease::LINEAR), + (Opacity, 0.85, 1.0, Ease::LINEAR), + (Opacity, 1.0, 0.0, Ease::IN), + ], + }, + Shape { + name: "Elastic Out", + keys: &[ + (Scale, 0.0, 1.0, Ease::LINEAR), + (Scale, 0.2, 1.06, Ease::IN_OUT), + (Scale, 0.4, 0.92, Ease::IN_OUT), + (Scale, 0.6, 1.2, Ease::IN), + (Scale, 1.0, 0.0, Ease::IN), + (Opacity, 0.0, 1.0, Ease::LINEAR), + (Opacity, 0.8, 1.0, Ease::LINEAR), + (Opacity, 1.0, 0.0, Ease::IN), + ], + }, + Shape { + name: "Swing Out", + keys: &[ + (Rotation, 0.0, 0.0, Ease::LINEAR), + (Rotation, 0.2, -3.0, Ease::IN_OUT), + (Rotation, 0.4, 6.0, Ease::IN_OUT), + (Rotation, 0.65, -12.0, Ease::IN_OUT), + (Rotation, 1.0, 25.0, Ease::IN), + (Opacity, 0.0, 1.0, Ease::LINEAR), + (Opacity, 0.85, 1.0, Ease::LINEAR), + (Opacity, 1.0, 0.0, Ease::IN), + ], + }, ]; /// The Combo shapes: over the whole clip. @@ -195,6 +373,177 @@ const COMBO: &[Shape] = &[ (Scale, 1.0, 1.12, Ease::LINEAR), ], }, + Shape { + name: "Sway", + keys: &[ + (X, 0.0, 0.0, Ease::IN_OUT), + (X, 0.25, 0.08, Ease::IN_OUT), + (X, 0.5, 0.0, Ease::IN_OUT), + (X, 0.75, -0.08, Ease::IN_OUT), + (X, 1.0, 0.0, Ease::IN_OUT), + ], + }, + Shape { + name: "Wave", + keys: &[ + (Y, 0.0, 0.0, Ease::IN_OUT), + (Y, 0.25, -0.05, Ease::IN_OUT), + (Y, 0.5, 0.0, Ease::IN_OUT), + (Y, 0.75, 0.05, Ease::IN_OUT), + (Y, 1.0, 0.0, Ease::IN_OUT), + ], + }, + Shape { + name: "Heartbeat", + keys: &[ + (Scale, 0.0, 1.0, Ease::LINEAR), + (Scale, 0.15, 1.12, Ease::OUT), + (Scale, 0.3, 1.0, Ease::IN), + (Scale, 0.45, 1.08, Ease::OUT), + (Scale, 0.6, 1.0, Ease::IN), + (Scale, 1.0, 1.0, Ease::LINEAR), + ], + }, + Shape { + name: "Breathe", + keys: &[ + (Scale, 0.0, 1.0, Ease::IN_OUT), + (Scale, 0.5, 1.05, Ease::IN_OUT), + (Scale, 1.0, 1.0, Ease::IN_OUT), + (Opacity, 0.0, 1.0, Ease::IN_OUT), + (Opacity, 0.5, 0.85, Ease::IN_OUT), + (Opacity, 1.0, 1.0, Ease::IN_OUT), + ], + }, + Shape { + name: "Wobble", + keys: &[ + (Rotation, 0.0, 0.0, Ease::IN_OUT), + (Rotation, 0.25, 8.0, Ease::IN_OUT), + (Rotation, 0.5, 0.0, Ease::IN_OUT), + (Rotation, 0.75, -8.0, Ease::IN_OUT), + (Rotation, 1.0, 0.0, Ease::IN_OUT), + ], + }, + Shape { + name: "Jelly", + keys: &[ + (Scale, 0.0, 1.0, Ease::IN_OUT), + (Scale, 0.125, 1.06, Ease::IN_OUT), + (Scale, 0.25, 0.97, Ease::IN_OUT), + (Scale, 0.375, 1.03, Ease::IN_OUT), + (Scale, 0.5, 0.99, Ease::IN_OUT), + (Scale, 0.625, 1.02, Ease::IN_OUT), + (Scale, 0.75, 0.995, Ease::IN_OUT), + (Scale, 1.0, 1.0, Ease::IN_OUT), + ], + }, + Shape { + name: "Flutter", + keys: &[ + (Rotation, 0.0, 0.0, Ease::LINEAR), + (Rotation, 0.125, 3.0, Ease::LINEAR), + (Rotation, 0.25, -3.0, Ease::LINEAR), + (Rotation, 0.375, 3.0, Ease::LINEAR), + (Rotation, 0.5, -3.0, Ease::LINEAR), + (Rotation, 0.625, 3.0, Ease::LINEAR), + (Rotation, 0.75, -3.0, Ease::LINEAR), + (Rotation, 0.875, 3.0, Ease::LINEAR), + (Rotation, 1.0, 0.0, Ease::LINEAR), + (Scale, 0.0, 1.0, Ease::LINEAR), + (Scale, 0.5, 0.97, Ease::LINEAR), + (Scale, 1.0, 1.0, Ease::LINEAR), + ], + }, +]; + +/// The Loop shapes: repeating motions over the clip. +const LOOP: &[Shape] = &[ + Shape { + name: "Spin", + keys: &[ + (Rotation, 0.0, 0.0, Ease::LINEAR), + (Rotation, 1.0, 360.0, Ease::LINEAR), + ], + }, + Shape { + name: "Pulse", + keys: &[ + (Scale, 0.0, 1.0, Ease::IN_OUT), + (Scale, 0.5, 1.15, Ease::IN_OUT), + (Scale, 1.0, 1.0, Ease::IN_OUT), + ], + }, + Shape { + name: "Breathe", + keys: &[ + (Scale, 0.0, 1.0, Ease::IN_OUT), + (Scale, 0.5, 1.06, Ease::IN_OUT), + (Scale, 1.0, 1.0, Ease::IN_OUT), + (Opacity, 0.0, 1.0, Ease::IN_OUT), + (Opacity, 0.5, 0.85, Ease::IN_OUT), + (Opacity, 1.0, 1.0, Ease::IN_OUT), + ], + }, + Shape { + name: "Wobble", + keys: &[ + (Rotation, 0.0, 0.0, Ease::IN_OUT), + (Rotation, 0.25, 8.0, Ease::IN_OUT), + (Rotation, 0.5, 0.0, Ease::IN_OUT), + (Rotation, 0.75, -8.0, Ease::IN_OUT), + (Rotation, 1.0, 0.0, Ease::IN_OUT), + ], + }, + Shape { + name: "Bounce", + keys: &[ + (Y, 0.0, 0.0, Ease::OUT), + (Y, 0.4, -0.15, Ease::IN), + (Y, 0.5, 0.0, Ease::OUT), + (Y, 0.75, -0.06, Ease::IN), + (Y, 1.0, 0.0, Ease::OUT), + ], + }, + Shape { + name: "Jitter", + keys: &[ + (X, 0.0, 0.0, Ease::LINEAR), + (X, 0.2, 0.03, Ease::LINEAR), + (X, 0.4, -0.03, Ease::LINEAR), + (X, 0.6, 0.02, Ease::LINEAR), + (X, 0.8, -0.02, Ease::LINEAR), + (X, 1.0, 0.0, Ease::LINEAR), + (Y, 0.0, 0.0, Ease::LINEAR), + (Y, 0.25, -0.03, Ease::LINEAR), + (Y, 0.5, 0.03, Ease::LINEAR), + (Y, 0.75, -0.02, Ease::LINEAR), + (Y, 1.0, 0.0, Ease::LINEAR), + ], + }, + Shape { + name: "Float", + keys: &[ + (Y, 0.0, 0.0, Ease::IN_OUT), + (Y, 0.5, -0.08, Ease::IN_OUT), + (Y, 1.0, 0.0, Ease::IN_OUT), + ], + }, + Shape { + name: "Flicker", + keys: &[ + (Opacity, 0.0, 1.0, Ease::LINEAR), + (Opacity, 0.1, 0.3, Ease::LINEAR), + (Opacity, 0.18, 1.0, Ease::LINEAR), + (Opacity, 0.3, 1.0, Ease::LINEAR), + (Opacity, 0.38, 0.4, Ease::LINEAR), + (Opacity, 0.5, 1.0, Ease::LINEAR), + (Opacity, 0.7, 1.0, Ease::LINEAR), + (Opacity, 0.78, 0.2, Ease::LINEAR), + (Opacity, 0.86, 1.0, Ease::LINEAR), + (Opacity, 1.0, 1.0, Ease::LINEAR), + ], + }, ]; fn shapes(slot: AnimationSlot) -> &'static [Shape] { @@ -202,6 +551,7 @@ fn shapes(slot: AnimationSlot) -> &'static [Shape] { AnimationSlot::In => IN, AnimationSlot::Out => OUT, AnimationSlot::Combo => COMBO, + AnimationSlot::Loop => LOOP, } } @@ -232,6 +582,37 @@ pub fn animation_of(clip: &Clip) -> Option { let Some(shape) = shapes(slot).iter().find(|shape| shape.name == set.preset) else { return; }; + if slot == AnimationSlot::Loop { + let cycle_secs = set.duration.max(0.2); + let cycles = (duration / cycle_secs).ceil() as usize; + let cycle_frac = cycle_secs / duration; + for c in 0..cycles { + let c_start = c as f64 * cycle_frac; + if c_start >= 1.0 { + break; + } + for &(prop, at, value, ease) in shape.keys { + let key_at = c_start + cycle_frac * at; + if key_at <= 1.0 + 1e-5 { + let key = Key { + at: key_at.min(1.0), + value, + ease, + }; + tracks[match prop { + Scale => 0, + X => 1, + Y => 2, + Rotation => 3, + Opacity => 4, + }] + .push(key); + any = true; + } + } + } + return; + } // The slot's share of the clip, as a fraction of its length. let mut span = (set.duration.max(0.05) / duration).min(1.0); if let Some(other) = other { @@ -243,7 +624,7 @@ pub fn animation_of(clip: &Clip) -> Option { let (from, to) = match slot { AnimationSlot::In => (0.0, span), AnimationSlot::Out => (1.0 - span, 1.0), - AnimationSlot::Combo => (0.0, 1.0), + AnimationSlot::Combo | AnimationSlot::Loop => (0.0, 1.0), }; for &(prop, at, value, ease) in shape.keys { let key = Key { @@ -265,6 +646,7 @@ pub fn animation_of(clip: &Clip) -> Option { lay(AnimationSlot::In, &clip.animation_in, &clip.animation_out); lay(AnimationSlot::Out, &clip.animation_out, &clip.animation_in); lay(AnimationSlot::Combo, &clip.animation_combo, &None); + lay(AnimationSlot::Loop, &clip.animation_loop, &None); if !any { return None; } @@ -290,7 +672,12 @@ mod tests { #[test] fn every_slot_names_its_shapes_and_finds_them_again() { - for slot in [AnimationSlot::In, AnimationSlot::Out, AnimationSlot::Combo] { + for slot in [ + AnimationSlot::In, + AnimationSlot::Out, + AnimationSlot::Combo, + AnimationSlot::Loop, + ] { let names = names(slot); assert!(!names.is_empty()); for (index, name) in names.iter().enumerate() { @@ -300,6 +687,44 @@ mod tests { } } + #[test] + fn a_loop_animation_repeats_across_the_clip() { + let mut editor = Editor::new(); + let id = editor + .apply(Command::AddTextClip { + above: false, + track_id: None, + start: 0.0, + style: None, + duration: Some(4.0), + offset_y: None, + }) + .unwrap() + .created_id + .unwrap(); + editor + .apply(Command::SetClipAnimation { + clip_id: id.clone(), + slot: AnimationSlot::Loop, + animation: Some(ClipAnimation { + preset: "Pulse".to_owned(), + duration: 1.0, // 1 second per cycle -> 4 cycles over 4 seconds + }), + }) + .unwrap(); + let clip = editor.project().active().clip(&id).unwrap(); + let animation = animation_of(clip).expect("a pulse loop is set"); + // Peak of pulse at half of each cycle: 0.5s / 4s = 0.125, 1.5s / 4s = 0.375 + let scale_0 = animation.transform_at(Default::default(), 0.0).scale; + let scale_peak1 = animation.transform_at(Default::default(), 0.125).scale; + let scale_mid = animation.transform_at(Default::default(), 0.25).scale; + let scale_peak2 = animation.transform_at(Default::default(), 0.375).scale; + assert_eq!(scale_0, 1.0); + assert!((scale_peak1 - 1.15).abs() < 1e-3); + assert_eq!(scale_mid, 1.0); + assert!((scale_peak2 - 1.15).abs() < 1e-3); + } + #[test] fn a_fade_in_lands_over_its_seconds_at_the_head() { let mut editor = Editor::new(); @@ -334,4 +759,42 @@ mod tests { assert!((animation.opacity_at(1.0, 0.9) - 1.0).abs() < 1e-9); assert_eq!(animation.transform_at(Default::default(), 0.1).scale, 1.0); } + + /// Elastic In starts a clip at zero scale, overshoots past 1.0, and + /// settles - the spring shape a "Bounce In" alone does not have. + #[test] + fn an_elastic_in_overshoots_before_settling() { + let mut editor = Editor::new(); + let id = editor + .apply(Command::AddTextClip { + above: false, + track_id: None, + start: 0.0, + style: None, + duration: Some(4.0), + offset_y: None, + }) + .unwrap() + .created_id + .unwrap(); + editor + .apply(Command::SetClipAnimation { + clip_id: id.clone(), + slot: AnimationSlot::In, + animation: Some(ClipAnimation { + preset: "Elastic In".to_owned(), + duration: 1.0, + }), + }) + .unwrap(); + let clip = editor.project().active().clip(&id).unwrap(); + let animation = animation_of(clip).expect("an elastic-in is set"); + // The In slot is 1s of a 4s clip: a quarter of the whole clip. + assert!(animation.transform_at(Default::default(), 0.0).scale < 0.01); + // The overshoot key sits at 40% of the slot, 10% of the clip. + assert!(animation.transform_at(Default::default(), 0.1).scale > 1.1); + // Settled by the slot's end, and held there for the rest of the clip. + assert!((animation.transform_at(Default::default(), 0.25).scale - 1.0).abs() < 1e-9); + assert!((animation.transform_at(Default::default(), 0.9).scale - 1.0).abs() < 1e-9); + } } diff --git a/src/crates/concat-project/src/commands/properties.rs b/src/crates/concat-project/src/commands/properties.rs index 7007db46..da902f0c 100644 --- a/src/crates/concat-project/src/commands/properties.rs +++ b/src/crates/concat-project/src/commands/properties.rs @@ -161,6 +161,7 @@ pub(super) fn apply( AnimationSlot::In => &mut clip.animation_in, AnimationSlot::Out => &mut clip.animation_out, AnimationSlot::Combo => &mut clip.animation_combo, + AnimationSlot::Loop => &mut clip.animation_loop, }; let applied = assign(field, animation); Ok(Outcome { diff --git a/src/crates/concat-project/src/model.rs b/src/crates/concat-project/src/model.rs index 26497c48..10a758ab 100644 --- a/src/crates/concat-project/src/model.rs +++ b/src/crates/concat-project/src/model.rs @@ -569,6 +569,8 @@ pub enum AnimationSlot { Out, /// The whole clip. Combo, + /// A repeating motion over the clip. + Loop, } /// A named animation on one slot. The keys are made from the name for the @@ -1142,6 +1144,10 @@ pub struct Clip { #[serde(default, skip_serializing_if = "Option::is_none")] #[serde(deserialize_with = "wire::maybe")] pub animation_combo: Option, + /// A repeating motion over the clip. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(deserialize_with = "wire::maybe")] + pub animation_loop: Option, /// The user's own keys, sorted by property and then by `at`. Empty is a /// clip whose properties are the constants above. /// @@ -1288,6 +1294,7 @@ impl Clip { animation_in: None, animation_out: None, animation_combo: None, + animation_loop: None, keys: Vec::new(), flip_h: false, flip_v: false, @@ -1362,6 +1369,7 @@ impl Clip { &mut self.animation_in, &mut self.animation_out, &mut self.animation_combo, + &mut self.animation_loop, ] { *slot = slot.take().and_then(ClipAnimation::tidy); } diff --git a/src/crates/concat-render/src/compositor.rs b/src/crates/concat-render/src/compositor.rs index a447e043..3ca63a29 100644 --- a/src/crates/concat-render/src/compositor.rs +++ b/src/crates/concat-render/src/compositor.rs @@ -18,7 +18,7 @@ use std::borrow::Cow; use concat_core::frame::{BYTES_PER_PIXEL, Frame}; -use concat_core::shader::ShaderPass; +use concat_core::shader::{ShaderPass, TransitionPass}; use concat_core::timeline::Blend; use crate::kernels; @@ -34,6 +34,23 @@ pub trait Compositor { /// The result is always fully opaque - it is what goes to screen or to an /// encoder, and neither has anything to show through. fn render(&mut self, plan: &FramePlan) -> Frame; + + /// Combines two finished frames with a transition: the outgoing picture + /// `from` and the incoming one `to`, at the pass's `progress`. The shader + /// owns the blend. `None` from a compositor that cannot run shaders, which + /// the CPU reference cannot: the caller then shows the fallback dissolve + /// the incoming layer already carries. + fn combine( + &mut self, + _width: u32, + _height: u32, + _time: f32, + _from: &Frame, + _to: &Frame, + _pass: &TransitionPass, + ) -> Option { + None + } } /// A straightforward CPU compositor. @@ -687,6 +704,7 @@ mod tests { values: Default::default(), intensity: 1.0, lut: None, + reveal_map: None, }; let mut frame_plan = plan(8, 8, vec![red, blue]); frame_plan.treatments = vec![PlannedTreatment { diff --git a/src/crates/concat-render/src/gpu.rs b/src/crates/concat-render/src/gpu.rs index 275c892b..5ec3f5cd 100644 --- a/src/crates/concat-render/src/gpu.rs +++ b/src/crates/concat-render/src/gpu.rs @@ -29,7 +29,7 @@ use std::collections::HashMap; use concat_core::frame::Frame; -use concat_core::shader::{Lut, ShaderPass}; +use concat_core::shader::{Lut, RevealMap, ShaderPass, TransitionPass}; use concat_core::timeline::Blend; use crate::compositor::{Compositor, CpuCompositor}; @@ -188,10 +188,21 @@ pub struct WgpuCompositor { uniform_layout: wgpu::BindGroupLayout, /// Group 2 of a shader pass: the package's look-up table, a 3D texture. lut_layout: wgpu::BindGroupLayout, + /// Group 3 of a shader pass: a title's per-word reveal map, a 2D + /// texture; see `concat_core::RevealMap`. + reveal_layout: wgpu::BindGroupLayout, + /// Group 0 of a transition: the outgoing and incoming pictures, each a + /// texture and its sampler. + transition_layout: wgpu::BindGroupLayout, /// Uploaded tables by their id, the identity among them; see `lut_group`. luts: HashMap, + /// Uploaded reveal maps by their id, the identity among them; see + /// `reveal_group`. + reveals: HashMap, /// Compiled passes by their key; see `ShaderPass::key`. shaders: HashMap, + /// Compiled transitions by their key; see `TransitionPass::key`. + transitions: HashMap, sampler: wgpu::Sampler, vertices: wgpu::Buffer, vertex_capacity: usize, @@ -301,6 +312,59 @@ impl WgpuCompositor { }, ], }); + // Group 3: a title's reveal map. Every pass binds one - a map that + // reveals everything when the package has none, or the pass is not + // over a title at all - so the same pipeline layout serves every + // shader pass whether or not it reads `reveal_order()`. + let reveal_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("concat pass reveal map"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + // Group 0 of a transition: two pictures, each a texture and a + // sampler - the outgoing at 0/1, the incoming at 2/3. + let texture_entry = |binding: u32| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }; + let sampler_entry = |binding: u32| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }; + let transition_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("concat transition inputs"), + entries: &[ + texture_entry(0), + sampler_entry(1), + texture_entry(2), + sampler_entry(3), + ], + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("concat compositor"), // Group 0 is the layer, group 1 its mask: the same shape, a @@ -416,8 +480,12 @@ impl WgpuCompositor { bind_layout, uniform_layout, lut_layout, + reveal_layout, + transition_layout, luts: HashMap::new(), + reveals: HashMap::new(), shaders: HashMap::new(), + transitions: HashMap::new(), sampler, vertices, vertex_capacity: 6 * 8, @@ -512,7 +580,11 @@ impl WgpuCompositor { ground = Some(below); continue; } - let treated = self.run_passes(width, height, below, &treatment.effects, seconds); + // A treatment has no single clip of its own - it runs over + // whatever stack sits beneath it - so `clip_time` falls back + // to the timeline's own clock, exactly what a package read + // before this existed. + let treated = self.run_passes(width, height, below, &treatment.effects, seconds, 0.0); ground = Some(if strength >= 1.0 { treated } else { @@ -556,6 +628,7 @@ impl WgpuCompositor { }; let geometry = layer.geometry(source, plan.width, plan.height); let seconds = plan.seconds(); + let clip_start = layer.clip_start.as_f64() as f32; let (size, texture, uvs, flips) = if layer.needs_preparing(&geometry) { let uploaded = self.upload(source); let made = self.make_picture( @@ -571,6 +644,7 @@ impl WgpuCompositor { made, &layer.effects, seconds, + clip_start, ); ( geometry.fitted, @@ -587,6 +661,7 @@ impl WgpuCompositor { index, &layer.effects, seconds, + clip_start, ); } ( @@ -1035,6 +1110,7 @@ impl WgpuCompositor { Some(&self.bind_layout), Some(&self.uniform_layout), Some(&self.lut_layout), + Some(&self.reveal_layout), ], immediate_size: 0, }); @@ -1069,7 +1145,9 @@ impl WgpuCompositor { }); let frame = self.device.create_buffer(&wgpu::BufferDescriptor { label: Some("concat pass frame"), - size: 16, + // size(vec2), time, intensity, clip_time, padded to Frame's own + // 8-byte alignment (from its vec2 member): 20 bytes rounds to 24. + size: 24, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); @@ -1104,6 +1182,135 @@ impl WgpuCompositor { ); } + /// The compiled pipeline for a transition, built the first time its key is + /// seen. Mirrors [`WgpuCompositor::shader`] but binds two input pictures at + /// group 0 and lets the shader own the blend. + fn transition_shader(&mut self, pass: &TransitionPass) { + if self.transitions.contains_key(&pass.key) { + return; + } + let module = self + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(&pass.key), + source: wgpu::ShaderSource::Wgsl(pass.source.as_ref().into()), + }); + let layout = self + .device + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some(&pass.key), + bind_group_layouts: &[ + Some(&self.transition_layout), + Some(&self.uniform_layout), + Some(&self.lut_layout), + ], + immediate_size: 0, + }); + let pipeline = self + .device + .create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(&pass.key), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &module, + entry_point: Some("vs_main"), + compilation_options: Default::default(), + buffers: &[], + }, + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("fs_main"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + // The transition owns the mix; the pipeline does none. + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + let frame = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("concat transition frame"), + size: 16, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let params = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("concat transition params"), + size: pass.params.len().max(ShaderPass::MIN_PARAMS) as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("concat transition uniforms"), + layout: &self.uniform_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: frame.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: params.as_entire_binding(), + }, + ], + }); + self.transitions.insert( + pass.key.clone(), + CompiledShader { + pipeline, + frame, + params, + bind_group, + }, + ); + } + + /// A texture holding a whole frame's pixels, for a transition input. Made + /// fresh each combine rather than pooled: a transition is a short window, + /// and its two inputs are full-frame, so the pool would only churn. + fn input_texture(&self, frame: &Frame) -> wgpu::Texture { + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("concat transition input"), + size: wgpu::Extent3d { + width: frame.width(), + height: frame.height(), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + frame.pixels(), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(frame.width() * 4), + rows_per_image: Some(frame.height()), + }, + wgpu::Extent3d { + width: frame.width(), + height: frame.height(), + depth_or_array_layers: 1, + }, + ); + texture + } + /// The bind group for a pass's table, uploaded the first time its id is /// seen, and the identity's for a pass without one. Returns the id the /// group is filed under. @@ -1166,6 +1373,68 @@ impl WgpuCompositor { lut.id } + /// The bind group for a title's reveal map, uploaded the first time its + /// id is seen, and the identity's - reveals everything - for a pass + /// without one. Mirrors [`WgpuCompositor::lut_group`]. Returns the id + /// the group is filed under. + fn reveal_group(&mut self, reveal: Option<&RevealMap>) -> u64 { + static IDENTITY: std::sync::OnceLock = std::sync::OnceLock::new(); + let reveal = reveal.unwrap_or_else(|| IDENTITY.get_or_init(RevealMap::identity)); + if self.reveals.contains_key(&reveal.id) { + return reveal.id; + } + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("concat reveal map"), + size: wgpu::Extent3d { + width: reveal.width, + height: reveal.height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &reveal.gray, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(reveal.width), + rows_per_image: Some(reveal.height), + }, + wgpu::Extent3d { + width: reveal.width, + height: reveal.height, + depth_or_array_layers: 1, + }, + ); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("concat reveal map"), + layout: &self.reveal_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + ], + }); + self.reveals.insert(reveal.id, bind_group); + reveal.id + } + /// Runs `passes` over the pooled texture `source` of `width` × `height`, /// each drawing into a fresh pooled texture of the same size, and /// returns the index of the last one drawn. Each pass is its own @@ -1177,15 +1446,25 @@ impl WgpuCompositor { source: usize, passes: &[ShaderPass], time: f32, + clip_start: f32, ) -> usize { let mut current = source; for pass in passes { let target = self.claim(width, height); self.shader(pass); let lut_id = self.lut_group(pass.lut.as_deref()); + let reveal_id = self.reveal_group(pass.reveal_map.as_deref()); let shader = &self.shaders[&pass.key]; let lut_group = &self.luts[&lut_id]; - let frame_block: [f32; 4] = [width as f32, height as f32, time, pass.intensity]; + let reveal_group = &self.reveals[&reveal_id]; + let frame_block: [f32; 6] = [ + width as f32, + height as f32, + time, + pass.intensity, + time - clip_start, + 0.0, + ]; let frame_bytes: Vec = frame_block.iter().flat_map(|v| v.to_le_bytes()).collect(); self.queue.write_buffer(&shader.frame, 0, &frame_bytes); let mut params = pass.params.clone(); @@ -1222,6 +1501,7 @@ impl WgpuCompositor { render.set_bind_group(0, &pool[current].bind_group, &[]); render.set_bind_group(1, &shader.bind_group, &[]); render.set_bind_group(2, lut_group, &[]); + render.set_bind_group(3, reveal_group, &[]); render.draw(0..3, 0..1); } self.queue.submit([encoder.finish()]); @@ -1351,6 +1631,126 @@ impl Compositor for WgpuCompositor { } } } + + fn combine( + &mut self, + width: u32, + height: u32, + time: f32, + from: &Frame, + to: &Frame, + pass: &TransitionPass, + ) -> Option { + if self.dead { + return None; + } + self.transition_shader(pass); + let lut_id = self.lut_group(pass.lut.as_deref()); + + // The two pictures, uploaded and bound at group 0. + let from_texture = self.input_texture(from); + let to_texture = self.input_texture(to); + let from_view = from_texture.create_view(&wgpu::TextureViewDescriptor::default()); + let to_view = to_texture.create_view(&wgpu::TextureViewDescriptor::default()); + let inputs = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("concat transition inputs"), + layout: &self.transition_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&from_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(&to_view), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, + ], + }); + + // The frame block carries progress where a pass carries intensity. + { + let shader = &self.transitions[&pass.key]; + let frame_block: [f32; 4] = [width as f32, height as f32, time, pass.progress]; + let frame_bytes: Vec = frame_block.iter().flat_map(|v| v.to_le_bytes()).collect(); + self.queue.write_buffer(&shader.frame, 0, &frame_bytes); + let mut params = pass.params.clone(); + params.resize(shader.params.size() as usize, 0); + self.queue.write_buffer(&shader.params, 0, ¶ms); + } + + self.target(width, height); + { + let target = self.target.as_ref().expect("just ensured"); + let view = target + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + let shader = &self.transitions[&pass.key]; + let lut_group = &self.luts[&lut_id]; + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("concat transition"), + }); + { + let mut render = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("concat transition"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + render.set_pipeline(&shader.pipeline); + render.set_bind_group(0, &inputs, &[]); + render.set_bind_group(1, &shader.bind_group, &[]); + render.set_bind_group(2, lut_group, &[]); + render.draw(0..3, 0..1); + } + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &target.texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &target.staging, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(target.padded_row as u32), + rows_per_image: Some(height), + }, + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + self.queue.submit([encoder.finish()]); + } + let frame = self.read_back(); + if frame.is_none() { + self.dead = true; + } + frame + } } impl WgpuCompositor { diff --git a/src/crates/concat-render/src/gpu/tests.rs b/src/crates/concat-render/src/gpu/tests.rs index 3b7280ca..58a148ea 100644 --- a/src/crates/concat-render/src/gpu/tests.rs +++ b/src/crates/concat-render/src/gpu/tests.rs @@ -9,7 +9,7 @@ use std::collections::BTreeMap; use std::sync::Arc; use concat_core::frame::Frame; -use concat_core::shader::{Lut, ShaderPass}; +use concat_core::shader::{Lut, RevealMap, ShaderPass}; use concat_core::timeline::{Blend, Transform}; use super::*; @@ -76,7 +76,7 @@ fn package( .iter() .map(|(key, value)| ((*key).to_owned(), *value)) .collect(); - shader.pass(&values, &manifest.params, intensity, None) + shader.pass(&values, &manifest.params, intensity, None, None) } const INVERT: &str = "fn effect(uv: vec2) -> vec4 { let c = sample(uv); return vec4(vec3(1.0) - c.rgb, c.a); }"; @@ -395,3 +395,126 @@ fn output_size_changes_are_handled() { assert_eq!((large.width(), large.height()), (16, 8)); assert_eq!(large.pixel(15, 7), Some([255, 255, 255, 255])); } + +/// A pass reads its title's reveal map through `reveal_order()`: the +/// first word's half of the canvas reads 0, the second word's half +/// reads 1, and a pass without one - the common case, any pass over +/// anything that is not a title - reads the identity, 0 everywhere. +#[test] +fn a_pass_reads_its_reveal_map_and_the_identity_everywhere() { + let Some(mut gpu) = gpu() else { return }; + let body = + "fn effect(uv: vec2) -> vec4 { let r = reveal_order(uv); return vec4(r, r, r, 1.0); }"; + let map = RevealMap::from_rects(4, 4, &[(0, 0, 2, 4), (2, 0, 2, 4)]); + let mut revealed = layer(solid(4, 4, [0, 0, 0, 255])); + let mut pass = package("test.reveal", body, "", &[], 1.0); + pass.reveal_map = Some(Arc::new(map)); + revealed.effects = vec![pass]; + let out = gpu.render(&plan(4, 4, vec![revealed])); + let pixels = out.pixels(); + assert_eq!(pixels[0], 0, "{:?}", &pixels[..4]); + assert_eq!(pixels[2 * 4], 255, "{:?}", &pixels[8..12]); + + let mut plain = layer(solid(4, 4, [0, 0, 0, 255])); + plain.effects = vec![package("test.reveal", body, "", &[], 1.0)]; + let out = gpu.render(&plan(4, 4, vec![plain])); + assert_eq!(&out.pixels()[..4], &[0, 0, 0, 255]); +} + +/// A transition combines its two inputs through its shader: a trivial +/// dissolve over two solid colours must equal `from` at progress 0, `to` +/// at progress 1, and the exact half-and-half mix at progress 0.5. The +/// golden every packaged transition's own shader is measured against. +#[test] +fn a_transition_combines_its_two_inputs_by_progress() { + let Some(mut gpu) = gpu() else { return }; + let manifest = concat_effects::Manifest::parse( + "[effect]\nid = \"test.dissolve\"\nname = \"Dissolve\"\nkind = \"transition\"\n[transition]\nentry = \"effect.wgsl\"\n", + ) + .expect("a manifest"); + let shader = concat_effects::TransitionShader::compile( + &manifest, + "fn transition(uv: vec2, progress: f32) -> vec4 { return mix(from_at(uv), to_at(uv), progress); }", + ) + .expect("compiles"); + let red = solid(4, 4, [255, 0, 0, 255]); + let blue = solid(4, 4, [0, 0, 255, 255]); + + let mut at = |progress: f32| { + let pass = shader.pass(&Default::default(), &[], progress, None); + gpu.combine(4, 4, 0.0, &red, &blue, &pass) + .expect("a GPU combine") + }; + assert_eq!(&at(0.0).pixels()[..3], &[255, 0, 0], "all outgoing at 0"); + assert_eq!(&at(1.0).pixels()[..3], &[0, 0, 255], "all incoming at 1"); + assert_eq!(&at(0.5).pixels()[..3], &[128, 0, 128], "the exact half mix"); +} + +/// Every packaged effect and filter with a shader actually renders on +/// the GPU at its default settings - naga's validation at load catches +/// a broken shader's syntax and types, but only a real pipeline creation +/// and draw catches a binding or layout mistake. +#[test] +fn every_shader_package_renders_at_its_defaults() { + let Some(mut gpu) = gpu() else { return }; + let source = solid(4, 4, [200, 120, 60, 255]); + let catalogue = concat_effects::Catalogue::builtin(); + for package in catalogue.packages() { + let Some(shader) = package.shader() else { continue }; + let values = package.resolve(&Default::default()); + let pass = shader.pass( + &values, + &package.manifest.params, + 1.0, + package.lut().cloned(), + None, + ); + let mut treated = layer(source.clone()); + treated.effects = vec![pass]; + let out = gpu.render(&plan(4, 4, vec![treated])); + assert_eq!(out.pixels().len(), 4 * 4 * 4, "{}", package.id()); + } +} + +/// A pass reads how long its own clip has been on screen through +/// `frame.clip_time` - the gap between the frame's own time and where the +/// clip begins on the timeline, not the timeline's absolute clock. A +/// clip starting at 2s, five seconds into the timeline, has been on +/// screen for exactly three. +#[test] +fn a_pass_reads_its_layers_clip_relative_time() { + let Some(mut gpu) = gpu() else { return }; + let body = "fn effect(uv: vec2) -> vec4 { if (abs(frame.clip_time - 3.0) < 0.001) { return vec4(0.0, 1.0, 0.0, 1.0); } return vec4(1.0, 0.0, 0.0, 1.0); }"; + let mut timed = layer(solid(4, 4, [0, 0, 0, 255])); + timed.clip_start = concat_core::time::Rational::approximate(2.0).expect("a rational"); + timed.effects = vec![package("test.cliptime", body, "", &[], 1.0)]; + let mut p = plan(4, 4, vec![timed]); + p.time = concat_core::time::Rational::approximate(5.0).expect("a rational"); + let out = gpu.render(&p); + assert_eq!(&out.pixels()[..3], &[0, 255, 0], "clip_time should read 3.0"); +} + +/// Every packaged transition's pipeline actually creates and runs on the +/// GPU, across its whole progress range - naga's validation at load +/// catches a broken shader's syntax and types, but only a real pipeline +/// creation catches a binding or layout mistake. +#[test] +fn every_packaged_transition_combines_across_its_progress_range() { + let Some(mut gpu) = gpu() else { return }; + let red = solid(4, 4, [255, 0, 0, 255]); + let blue = solid(4, 4, [0, 0, 255, 255]); + let catalogue = concat_effects::Catalogue::builtin(); + for package in catalogue.packages() { + if package.kind() != concat_effects::Kind::Transition { + continue; + } + for progress in [0.0, 0.25, 0.5, 0.75, 1.0] { + let pass = catalogue + .transition_pass(package.id(), &Default::default(), progress) + .unwrap_or_else(|| panic!("{} has no transition pass", package.id())); + gpu.combine(4, 4, 0.0, &red, &blue, &pass).unwrap_or_else(|| { + panic!("{} failed to combine at progress {progress}", package.id()) + }); + } + } +} diff --git a/src/crates/concat-render/src/kernels.rs b/src/crates/concat-render/src/kernels.rs index 67ea0d99..3fe13f09 100644 --- a/src/crates/concat-render/src/kernels.rs +++ b/src/crates/concat-render/src/kernels.rs @@ -205,6 +205,7 @@ mod tests { .collect::>(), intensity: 1.0, lut: None, + reveal_map: None, } } diff --git a/src/crates/concat-render/src/plan.rs b/src/crates/concat-render/src/plan.rs index a9740cff..b1bec277 100644 --- a/src/crates/concat-render/src/plan.rs +++ b/src/crates/concat-render/src/plan.rs @@ -180,6 +180,12 @@ impl Shading { pub struct PlannedLayer { /// Which clip produced this layer. pub clip: ClipId, + /// Where this clip begins on the timeline - not the source's own time, + /// `source_time`, but where the clip itself sits. A shader effect reads + /// the gap between this and the frame's own time as `frame.clip_time`, + /// so a one-shot look can size itself to however long the clip has + /// been on screen instead of the timeline's absolute clock. + pub clip_start: Rational, /// The track the layer sits on, bottom-most zero: every layer on a /// lower track is under it, and a treatment names the track it sits /// above. @@ -232,6 +238,7 @@ impl PlannedLayer { pub fn picture(clip: ClipId, frame: Arc) -> PlannedLayer { PlannedLayer { clip, + clip_start: Rational::ZERO, track: 0, media: PathBuf::new(), source_time: Rational::ZERO, @@ -501,6 +508,7 @@ pub fn plan_frame(timeline: &Timeline, time: Rational) -> FramePlan { layers.push(PlannedLayer { clip: clip_id, + clip_start: clip.start, track, media: clip.media.path.clone(), source_time, diff --git a/src/crates/concat-text/src/lib.rs b/src/crates/concat-text/src/lib.rs index 16e3e36e..c6cb3ced 100644 --- a/src/crates/concat-text/src/lib.rs +++ b/src/crates/concat-text/src/lib.rs @@ -92,6 +92,22 @@ pub struct TitleStyle { pub max_height: f64, } +/// One word's box in canvas pixels, top-left origin, in reading order +/// (line by line, left to right within a line). A pixel-reveal effect +/// keys off this order rather than the word itself, which is why it is +/// exposed as rects and not text. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct WordRect { + /// The box's left edge. + pub x: i32, + /// The box's top edge. + pub y: i32, + /// The box's width. + pub width: u32, + /// The box's height. + pub height: u32, +} + /// The finished title as pixels: the canvas, RGBA with straight alpha, /// for a monitor that wants it now and not from a file. #[derive(Clone, PartialEq, Debug)] @@ -110,6 +126,8 @@ pub struct RenderedFrame { pub block_dx: i32, /// See [`Rendered::block_dy`]. pub block_dy: i32, + /// See [`Rendered::words`]. + pub words: Vec, } /// The finished title. @@ -134,6 +152,10 @@ pub struct Rendered { /// The vertical half of `block_dx`, y down. Always zero for now: the /// block is centred vertically whatever the alignment. pub block_dy: i32, + /// Every word's box on the canvas, in reading order - a byproduct of + /// layout this crate already does, kept for a caller that wants to + /// reveal a title one word at a time without knowing what a word is. + pub words: Vec, } /// What can go wrong. Fonts fall back rather than fail, so this is short. @@ -260,11 +282,13 @@ impl Fonts { } } -/// One shaped line: its outline path in pixels, pen at the origin, and its -/// advance width. +/// One shaped line: its outline path in pixels, pen at the origin, its +/// advance width, and each of its words' `(start_x, end_x)` in that same +/// pen space, left to right. struct Line { path: Option, width: f32, + words: Vec<(f32, f32)>, } /// A colour from `#rrggbb` or `#rrggbbaa`; anything else is `None`. @@ -330,31 +354,47 @@ fn wrap_line( tracking: f32, max_w: f32, ) -> Vec { - if max_w <= 0.0 || text.trim().is_empty() { + if text.trim().is_empty() { return vec![shape_line(face, text, em, tracking)]; } + // Word boundaries as byte ranges into `text`, so a growing candidate is + // a real prefix of the source - keeping its own inter-word spacing - + // rather than words rejoined with a single space. + let mut bounds: Vec<(usize, usize)> = Vec::new(); + let mut start: Option = None; + for (index, ch) in text.char_indices() { + if ch.is_whitespace() { + if let Some(word_start) = start.take() { + bounds.push((word_start, index)); + } + } else if start.is_none() { + start = Some(index); + } + } + if let Some(word_start) = start { + bounds.push((word_start, text.len())); + } + let mut lines = Vec::new(); - let mut current = String::new(); + let mut line_start = bounds[0].0; let mut shaped = shape_line(face, "", em, tracking); - for word in text.split_whitespace() { - let candidate = if current.is_empty() { - word.to_owned() - } else { - format!("{current} {word}") - }; - let trial = shape_line(face, &candidate, em, tracking); - if trial.width <= max_w || current.is_empty() { - current = candidate; + let mut words: Vec<(f32, f32)> = Vec::new(); + for &(word_start, word_end) in &bounds { + let trial = shape_line(face, &text[line_start..word_end], em, tracking); + // A word that fits nowhere still gets a line of its own. + if max_w <= 0.0 || trial.width <= max_w || words.is_empty() { + words.push((shaped.width, trial.width)); shaped = trial; } else { + shaped.words = std::mem::take(&mut words); lines.push(shaped); - current = word.to_owned(); - shaped = shape_line(face, word, em, tracking); + line_start = word_start; + shaped = shape_line(face, &text[line_start..word_end], em, tracking); + words.push((0.0, shaped.width)); } } - if !current.is_empty() { - lines.push(shaped); - } + shaped.words = words; + lines.push(shaped); lines } @@ -363,6 +403,7 @@ fn shape_line(face: &rustybuzz::Face<'_>, text: &str, em: f32, tracking: f32) -> return Line { path: None, width: 0.0, + words: Vec::new(), }; } let scale = em / face.units_per_em() as f32; @@ -391,6 +432,7 @@ fn shape_line(face: &rustybuzz::Face<'_>, text: &str, em: f32, tracking: f32) -> Line { path: builder.finish(), width, + words: Vec::new(), } } @@ -455,7 +497,7 @@ pub fn render( width: u32, height: u32, ) -> Result { - let (canvas, block) = paint(fonts, style, width, height)?; + let (canvas, block, words) = paint(fonts, style, width, height)?; let png = canvas .encode_png() .map_err(|error| Error::Encode(error.to_string()))?; @@ -467,6 +509,7 @@ pub fn render( block_height: block.1, block_dx: block.2, block_dy: block.3, + words, }) } @@ -480,7 +523,7 @@ pub fn render_frame( width: u32, height: u32, ) -> Result { - let (canvas, block) = paint(fonts, style, width, height)?; + let (canvas, block, words) = paint(fonts, style, width, height)?; // tiny-skia keeps premultiplied pixels; a frame carries straight alpha. let mut rgba = Vec::with_capacity(canvas.pixels().len() * 4); for pixel in canvas.pixels() { @@ -500,19 +543,21 @@ pub fn render_frame( block_height: block.1, block_dx: block.2, block_dy: block.3, + words, }) } /// A painted block: (width, height, dx, dy), on [`Rendered`]'s terms. type Block = (u32, u32, i32, i32); -/// The canvas with the title on it, and the block. +/// The canvas with the title on it, the block, and every word's box on the +/// canvas in reading order. fn paint( fonts: &Fonts, style: &TitleStyle, width: u32, height: u32, -) -> Result<(Pixmap, Block), Error> { +) -> Result<(Pixmap, Block, Vec), Error> { let mut canvas = Pixmap::new(width, height).ok_or(Error::Canvas(width, height))?; let frame_h = height as f32; let em = (style.font_size.clamp(0.005, 1.0) as f32) * frame_h; @@ -546,7 +591,7 @@ fn paint( let words_h = (rows as f32 - 1.0) * pitch + ascent + descent; if words_w <= 0.0 || lines.is_empty() { // Nothing to paint: an empty, valid canvas. - return Ok((canvas, (0, 0, 0, 0))); + return Ok((canvas, (0, 0, 0, 0), Vec::new())); } // The box the words sit in. Sized by the style where the style says, @@ -596,8 +641,10 @@ fn paint( let block_dx = (outer_left + outer_w / 2.0 - anchor_x).round() as i32; // One path for all the words, placed. Each line is aligned within the - // box's width and sits on its own baseline. + // box's width and sits on its own baseline. A word's box rides the + // same indent and baseline, in reading order. let mut words = PathBuilder::new(); + let mut word_rects = Vec::new(); for (row, line) in lines.iter().enumerate() { let Some(path) = &line.path else { continue }; let indent = match style.align { @@ -611,11 +658,21 @@ fn paint( .transform(Transform::from_translate(left + indent, baseline)) .expect("a translated glyph path stays finite"); words.push_path(&placed); + let line_top = baseline - ascent; + for &(start_x, end_x) in &line.words { + word_rects.push(WordRect { + x: (left + indent + start_x).round() as i32, + y: line_top.round() as i32, + width: (end_x - start_x).max(0.0).round() as u32, + height: (ascent + descent).round() as u32, + }); + } } let Some(words) = words.finish() else { return Ok(( canvas, (outer_w.round() as u32, outer_h.round() as u32, block_dx, 0), + word_rects, )); }; @@ -695,6 +752,7 @@ fn paint( Ok(( canvas, (outer_w.round() as u32, outer_h.round() as u32, block_dx, 0), + word_rects, )) } @@ -771,6 +829,30 @@ mod tests { assert!(rendered.block_width > 7); } + /// Every word gets a box, in reading order, left to right on a line and + /// top to bottom across lines that wrap - what a per-word reveal keys + /// off without knowing what a word is. + #[test] + fn every_word_gets_a_box_in_reading_order() { + let fonts = Fonts::new(); + let out = render(&fonts, &style("one two three"), 640, 360).expect("renders"); + assert_eq!(out.words.len(), 3, "{:?}", out.words); + assert!(out.words[0].x < out.words[1].x); + assert!(out.words[1].x < out.words[2].x); + for word in &out.words { + assert!(word.width > 0 && word.height > 0, "{word:?}"); + } + + let mut narrow = style("one two three four five six"); + narrow.max_width = 0.3; + let wrapped = render(&fonts, &narrow, 640, 360).expect("renders"); + assert_eq!(wrapped.words.len(), 6, "{:?}", wrapped.words); + // A later word on a lower line sits strictly below an earlier one. + let last_row_y = wrapped.words.last().expect("six words").y; + let first_row_y = wrapped.words.first().expect("six words").y; + assert!(last_row_y > first_row_y, "{last_row_y} vs {first_row_y}"); + } + fn opaque_pixels(png: &[u8]) -> usize { let pixmap = Pixmap::decode_png(png).expect("our own PNG decodes"); pixmap.pixels().iter().filter(|p| p.alpha() > 0).count() diff --git a/src/crates/concat/src/lib.rs b/src/crates/concat/src/lib.rs index c687057c..5372e48a 100644 --- a/src/crates/concat/src/lib.rs +++ b/src/crates/concat/src/lib.rs @@ -161,9 +161,11 @@ pub fn run() -> Result<(), slint::PlatformError> { editor.set_catalogue_effects(ModelRc::from(models.catalogue_effects.clone())); editor.set_catalogue_filters(ModelRc::from(models.catalogue_filters.clone())); editor.set_catalogue_audio(ModelRc::from(models.catalogue_audio.clone())); + editor.set_catalogue_transitions(ModelRc::from(models.catalogue_transitions.clone())); editor.set_effect_groups(ModelRc::from(models.effect_groups.clone())); editor.set_filter_groups(ModelRc::from(models.filter_groups.clone())); editor.set_audio_groups(ModelRc::from(models.audio_groups.clone())); + editor.set_transition_groups(ModelRc::from(models.transition_groups.clone())); editor.set_applied_visual(ModelRc::from(models.applied_visual.clone())); editor.set_applied_audio(ModelRc::from(models.applied_audio.clone())); editor.set_visual_params(ModelRc::from(models.visual_params.clone())); @@ -788,6 +790,9 @@ pub fn run() -> Result<(), slint::PlatformError> { library.on_favourites_changed(on_window!(|state, shelf: i32, on: bool| { state.library_favourites(shelf, on); })); + library.on_category_changed(on_window!(|state, shelf: i32, category: SharedString| { + state.library_category(shelf, &category); + })); library.on_favourite(on_window!(|state, id: SharedString, on: bool| { state.library_favourite(&id, on); })); @@ -891,6 +896,12 @@ pub fn run() -> Result<(), slint::PlatformError> { editor.on_cutout_clear(on_window!(|state| { state.cutout_clear(); })); + editor.on_transition_remove(on_window!(|state| { + state.remove_transition(); + })); + editor.on_transition_duration_set(on_window!(|state, seconds: f32| { + state.set_transition_duration(seconds as f64); + })); // ── the context menu ── editor.on_clip_context(on_window!(|state, id: SharedString| { diff --git a/src/crates/concat/src/studio.rs b/src/crates/concat/src/studio.rs index 3150224b..abc54210 100644 --- a/src/crates/concat/src/studio.rs +++ b/src/crates/concat/src/studio.rs @@ -192,12 +192,19 @@ pub struct LibraryView { pub group: i32, /// Only starred packages show, whatever shelf they are on. pub favourites: bool, + /// Active category filter (e.g. "All", "Featured", "Retro & Film", etc.). + pub category: String, } /// Which library a shelf index names. The numbers are the panel's own - -/// `PresetStack.shelf` - and the order the tabs sit in. -pub const SHELF_KINDS: [PackageKind; 3] = - [PackageKind::Filter, PackageKind::Effect, PackageKind::Audio]; +/// `PresetStack.shelf`, and `Library.view`'s shelf argument everywhere else - +/// and the order the tabs sit in. +pub const SHELF_KINDS: [PackageKind; 4] = [ + PackageKind::Filter, + PackageKind::Effect, + PackageKind::Audio, + PackageKind::Transition, +]; /// `name` under the home directory, as a path string; empty when there is /// no home to speak of, and the form then asks for a folder outright. @@ -249,6 +256,11 @@ pub enum Gesture { duration: f32, source_start: f32, }, + /// Dragging a transition's duration handle on the timeline. + TransitionResize { + clip: String, + original_duration: f32, + }, /// A drag on the stage: every selected picture under the playhead slides /// with the pointer, from where each one was when the press landed. StageMove { @@ -418,9 +430,11 @@ pub struct Models { pub catalogue_effects: Rc>, pub catalogue_filters: Rc>, pub catalogue_audio: Rc>, + pub catalogue_transitions: Rc>, pub effect_groups: Rc>, pub filter_groups: Rc>, pub audio_groups: Rc>, + pub transition_groups: Rc>, /// The selected clip's two chains and their knobs. pub applied_visual: Rc>, pub applied_audio: Rc>, @@ -463,9 +477,11 @@ impl Models { catalogue_effects: Rc::new(VecModel::default()), catalogue_filters: Rc::new(VecModel::default()), catalogue_audio: Rc::new(VecModel::default()), + catalogue_transitions: Rc::new(VecModel::default()), effect_groups: Rc::new(VecModel::default()), filter_groups: Rc::new(VecModel::default()), audio_groups: Rc::new(VecModel::default()), + transition_groups: Rc::new(VecModel::default()), applied_visual: Rc::new(VecModel::default()), applied_audio: Rc::new(VecModel::default()), visual_params: Rc::new(VecModel::default()), @@ -514,7 +530,7 @@ pub struct Studio { pub prefs: Preferences, /// What each effect library is showing, indexed the way `SHELF_KINDS` /// is: 0 filters, 1 effects, 2 audio. - pub library: [LibraryView; 3], + pub library: [LibraryView; 4], // ── the edit ── pub session: Option, @@ -768,8 +784,12 @@ fn shelves( // "echo" wants the echo, not the echo that happens to be filed // where they were already looking. name.to_lowercase().contains(&query) || description.to_lowercase().contains(&query) - } else { + } else if !view.category.is_empty() { + package.matches_category(&view.category) + } else if view.group >= 0 { group as i32 == view.group + } else { + true }; if !shown { continue; @@ -923,7 +943,7 @@ fn set_slot( struct ShelfStamp { catalogue: usize, lang: String, - views: Vec<(String, i32, bool)>, + views: Vec<(String, i32, bool, String)>, favourites: Vec, } @@ -1262,6 +1282,10 @@ impl Studio { studio.handle(crate::panes::Msg::Settings( crate::panes::settings::SettingsMsg::Restore, )); + // The effects shelf starts on "All", not whatever a stale default + // would leave it on. + studio.library[1].category = "All".to_owned(); + studio.library[1].group = -1; studio } @@ -2393,9 +2417,9 @@ impl Studio { let Some(clip) = self.clip(&clip_id).cloned() else { return; }; - if video && !clip.kind.is_visual() { + if video && !(clip.kind.is_visual() || clip.kind == model::ClipKind::Text) { self.notify( - &t("Select a video or image clip on the timeline first"), + &t("Select a video, image or text clip on the timeline first"), true, ); return; @@ -2431,27 +2455,131 @@ impl Studio { ); } + /// The clip that ends where `clip` starts, on the same track - the + /// outgoing side of the cut a transition into `clip` rides. `None` when + /// nothing is adjacent, which is when a transition has nothing to + /// dissolve from. + fn outgoing_of(&self, clip: &Clip) -> Option<&Clip> { + let frame = self.frame_seconds(); + self.timeline() + .clips + .iter() + .find(|other| { + other.id != clip.id + && other.track_id == clip.track_id + && (other.start + other.duration - clip.start).abs() < frame / 2.0 + }) + .map(|other| other.as_ref()) + } + + /// One frame of the active timeline, in seconds - the same tolerance + /// `concat-export`'s own adjacency test uses, so a clip this call finds + /// adjacent is one the exporter will too. + fn frame_seconds(&self) -> f64 { + let video = &self.timeline().video; + video.rate_den as f64 / video.rate_num.max(1) as f64 + } + + /// The duration a transition into `clip` can actually run at: the + /// requested length, clamped to both clips' own durations. `None` when there + /// is no adjacent clip to dissolve from, or both clips are shorter than a frame - + /// the ways a transition here would render as nothing at all. + pub fn transition_duration(&self, clip: &Clip, requested: f64) -> Option { + let outgoing = self.outgoing_of(clip)?; + let frame = self.frame_seconds(); + let max_duration = outgoing.duration.min(clip.duration); + if max_duration < frame { + return None; + } + let requested = if requested <= 0.0 { + frame + } else { + requested.max(frame) + }; + let duration = requested.min(max_duration).max(0.0); + (duration >= frame).then_some(duration) + } + pub fn apply_transition(&mut self, id: &str) { let Some(clip_id) = self.sole_selection() else { self.notify(&t("Select the clip the transition leads into"), true); return; }; - if !self - .clip(&clip_id) - .is_some_and(|clip| clip.kind.is_visual()) - { + let Some(clip) = self.clip(&clip_id) else { + return; + }; + if !clip.kind.is_visual() { self.notify( &t("Select a video or image clip on the timeline first"), true, ); return; } + let Some(duration) = self.transition_duration(clip, 0.5) else { + self.notify( + &t( + "There's no room for a transition here - place an adjacent clip \ + before this one on the same track to dissolve from", + ), + true, + ); + return; + }; self.apply(Command::UpdateClip { clip_id, patch: ClipPatch { transition_in: Some(Some(Transition { id: id.to_owned(), - duration: 0.5, + duration, + })), + ..ClipPatch::default() + }, + }); + } + + /// Takes the transition off the selected clip, leaving a plain cut. + pub fn remove_transition(&mut self) { + let Some(clip_id) = self.sole_selection() else { + return; + }; + self.apply(Command::UpdateClip { + clip_id, + patch: ClipPatch { + transition_in: Some(None), + ..ClipPatch::default() + }, + }); + } + + /// Sets the selected clip's transition to `seconds`, clamped to what + /// the cut can actually support - the same ceiling `apply_transition` + /// enforces, kept here so a drag on the timeline can never ask for more + /// than the render will give it. + pub fn set_transition_duration(&mut self, seconds: f64) { + let Some(clip_id) = self.sole_selection() else { + return; + }; + self.set_clip_transition_duration(&clip_id, seconds); + } + + /// Sets `clip_id`'s transition to `seconds`, clamped to what the cut can + /// actually support. + pub fn set_clip_transition_duration(&mut self, clip_id: &str, seconds: f64) { + let Some(clip) = self.clip(clip_id) else { + return; + }; + let Some(existing) = clip.transition_in.clone() else { + return; + }; + let Some(duration) = self.transition_duration(clip, seconds.max(0.0)) else { + return; + }; + self.apply(Command::UpdateClip { + clip_id: clip_id.to_owned(), + patch: ClipPatch { + transition_in: Some(Some(Transition { + duration, + ..existing })), ..ClipPatch::default() }, @@ -2768,6 +2896,15 @@ impl Studio { }; self.begin_echo(); + if edge == 2 && self.selection.len() <= 1 { + if let Some(transition) = clip.transition_in.as_ref() { + self.gesture = Gesture::TransitionResize { + clip: id.to_owned(), + original_duration: transition.duration as f32, + }; + return; + } + } if edge >= 0 && self.selection.len() <= 1 { self.gesture = Gesture::Trim { clip: id.to_owned(), @@ -2870,6 +3007,23 @@ impl Studio { } } } + Gesture::TransitionResize { + clip, + original_duration, + } => { + let id = clip.clone(); + let original_duration = *original_duration; + if let Some(clip_ref) = self.clip(&id).cloned() { + let wanted = (original_duration + seconds).max(0.0) as f64; + if let Some(duration) = self.transition_duration(&clip_ref, wanted) { + if let Some(echo_clip) = self.echo_clip_mut(&id) { + if let Some(t) = echo_clip.transition_in.as_mut() { + t.duration = duration; + } + } + } + } + } // A stage gesture is the monitor's; the lanes have nothing to // add to it. Gesture::None @@ -2936,6 +3090,18 @@ impl Studio { }); } } + Gesture::TransitionResize { + clip, + original_duration, + } => { + let after = echo.active().clip(&clip).cloned(); + self.echo = None; + let Some(after) = after else { return }; + let new_duration = after.transition_in.as_ref().map(|t| t.duration).unwrap_or(0.0); + if (new_duration - f64::from(original_duration)).abs() > 1e-4 { + self.set_clip_transition_duration(&clip, new_duration); + } + } Gesture::None => { self.echo = None; } @@ -3049,6 +3215,12 @@ impl Studio { value, clip.duration, ), + ClipField::AnimLoop => set_slot( + concat_project::model::AnimationSlot::Loop, + &mut clip.animation_loop, + value, + 1.0, + ), ClipField::AnimInDuration => { if let Some(set) = clip.animation_in.as_mut() { set.duration = value.clamp(0.05, 60.0); @@ -3059,6 +3231,11 @@ impl Studio { set.duration = value.clamp(0.05, 60.0); } } + ClipField::AnimLoopDuration => { + if let Some(set) = clip.animation_loop.as_mut() { + set.duration = value.clamp(0.1, 60.0); + } + } ClipField::SpeedCurve => { // The same arithmetic as the command: the source covered is // held, so the length follows the curve's mean. @@ -3316,6 +3493,11 @@ impl Studio { &after.animation_combo, &before.animation_combo, ), + ( + concat_project::model::AnimationSlot::Loop, + &after.animation_loop, + &before.animation_loop, + ), ] { if now != was { commands.push(Command::SetClipAnimation { @@ -5040,7 +5222,11 @@ impl Studio { duration: clip.duration as f32, selected: self.selection.iter().any(|id| id == &clip.id), fx: clip.video_effects.iter().any(|effect| effect.enabled), - transition_in: clip.transition_in.is_some(), + transition_duration: clip + .transition_in + .as_ref() + .map(|transition| transition.duration as f32) + .unwrap_or(0.0), fade_in: clip.fade_in as f32, fade_out: clip.fade_out as f32, volume: clip.volume as f32, @@ -5232,11 +5418,21 @@ impl Studio { pub fn library_group(&mut self, shelf: i32, index: i32) { if let Some(view) = self.library_at(shelf) { view.group = index.max(0); + view.category.clear(); view.query.clear(); view.favourites = false; } } + /// A category pill was picked. + pub fn library_category(&mut self, shelf: i32, category: &str) { + if let Some(view) = self.library_at(shelf) { + view.category = category.to_owned(); + view.group = -1; + view.query.clear(); + } + } + /// The star chip. Turning it on clears the query for the same reason /// picking a shelf does: two filters at once is a list nobody can /// predict the contents of. @@ -5428,6 +5624,24 @@ impl Studio { name: clip.name.as_str().into(), kind: kind_of(clip), duration: clip.duration as f32, + transition_id: clip + .transition_in + .as_ref() + .map(|transition| transition.id.as_str()) + .unwrap_or_default() + .into(), + transition_name: clip + .transition_in + .as_ref() + .and_then(|transition| Catalogue::builtin().get(&transition.id)) + .map(|package| t(&package.manifest.effect.name)) + .unwrap_or_default() + .into(), + transition_duration: clip + .transition_in + .as_ref() + .map(|transition| transition.duration as f32) + .unwrap_or(0.5), scale: clip.scale as f32, offset_x: clip.offset_x as f32, offset_y: clip.offset_y as f32, @@ -5459,6 +5673,10 @@ impl Studio { concat_project::model::AnimationSlot::Combo, &clip.animation_combo, ), + anim_loop: slot_index( + concat_project::model::AnimationSlot::Loop, + &clip.animation_loop, + ), anim_in_duration: clip .animation_in .as_ref() @@ -5469,6 +5687,11 @@ impl Studio { .as_ref() .map(|set| set.duration as f32) .unwrap_or(0.5), + anim_loop_duration: clip + .animation_loop + .as_ref() + .map(|set| set.duration as f32) + .unwrap_or(1.0), flip_h: clip.flip_h, flip_v: clip.flip_v, blend: concat_core::Blend::ALL @@ -5673,7 +5896,7 @@ impl Studio { views: self .library .iter() - .map(|view| (view.query.clone(), view.group, view.favourites)) + .map(|view| (view.query.clone(), view.group, view.favourites, view.category.clone())) .collect(), favourites: starred.clone(), }; @@ -5690,6 +5913,10 @@ impl Studio { shelves(SHELF_KINDS[2], &self.library[2], starred, &self.look_art); sync(&models.audio_groups, groups); sync(&models.catalogue_audio, entries); + let (groups, entries) = + shelves(SHELF_KINDS[3], &self.library[3], starred, &self.look_art); + sync(&models.transition_groups, groups); + sync(&models.catalogue_transitions, entries); *self.shelf_stamp.borrow_mut() = Some(stamp); } sync( @@ -5700,6 +5927,7 @@ impl Studio { query: view.query.as_str().into(), group: view.group, favourites: view.favourites, + category: view.category.as_str().into(), }) .collect(), ); diff --git a/src/crates/concat/ui/assets/effect-previews/adjust.jpg b/src/crates/concat/ui/assets/effect-previews/adjust.jpg new file mode 100644 index 00000000..ad18fb3e Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/adjust.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/autumn.jpg b/src/crates/concat/ui/assets/effect-previews/autumn.jpg new file mode 100644 index 00000000..20de26da Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/autumn.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/bass-shockwave.jpg b/src/crates/concat/ui/assets/effect-previews/bass-shockwave.jpg new file mode 100644 index 00000000..14ceb333 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/bass-shockwave.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/black-white.jpg b/src/crates/concat/ui/assets/effect-previews/black-white.jpg index e33005b9..83c0922f 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/black-white.jpg and b/src/crates/concat/ui/assets/effect-previews/black-white.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/bleach-bypass.jpg b/src/crates/concat/ui/assets/effect-previews/bleach-bypass.jpg new file mode 100644 index 00000000..3f4f0bc4 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/bleach-bypass.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/bloom-pulse.jpg b/src/crates/concat/ui/assets/effect-previews/bloom-pulse.jpg new file mode 100644 index 00000000..b8a1211f Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/bloom-pulse.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/blue-screen.jpg b/src/crates/concat/ui/assets/effect-previews/blue-screen.jpg new file mode 100644 index 00000000..928b38c3 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/blue-screen.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/blur-dissolve.jpg b/src/crates/concat/ui/assets/effect-previews/blur-dissolve.jpg new file mode 100644 index 00000000..09f9b9f9 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/blur-dissolve.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/bokeh.jpg b/src/crates/concat/ui/assets/effect-previews/bokeh.jpg new file mode 100644 index 00000000..3f547bf6 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/bokeh.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/box-blur.jpg b/src/crates/concat/ui/assets/effect-previews/box-blur.jpg index 51abb9f2..5ea94b97 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/box-blur.jpg and b/src/crates/concat/ui/assets/effect-previews/box-blur.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/bronze.jpg b/src/crates/concat/ui/assets/effect-previews/bronze.jpg index 117922b1..a48b8294 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/bronze.jpg and b/src/crates/concat/ui/assets/effect-previews/bronze.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/bulge-pinch.jpg b/src/crates/concat/ui/assets/effect-previews/bulge-pinch.jpg new file mode 100644 index 00000000..b41745db Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/bulge-pinch.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/camcorder-90s.jpg b/src/crates/concat/ui/assets/effect-previews/camcorder-90s.jpg new file mode 100644 index 00000000..136a28e8 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/camcorder-90s.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/camera-shake.jpg b/src/crates/concat/ui/assets/effect-previews/camera-shake.jpg new file mode 100644 index 00000000..a22daaf8 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/camera-shake.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/chromatic-aberration.jpg b/src/crates/concat/ui/assets/effect-previews/chromatic-aberration.jpg index 4f2b8ab0..743fc28a 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/chromatic-aberration.jpg and b/src/crates/concat/ui/assets/effect-previews/chromatic-aberration.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/classic-chrome.jpg b/src/crates/concat/ui/assets/effect-previews/classic-chrome.jpg new file mode 100644 index 00000000..75c400b5 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/classic-chrome.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/clean.jpg b/src/crates/concat/ui/assets/effect-previews/clean.jpg index 37c771c4..7cd0d23d 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/clean.jpg and b/src/crates/concat/ui/assets/effect-previews/clean.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/clock-wipe.jpg b/src/crates/concat/ui/assets/effect-previews/clock-wipe.jpg new file mode 100644 index 00000000..903880e3 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/clock-wipe.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/cloudless-sky.jpg b/src/crates/concat/ui/assets/effect-previews/cloudless-sky.jpg index f3e100d5..48359683 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/cloudless-sky.jpg and b/src/crates/concat/ui/assets/effect-previews/cloudless-sky.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/color-cycle.jpg b/src/crates/concat/ui/assets/effect-previews/color-cycle.jpg new file mode 100644 index 00000000..fc946d96 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/color-cycle.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/contrast-pop.jpg b/src/crates/concat/ui/assets/effect-previews/contrast-pop.jpg index c5d46098..1793487f 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/contrast-pop.jpg and b/src/crates/concat/ui/assets/effect-previews/contrast-pop.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/cool-blue.jpg b/src/crates/concat/ui/assets/effect-previews/cool-blue.jpg index 60e70837..90e29c6c 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/cool-blue.jpg and b/src/crates/concat/ui/assets/effect-previews/cool-blue.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/cool.jpg b/src/crates/concat/ui/assets/effect-previews/cool.jpg index cf780417..3cc6cb9d 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/cool.jpg and b/src/crates/concat/ui/assets/effect-previews/cool.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/crosshatch.jpg b/src/crates/concat/ui/assets/effect-previews/crosshatch.jpg new file mode 100644 index 00000000..0aa269a7 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/crosshatch.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/crt-scanlines.jpg b/src/crates/concat/ui/assets/effect-previews/crt-scanlines.jpg new file mode 100644 index 00000000..1d1e5d04 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/crt-scanlines.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/cube.jpg b/src/crates/concat/ui/assets/effect-previews/cube.jpg new file mode 100644 index 00000000..b9772778 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/cube.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/cyberpunk.jpg b/src/crates/concat/ui/assets/effect-previews/cyberpunk.jpg new file mode 100644 index 00000000..2182828b Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/cyberpunk.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/datamosh.jpg b/src/crates/concat/ui/assets/effect-previews/datamosh.jpg new file mode 100644 index 00000000..ec424a52 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/datamosh.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/daydream.jpg b/src/crates/concat/ui/assets/effect-previews/daydream.jpg index 16ab04c9..e495ada7 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/daydream.jpg and b/src/crates/concat/ui/assets/effect-previews/daydream.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/delicious.jpg b/src/crates/concat/ui/assets/effect-previews/delicious.jpg new file mode 100644 index 00000000..64609d5c Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/delicious.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/digicam.jpg b/src/crates/concat/ui/assets/effect-previews/digicam.jpg index 96b59b16..7789303a 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/digicam.jpg and b/src/crates/concat/ui/assets/effect-previews/digicam.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/dissolve.jpg b/src/crates/concat/ui/assets/effect-previews/dissolve.jpg new file mode 100644 index 00000000..0b81ca8e Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/dissolve.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/dreamy.jpg b/src/crates/concat/ui/assets/effect-previews/dreamy.jpg new file mode 100644 index 00000000..7b6dedf4 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/dreamy.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/duotone.jpg b/src/crates/concat/ui/assets/effect-previews/duotone.jpg new file mode 100644 index 00000000..0984ffff Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/duotone.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/dust.jpg b/src/crates/concat/ui/assets/effect-previews/dust.jpg new file mode 100644 index 00000000..877de91a Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/dust.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/emboss.jpg b/src/crates/concat/ui/assets/effect-previews/emboss.jpg index 98ff1da7..75cf19b7 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/emboss.jpg and b/src/crates/concat/ui/assets/effect-previews/emboss.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/fair.jpg b/src/crates/concat/ui/assets/effect-previews/fair.jpg index df5ec1e5..63ac87ad 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/fair.jpg and b/src/crates/concat/ui/assets/effect-previews/fair.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/falling-dust.jpg b/src/crates/concat/ui/assets/effect-previews/falling-dust.jpg index e4bf286b..4299ce00 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/falling-dust.jpg and b/src/crates/concat/ui/assets/effect-previews/falling-dust.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/film-burn.jpg b/src/crates/concat/ui/assets/effect-previews/film-burn.jpg new file mode 100644 index 00000000..9131cd3e Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/film-burn.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/film-grain.jpg b/src/crates/concat/ui/assets/effect-previews/film-grain.jpg index a1a6f9e6..410cf442 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/film-grain.jpg and b/src/crates/concat/ui/assets/effect-previews/film-grain.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/film-reel.jpg b/src/crates/concat/ui/assets/effect-previews/film-reel.jpg new file mode 100644 index 00000000..c8d544fe Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/film-reel.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/film.jpg b/src/crates/concat/ui/assets/effect-previews/film.jpg index 0e1b4360..c397f36e 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/film.jpg and b/src/crates/concat/ui/assets/effect-previews/film.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/fisheye.jpg b/src/crates/concat/ui/assets/effect-previews/fisheye.jpg index 84e4403d..be8ffba3 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/fisheye.jpg and b/src/crates/concat/ui/assets/effect-previews/fisheye.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/fog.jpg b/src/crates/concat/ui/assets/effect-previews/fog.jpg new file mode 100644 index 00000000..d291b5ef Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/fog.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/fresh.jpg b/src/crates/concat/ui/assets/effect-previews/fresh.jpg index 57249abd..33a49d4d 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/fresh.jpg and b/src/crates/concat/ui/assets/effect-previews/fresh.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/gaussian-blur.jpg b/src/crates/concat/ui/assets/effect-previews/gaussian-blur.jpg index ce7113eb..c8d90a6b 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/gaussian-blur.jpg and b/src/crates/concat/ui/assets/effect-previews/gaussian-blur.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/glitch-transition.jpg b/src/crates/concat/ui/assets/effect-previews/glitch-transition.jpg new file mode 100644 index 00000000..b76aa798 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/glitch-transition.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/glitch-warp.jpg b/src/crates/concat/ui/assets/effect-previews/glitch-warp.jpg new file mode 100644 index 00000000..a93e631d Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/glitch-warp.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/glow.jpg b/src/crates/concat/ui/assets/effect-previews/glow.jpg index a456043e..856db6fa 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/glow.jpg and b/src/crates/concat/ui/assets/effect-previews/glow.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/golden-hour.jpg b/src/crates/concat/ui/assets/effect-previews/golden-hour.jpg index 90852126..ad16c72e 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/golden-hour.jpg and b/src/crates/concat/ui/assets/effect-previews/golden-hour.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/green-screen.jpg b/src/crates/concat/ui/assets/effect-previews/green-screen.jpg new file mode 100644 index 00000000..928b38c3 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/green-screen.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/halation.jpg b/src/crates/concat/ui/assets/effect-previews/halation.jpg new file mode 100644 index 00000000..c08fa253 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/halation.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/halftone.jpg b/src/crates/concat/ui/assets/effect-previews/halftone.jpg new file mode 100644 index 00000000..4bdee9b7 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/halftone.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/high-contrast-mono.jpg b/src/crates/concat/ui/assets/effect-previews/high-contrast-mono.jpg new file mode 100644 index 00000000..f8a1df34 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/high-contrast-mono.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/hologram.jpg b/src/crates/concat/ui/assets/effect-previews/hologram.jpg index 2896910d..a5c2458e 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/hologram.jpg and b/src/crates/concat/ui/assets/effect-previews/hologram.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/hue-shift.jpg b/src/crates/concat/ui/assets/effect-previews/hue-shift.jpg index 79aedab1..08cecbe3 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/hue-shift.jpg and b/src/crates/concat/ui/assets/effect-previews/hue-shift.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/invert.jpg b/src/crates/concat/ui/assets/effect-previews/invert.jpg index b553518b..2cb16855 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/invert.jpg and b/src/crates/concat/ui/assets/effect-previews/invert.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/iris.jpg b/src/crates/concat/ui/assets/effect-previews/iris.jpg new file mode 100644 index 00000000..d83e8ccf Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/iris.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/ivory.jpg b/src/crates/concat/ui/assets/effect-previews/ivory.jpg index 3da55b6e..ea849dfc 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/ivory.jpg and b/src/crates/concat/ui/assets/effect-previews/ivory.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/kaleidoscope.jpg b/src/crates/concat/ui/assets/effect-previews/kaleidoscope.jpg new file mode 100644 index 00000000..de07fe53 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/kaleidoscope.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/lagoon.jpg b/src/crates/concat/ui/assets/effect-previews/lagoon.jpg index cd0d4f02..fcbf5d43 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/lagoon.jpg and b/src/crates/concat/ui/assets/effect-previews/lagoon.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/landscape-pop.jpg b/src/crates/concat/ui/assets/effect-previews/landscape-pop.jpg new file mode 100644 index 00000000..296de194 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/landscape-pop.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/laser-beams.jpg b/src/crates/concat/ui/assets/effect-previews/laser-beams.jpg new file mode 100644 index 00000000..f82643a1 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/laser-beams.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/lens-flare.jpg b/src/crates/concat/ui/assets/effect-previews/lens-flare.jpg new file mode 100644 index 00000000..1f47330f Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/lens-flare.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/light-leak-flash.jpg b/src/crates/concat/ui/assets/effect-previews/light-leak-flash.jpg new file mode 100644 index 00000000..88382a1d Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/light-leak-flash.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/light-leak-transition.jpg b/src/crates/concat/ui/assets/effect-previews/light-leak-transition.jpg new file mode 100644 index 00000000..9131cd3e Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/light-leak-transition.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/light-leak.jpg b/src/crates/concat/ui/assets/effect-previews/light-leak.jpg new file mode 100644 index 00000000..d80d6012 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/light-leak.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/linear-wipe.jpg b/src/crates/concat/ui/assets/effect-previews/linear-wipe.jpg new file mode 100644 index 00000000..e51fd4df Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/linear-wipe.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/marquee.jpg b/src/crates/concat/ui/assets/effect-previews/marquee.jpg index 68fcbb4e..78d72a9c 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/marquee.jpg and b/src/crates/concat/ui/assets/effect-previews/marquee.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/mask-circle.jpg b/src/crates/concat/ui/assets/effect-previews/mask-circle.jpg new file mode 100644 index 00000000..67a55604 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/mask-circle.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/mask-linear.jpg b/src/crates/concat/ui/assets/effect-previews/mask-linear.jpg new file mode 100644 index 00000000..67a55604 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/mask-linear.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/mask-rectangle.jpg b/src/crates/concat/ui/assets/effect-previews/mask-rectangle.jpg new file mode 100644 index 00000000..67a55604 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/mask-rectangle.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/matcha.jpg b/src/crates/concat/ui/assets/effect-previews/matcha.jpg index 8f033ce3..9d6236dc 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/matcha.jpg and b/src/crates/concat/ui/assets/effect-previews/matcha.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/midnight.jpg b/src/crates/concat/ui/assets/effect-previews/midnight.jpg index c91a7e5b..973dc17a 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/midnight.jpg and b/src/crates/concat/ui/assets/effect-previews/midnight.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/milk.jpg b/src/crates/concat/ui/assets/effect-previews/milk.jpg index b1dc85a8..41aa10ce 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/milk.jpg and b/src/crates/concat/ui/assets/effect-previews/milk.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/mirror-tile.jpg b/src/crates/concat/ui/assets/effect-previews/mirror-tile.jpg new file mode 100644 index 00000000..4c5303fe Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/mirror-tile.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/mirror.jpg b/src/crates/concat/ui/assets/effect-previews/mirror.jpg index e8c16f3b..ce1974fb 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/mirror.jpg and b/src/crates/concat/ui/assets/effect-previews/mirror.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/mono.jpg b/src/crates/concat/ui/assets/effect-previews/mono.jpg index df8d95b3..9af3bf9c 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/mono.jpg and b/src/crates/concat/ui/assets/effect-previews/mono.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/moody.jpg b/src/crates/concat/ui/assets/effect-previews/moody.jpg index 087dbbe4..14607830 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/moody.jpg and b/src/crates/concat/ui/assets/effect-previews/moody.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/motion-blur.jpg b/src/crates/concat/ui/assets/effect-previews/motion-blur.jpg index 2db5c4e3..52c87551 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/motion-blur.jpg and b/src/crates/concat/ui/assets/effect-previews/motion-blur.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/natural.jpg b/src/crates/concat/ui/assets/effect-previews/natural.jpg new file mode 100644 index 00000000..306ce0df Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/natural.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/neon-edges.jpg b/src/crates/concat/ui/assets/effect-previews/neon-edges.jpg new file mode 100644 index 00000000..a77a2819 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/neon-edges.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/neon-glow.jpg b/src/crates/concat/ui/assets/effect-previews/neon-glow.jpg new file mode 100644 index 00000000..29e19fc2 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/neon-glow.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/neon.jpg b/src/crates/concat/ui/assets/effect-previews/neon.jpg index f0668677..c039b0db 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/neon.jpg and b/src/crates/concat/ui/assets/effect-previews/neon.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/night.jpg b/src/crates/concat/ui/assets/effect-previews/night.jpg index 7a7c66ca..394de881 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/night.jpg and b/src/crates/concat/ui/assets/effect-previews/night.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/noir.jpg b/src/crates/concat/ui/assets/effect-previews/noir.jpg new file mode 100644 index 00000000..3ddbabf4 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/noir.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/oil-paint.jpg b/src/crates/concat/ui/assets/effect-previews/oil-paint.jpg new file mode 100644 index 00000000..00f6b4ef Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/oil-paint.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/peach.jpg b/src/crates/concat/ui/assets/effect-previews/peach.jpg index 8eafbf89..5c229be4 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/peach.jpg and b/src/crates/concat/ui/assets/effect-previews/peach.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/pink-fuzz.jpg b/src/crates/concat/ui/assets/effect-previews/pink-fuzz.jpg index b1263dce..0d08dc8b 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/pink-fuzz.jpg and b/src/crates/concat/ui/assets/effect-previews/pink-fuzz.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/pixelate.jpg b/src/crates/concat/ui/assets/effect-previews/pixelate.jpg index 59d59cfb..723826cb 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/pixelate.jpg and b/src/crates/concat/ui/assets/effect-previews/pixelate.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/polaroid.jpg b/src/crates/concat/ui/assets/effect-previews/polaroid.jpg index 8a9f323b..1c189641 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/polaroid.jpg and b/src/crates/concat/ui/assets/effect-previews/polaroid.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/posterize.jpg b/src/crates/concat/ui/assets/effect-previews/posterize.jpg index 920cf902..8283947f 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/posterize.jpg and b/src/crates/concat/ui/assets/effect-previews/posterize.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/prism-dispersion.jpg b/src/crates/concat/ui/assets/effect-previews/prism-dispersion.jpg new file mode 100644 index 00000000..10e7d778 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/prism-dispersion.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/push.jpg b/src/crates/concat/ui/assets/effect-previews/push.jpg new file mode 100644 index 00000000..b9772778 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/push.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/rain.jpg b/src/crates/concat/ui/assets/effect-previews/rain.jpg index c7d3b03c..00b80439 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/rain.jpg and b/src/crates/concat/ui/assets/effect-previews/rain.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/retro-70s.jpg b/src/crates/concat/ui/assets/effect-previews/retro-70s.jpg new file mode 100644 index 00000000..01ea1879 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/retro-70s.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/retro-cartoon.jpg b/src/crates/concat/ui/assets/effect-previews/retro-cartoon.jpg index 121c8041..5069259a 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/retro-cartoon.jpg and b/src/crates/concat/ui/assets/effect-previews/retro-cartoon.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/rgb-glitch.jpg b/src/crates/concat/ui/assets/effect-previews/rgb-glitch.jpg new file mode 100644 index 00000000..010b84cc Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/rgb-glitch.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/rgb-split-wipe.jpg b/src/crates/concat/ui/assets/effect-previews/rgb-split-wipe.jpg new file mode 100644 index 00000000..e51fd4df Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/rgb-split-wipe.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/rgb-split.jpg b/src/crates/concat/ui/assets/effect-previews/rgb-split.jpg new file mode 100644 index 00000000..24431043 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/rgb-split.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/ripple.jpg b/src/crates/concat/ui/assets/effect-previews/ripple.jpg new file mode 100644 index 00000000..e8839e27 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/ripple.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/rosy.jpg b/src/crates/concat/ui/assets/effect-previews/rosy.jpg new file mode 100644 index 00000000..107d6380 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/rosy.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/sage.jpg b/src/crates/concat/ui/assets/effect-previews/sage.jpg index edd4b86d..27c46d58 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/sage.jpg and b/src/crates/concat/ui/assets/effect-previews/sage.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/scanlines.jpg b/src/crates/concat/ui/assets/effect-previews/scanlines.jpg index 4e61944a..2e6a09e0 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/scanlines.jpg and b/src/crates/concat/ui/assets/effect-previews/scanlines.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/sci-fi.jpg b/src/crates/concat/ui/assets/effect-previews/sci-fi.jpg new file mode 100644 index 00000000..bf2ac037 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/sci-fi.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/sepia.jpg b/src/crates/concat/ui/assets/effect-previews/sepia.jpg index d8994210..f23c75a3 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/sepia.jpg and b/src/crates/concat/ui/assets/effect-previews/sepia.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/shake.jpg b/src/crates/concat/ui/assets/effect-previews/shake.jpg index 0cb5acc2..efeab7c6 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/shake.jpg and b/src/crates/concat/ui/assets/effect-previews/shake.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/shape-wipe.jpg b/src/crates/concat/ui/assets/effect-previews/shape-wipe.jpg new file mode 100644 index 00000000..d83e8ccf Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/shape-wipe.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/sharpen.jpg b/src/crates/concat/ui/assets/effect-previews/sharpen.jpg index f9c0b0bb..58cc7af5 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/sharpen.jpg and b/src/crates/concat/ui/assets/effect-previews/sharpen.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/silver.jpg b/src/crates/concat/ui/assets/effect-previews/silver.jpg new file mode 100644 index 00000000..9e3edaf5 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/silver.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/sketch.jpg b/src/crates/concat/ui/assets/effect-previews/sketch.jpg index 523b559a..919ef590 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/sketch.jpg and b/src/crates/concat/ui/assets/effect-previews/sketch.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/slide.jpg b/src/crates/concat/ui/assets/effect-previews/slide.jpg new file mode 100644 index 00000000..b9772778 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/slide.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/snow.jpg b/src/crates/concat/ui/assets/effect-previews/snow.jpg new file mode 100644 index 00000000..7a9caa88 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/snow.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/soft-light.jpg b/src/crates/concat/ui/assets/effect-previews/soft-light.jpg index 9bfb8b72..c3055625 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/soft-light.jpg and b/src/crates/concat/ui/assets/effect-previews/soft-light.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/solarize.jpg b/src/crates/concat/ui/assets/effect-previews/solarize.jpg index 15c5eb50..fde0ec6e 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/solarize.jpg and b/src/crates/concat/ui/assets/effect-previews/solarize.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/sparkle.jpg b/src/crates/concat/ui/assets/effect-previews/sparkle.jpg new file mode 100644 index 00000000..2b32cdc0 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/sparkle.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/spin.jpg b/src/crates/concat/ui/assets/effect-previews/spin.jpg new file mode 100644 index 00000000..903880e3 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/spin.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/spooky.jpg b/src/crates/concat/ui/assets/effect-previews/spooky.jpg index b2671fe1..395c76ae 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/spooky.jpg and b/src/crates/concat/ui/assets/effect-previews/spooky.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/strobe-flash.jpg b/src/crates/concat/ui/assets/effect-previews/strobe-flash.jpg new file mode 100644 index 00000000..6fe0c0bc Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/strobe-flash.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/super-8.jpg b/src/crates/concat/ui/assets/effect-previews/super-8.jpg new file mode 100644 index 00000000..cc71b789 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/super-8.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/swirl.jpg b/src/crates/concat/ui/assets/effect-previews/swirl.jpg new file mode 100644 index 00000000..903880e3 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/swirl.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/teal-orange.jpg b/src/crates/concat/ui/assets/effect-previews/teal-orange.jpg index 4ff5cf77..700e917e 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/teal-orange.jpg and b/src/crates/concat/ui/assets/effect-previews/teal-orange.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/threshold.jpg b/src/crates/concat/ui/assets/effect-previews/threshold.jpg index 5c997eac..a0f60722 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/threshold.jpg and b/src/crates/concat/ui/assets/effect-previews/threshold.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/tilt-shift.jpg b/src/crates/concat/ui/assets/effect-previews/tilt-shift.jpg new file mode 100644 index 00000000..028f2ab1 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/tilt-shift.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/tropical.jpg b/src/crates/concat/ui/assets/effect-previews/tropical.jpg new file mode 100644 index 00000000..b322db9c Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/tropical.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/twirl.jpg b/src/crates/concat/ui/assets/effect-previews/twirl.jpg new file mode 100644 index 00000000..bdf49e77 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/twirl.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/umber.jpg b/src/crates/concat/ui/assets/effect-previews/umber.jpg index 681011a8..50ac9117 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/umber.jpg and b/src/crates/concat/ui/assets/effect-previews/umber.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/vaporwave.jpg b/src/crates/concat/ui/assets/effect-previews/vaporwave.jpg new file mode 100644 index 00000000..bd39ca49 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/vaporwave.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/vhs.jpg b/src/crates/concat/ui/assets/effect-previews/vhs.jpg index 9b2d2793..09d38022 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/vhs.jpg and b/src/crates/concat/ui/assets/effect-previews/vhs.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/vibrance.jpg b/src/crates/concat/ui/assets/effect-previews/vibrance.jpg index 21ab1a06..1b40b812 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/vibrance.jpg and b/src/crates/concat/ui/assets/effect-previews/vibrance.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/vignette.jpg b/src/crates/concat/ui/assets/effect-previews/vignette.jpg index 934a1724..ca3de3c7 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/vignette.jpg and b/src/crates/concat/ui/assets/effect-previews/vignette.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/vintage.jpg b/src/crates/concat/ui/assets/effect-previews/vintage.jpg index f333762a..767b61e6 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/vintage.jpg and b/src/crates/concat/ui/assets/effect-previews/vintage.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/vivid.jpg b/src/crates/concat/ui/assets/effect-previews/vivid.jpg index f84a3900..0a0882ad 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/vivid.jpg and b/src/crates/concat/ui/assets/effect-previews/vivid.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/vortex-swirl.jpg b/src/crates/concat/ui/assets/effect-previews/vortex-swirl.jpg new file mode 100644 index 00000000..5f83f015 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/vortex-swirl.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/warm.jpg b/src/crates/concat/ui/assets/effect-previews/warm.jpg index 5735597f..d09a7de5 100644 Binary files a/src/crates/concat/ui/assets/effect-previews/warm.jpg and b/src/crates/concat/ui/assets/effect-previews/warm.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/wave-warp.jpg b/src/crates/concat/ui/assets/effect-previews/wave-warp.jpg new file mode 100644 index 00000000..c0dc6f71 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/wave-warp.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/whip-pan.jpg b/src/crates/concat/ui/assets/effect-previews/whip-pan.jpg new file mode 100644 index 00000000..7b3e14e0 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/whip-pan.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/winter-cool.jpg b/src/crates/concat/ui/assets/effect-previews/winter-cool.jpg new file mode 100644 index 00000000..28fbbe31 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/winter-cool.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/zoom-blur-transition.jpg b/src/crates/concat/ui/assets/effect-previews/zoom-blur-transition.jpg new file mode 100644 index 00000000..bbc4b959 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/zoom-blur-transition.jpg differ diff --git a/src/crates/concat/ui/assets/effect-previews/zoom-transition.jpg b/src/crates/concat/ui/assets/effect-previews/zoom-transition.jpg new file mode 100644 index 00000000..b6862998 Binary files /dev/null and b/src/crates/concat/ui/assets/effect-previews/zoom-transition.jpg differ diff --git a/src/crates/concat/ui/editor.slint b/src/crates/concat/ui/editor.slint index 9a2d55bc..751a6696 100644 --- a/src/crates/concat/ui/editor.slint +++ b/src/crates/concat/ui/editor.slint @@ -126,9 +126,11 @@ export global Editor { in-out property <[CatalogueEntryData]> catalogue-effects; in-out property <[CatalogueEntryData]> catalogue-filters; in-out property <[CatalogueEntryData]> catalogue-audio; + in-out property <[CatalogueEntryData]> catalogue-transitions; in-out property <[string]> effect-groups; in-out property <[string]> filter-groups; in-out property <[string]> audio-groups; + in-out property <[string]> transition-groups; in-out property <[AppliedEntryData]> applied-visual; in-out property <[AppliedEntryData]> applied-audio; in-out property <[AppliedParamData]> visual-params; @@ -222,6 +224,9 @@ export global Editor { callback cutout-painting(on: bool); callback cutout-subject(index: int); callback cutout-clear(); + /// The transition riding into the selected clip; see VideoPanel. + callback transition-remove(); + callback transition-duration-set(seconds: float); // ── the timeline ── in-out property <[TimelineTabData]> timeline-tabs; diff --git a/src/crates/concat/ui/inspector/clip-inspector.slint b/src/crates/concat/ui/inspector/clip-inspector.slint index b4d1f886..45becab4 100644 --- a/src/crates/concat/ui/inspector/clip-inspector.slint +++ b/src/crates/concat/ui/inspector/clip-inspector.slint @@ -68,6 +68,8 @@ export struct LibraryViewData { group: int, /// The star is down: only favourites show, whatever shelf they are on. favourites: bool, + /// Active category filter (e.g. "All", "Featured", "Retro & Film", etc.). + category: string, } /// The three effect libraries' view state and the verbs that change it. @@ -79,7 +81,7 @@ export struct LibraryViewData { /// plumbing for a fact that has one owner. export global Library { /// One view per shelf, indexed by the numbers `PresetStack.shelf` uses: - /// 0 filters, 1 effects, 2 audio. + /// 0 filters, 1 effects, 2 audio, 3 transitions. in property <[LibraryViewData]> views; /// The search box was typed in. @@ -88,13 +90,15 @@ export global Library { callback group-changed(shelf: int, index: int); /// The star chip was pressed. callback favourites-changed(shelf: int, on: bool); + /// A category pill was selected. + callback category-changed(shelf: int, category: string); /// A package was starred or unstarred. Not per shelf: a star is a fact /// about the package, and it is remembered across sessions. callback favourite(id: string, on: bool); pure public function view(shelf: int) -> LibraryViewData { - if (Library.views.length < 3) { - return { query: "", group: 0, favourites: false }; + if (Library.views.length < 4) { + return { query: "", group: 0, favourites: false, category: "" }; } return Library.views[shelf]; } @@ -821,6 +825,11 @@ export component VideoPanel inherits VerticalLayout { /// What the automatic cutout keeps: 0 auto, 1 person, 2 object. callback cutout-subject(index: int); callback cutout-clear(); + /// The transition riding into this clip: dropped, or set to a new + /// duration - clamped, same as applying one, so a drag here can never + /// ask for more than the cut can give it. + callback transition-remove(); + callback transition-duration-set(seconds: float); /// Which keyer is on the chain, and where: -1 for none. Scans the first /// eight links, as PresetStack does, for want of a model lookup. @@ -874,6 +883,7 @@ export component VideoPanel inherits VerticalLayout { property crop-right; property crop-bottom; property feather; + property transition-duration; function seed() { root.feather = root.selected.cutout-feather; @@ -888,6 +898,7 @@ export component VideoPanel inherits VerticalLayout { root.crop-top = root.selected.crop-top; root.crop-right = root.selected.crop-right; root.crop-bottom = root.selected.crop-bottom; + root.transition-duration = root.selected.transition-duration; } property <[string]> blends: [ @@ -1313,6 +1324,60 @@ export component VideoPanel inherits VerticalLayout { } } } + + // The transition riding into this clip from the cut before it - see + // Studio::apply_transition. The section always shows, so its place in + // the panel is stable, with a note in place of controls when there is + // none: applying one from the Transitions tab is what puts it here. + Section { + transition := SectionHeader { + title: I18n.t("Transition"); + collapsible: true; + summary: root.selected.transition-id == "" ? I18n.t("none") + : root.selected.transition-name + " · " + + InspectorFmt.format(ParamFormat.seconds2, root.selected.transition-duration); + } + + if transition.open: SectionBody { + if root.selected.transition-id == "": Note { + text: I18n.t("Pick one from the Transitions tab to dissolve into this clip from the cut before it."); + } + + if root.selected.transition-id != "": HorizontalLayout { + padding-left: Theme.pad; + padding-right: Theme.pad; + padding-bottom: 6px; + spacing: 4px; + + Text { + horizontal-stretch: 1; + text: root.selected.transition-name; + color: Theme.fg; + font-size: Theme.fs; + font-weight: Theme.weight-title; + vertical-alignment: center; + overflow: elide; + } + + IconButton { + glyph: Glyph.close; + label: I18n.t1("Remove {0}", "\{root.selected.transition-name}"); + clicked => { root.transition-remove(); } + } + } + + if root.selected.transition-id != "": Param { + label: I18n.t("Duration"); + value <=> root.transition-duration; + minimum: 0.05; + maximum: 5; + step: 0.05; + default-value: 0.5; + fmt: ParamFormat.seconds2; + changed(v) => { root.transition-duration-set(v); } + } + } + } } /// Audio: the level and the fades. The voice and room chain sits under it @@ -1556,6 +1621,147 @@ export component SpeedPanel inherits VerticalLayout { } } +struct AnimCardData { + id: string, + name: string, + glyph: Glyph, +} + +component AnimationCardGrid inherits Rectangle { + in property <[AnimCardData]> items; + in property selected-index: -1; + callback picked(int); + + property columns: 3; + property pad: Theme.pad; + property cell-gap: 6px; + property cell-w: (root.width - 2 * root.pad + - (root.columns - 1) * root.cell-gap) / root.columns; + property cell-h: 56px; + property rows: ceil(root.items.length / root.columns); + + height: root.rows * (root.cell-h + root.cell-gap); + + for item[index] in root.items: card := Rectangle { + property is-selected: index == root.selected-index; + + x: root.pad + mod(index, root.columns) * (root.cell-w + root.cell-gap); + y: floor(index / root.columns) * (root.cell-h + root.cell-gap); + width: root.cell-w; + height: root.cell-h; + border-radius: Theme.r-sm; + border-width: card.is-selected ? 2px : 1px; + border-color: card.is-selected ? Theme.accent + : touch.has-hover ? Theme.line-strong + : Theme.line; + background: card.is-selected ? Theme.accent-soft + : touch.has-hover ? Theme.field-hover + : Theme.field; + + animate background, border-color { + duration: 100ms; + easing: cubic-bezier(0.4, 0, 0.2, 1); + } + + property is-zoom-in: item.id == "zoom-in" || item.id == "pop"; + property is-zoom-out: item.id == "zoom-out"; + property is-pulse: item.id == "pulse" || item.id == "heartbeat" || item.id == "breathe"; + property is-slide-up: item.id == "slide-up" || item.id == "float-up" || item.id == "float"; + property is-slide-down: item.id == "slide-down" || item.id == "drop-in"; + property is-slide-left: item.id == "slide-left"; + property is-slide-right: item.id == "slide-right" || item.id == "drift"; + property is-spin: item.id == "spin" || item.id == "roll-in"; + property is-fade: item.id == "fade"; + property is-bounce: item.id == "bounce" || item.id == "bounce-in"; + property is-shake: item.id == "shake" || item.id == "jitter" || item.id == "wobble" || item.id == "sway"; + + // Scale animation + property anim-scale: !touch.has-hover ? 1.0 + : card.is-zoom-in ? 1.4 + : card.is-zoom-out ? 0.6 + : card.is-pulse ? 1.25 + : 1.0; + animate anim-scale { duration: card.is-pulse ? 450ms : 600ms; easing: ease-in-out; iteration-count: -1; } + + // Y-offset animation + property anim-y: !touch.has-hover ? 0px + : card.is-slide-up ? -6px + : card.is-slide-down ? 6px + : card.is-bounce ? -6px + : 0px; + animate anim-y { duration: 500ms; easing: ease-in-out; iteration-count: -1; } + + // X-offset animation + property anim-x: !touch.has-hover ? 0px + : card.is-slide-left ? -6px + : card.is-slide-right ? 6px + : card.is-shake ? 4px + : 0px; + animate anim-x { duration: card.is-shake ? 150ms : 500ms; easing: ease-in-out; iteration-count: -1; } + + // Rotation animation + property anim-rot: !touch.has-hover ? 0deg + : card.is-spin ? 360deg + : card.is-shake ? 12deg + : 0deg; + animate anim-rot { duration: card.is-spin ? 800ms : 200ms; easing: ease-in-out; iteration-count: -1; } + + // Opacity animation + property anim-opacity: !touch.has-hover ? 1.0 + : card.is-fade ? 0.2 + : 1.0; + animate anim-opacity { duration: 600ms; easing: ease-in-out; iteration-count: -1; } + + VerticalLayout { + alignment: center; + spacing: 4px; + padding-left: 4px; + padding-right: 4px; + + HorizontalLayout { + alignment: center; + preview-box := Rectangle { + width: 24px; + height: 24px; + transform-rotation: card.anim-rot; + + Icon { + x: (parent.width - self.width) / 2 + card.anim-x; + y: (parent.height - self.height) / 2 + card.anim-y; + size: 18px * card.anim-scale; + opacity: card.anim-opacity; + glyph: item.glyph; + tint: card.is-selected ? Theme.accent : touch.has-hover ? Theme.fg : Theme.fg-muted; + animate tint { + duration: 100ms; + easing: cubic-bezier(0.4, 0, 0.2, 1); + } + } + } + } + + Text { + text: item.name; + color: card.is-selected ? Theme.accent : Theme.fg; + font-size: Theme.fs-xs; + horizontal-alignment: center; + overflow: elide; + animate color { + duration: 100ms; + easing: cubic-bezier(0.4, 0, 0.2, 1); + } + } + } + + touch := TouchArea { + mouse-cursor: pointer; + clicked => { + root.picked(index); + } + } + } +} + /// Animation: how the clip comes in, goes out, and moves in between. Each /// slot is a named shape from Rust's list and, for the head and tail, the /// seconds it takes. The shapes are keys on the clip's own placement, so @@ -1566,42 +1772,77 @@ export component AnimationPanel inherits VerticalLayout { callback set(field: ClipField, value: float); callback commit(); - property <[string]> in-shapes: [ - I18n.t("None"), - I18n.t("Fade"), - I18n.t("Zoom In"), - I18n.t("Zoom Out"), - I18n.t("Slide Up"), - I18n.t("Slide Down"), - I18n.t("Slide Left"), - I18n.t("Slide Right"), - I18n.t("Spin") + property active-tab: 0; + property <[string]> categories: [ + I18n.t("In"), + I18n.t("Out"), + I18n.t("Combo"), + I18n.t("Loop") + ]; + + property <[AnimCardData]> in-items: [ + { id: "none", name: I18n.t("None"), glyph: Glyph.close }, + { id: "fade", name: I18n.t("Fade"), glyph: Glyph.sparkle }, + { id: "zoom-in", name: I18n.t("Zoom In"), glyph: Glyph.fit }, + { id: "zoom-out", name: I18n.t("Zoom Out"), glyph: Glyph.minus }, + { id: "slide-up", name: I18n.t("Slide Up"), glyph: Glyph.chevron-up }, + { id: "slide-down", name: I18n.t("Slide Down"), glyph: Glyph.chevron-down }, + { id: "slide-left", name: I18n.t("Slide Left"), glyph: Glyph.chevron-left }, + { id: "slide-right", name: I18n.t("Slide Right"), glyph: Glyph.chevron-right }, + { id: "spin", name: I18n.t("Spin"), glyph: Glyph.reset }, + { id: "bounce-in", name: I18n.t("Bounce In"), glyph: Glyph.keyframe }, + { id: "pop", name: I18n.t("Pop"), glyph: Glyph.sparkle }, + { id: "roll-in", name: I18n.t("Roll In"), glyph: Glyph.reset }, + { id: "drop-in", name: I18n.t("Drop In"), glyph: Glyph.chevron-down }, + { id: "float-up", name: I18n.t("Float Up"), glyph: Glyph.chevron-up } ]; - property <[string]> out-shapes: [ - I18n.t("None"), - I18n.t("Fade"), - I18n.t("Zoom In"), - I18n.t("Zoom Out"), - I18n.t("Slide Up"), - I18n.t("Slide Down"), - I18n.t("Slide Left"), - I18n.t("Slide Right"), - I18n.t("Spin") + property <[AnimCardData]> out-items: [ + { id: "none", name: I18n.t("None"), glyph: Glyph.close }, + { id: "fade", name: I18n.t("Fade"), glyph: Glyph.sparkle }, + { id: "zoom-in", name: I18n.t("Zoom In"), glyph: Glyph.fit }, + { id: "zoom-out", name: I18n.t("Zoom Out"), glyph: Glyph.minus }, + { id: "slide-up", name: I18n.t("Slide Up"), glyph: Glyph.chevron-up }, + { id: "slide-down", name: I18n.t("Slide Down"), glyph: Glyph.chevron-down }, + { id: "slide-left", name: I18n.t("Slide Left"), glyph: Glyph.chevron-left }, + { id: "slide-right", name: I18n.t("Slide Right"), glyph: Glyph.chevron-right }, + { id: "spin", name: I18n.t("Spin"), glyph: Glyph.reset }, + { id: "bounce-in", name: I18n.t("Bounce In"), glyph: Glyph.keyframe }, + { id: "pop", name: I18n.t("Pop"), glyph: Glyph.sparkle }, + { id: "roll-in", name: I18n.t("Roll In"), glyph: Glyph.reset }, + { id: "drop-in", name: I18n.t("Drop In"), glyph: Glyph.chevron-down }, + { id: "float-up", name: I18n.t("Float Up"), glyph: Glyph.chevron-up } ]; - property <[string]> combo-shapes: [ - I18n.t("None"), - I18n.t("Pulse"), - I18n.t("Shake"), - I18n.t("Spin"), - I18n.t("Bounce"), - I18n.t("Drift") + property <[AnimCardData]> combo-items: [ + { id: "none", name: I18n.t("None"), glyph: Glyph.close }, + { id: "pulse", name: I18n.t("Pulse"), glyph: Glyph.waveform }, + { id: "shake", name: I18n.t("Shake"), glyph: Glyph.split }, + { id: "spin", name: I18n.t("Spin"), glyph: Glyph.reset }, + { id: "bounce", name: I18n.t("Bounce"), glyph: Glyph.chevron-up }, + { id: "drift", name: I18n.t("Drift"), glyph: Glyph.chevron-right }, + { id: "sway", name: I18n.t("Sway"), glyph: Glyph.flip-h }, + { id: "wave", name: I18n.t("Wave"), glyph: Glyph.waveform }, + { id: "heartbeat", name: I18n.t("Heartbeat"), glyph: Glyph.waveform }, + { id: "breathe", name: I18n.t("Breathe"), glyph: Glyph.fit }, + { id: "wobble", name: I18n.t("Wobble"), glyph: Glyph.flip-v } + ]; + property <[AnimCardData]> loop-items: [ + { id: "none", name: I18n.t("None"), glyph: Glyph.close }, + { id: "spin", name: I18n.t("Spin"), glyph: Glyph.reset }, + { id: "pulse", name: I18n.t("Pulse"), glyph: Glyph.waveform }, + { id: "breathe", name: I18n.t("Breathe"), glyph: Glyph.fit }, + { id: "wobble", name: I18n.t("Wobble"), glyph: Glyph.flip-v }, + { id: "bounce", name: I18n.t("Bounce"), glyph: Glyph.chevron-up }, + { id: "jitter", name: I18n.t("Jitter"), glyph: Glyph.split }, + { id: "float", name: I18n.t("Float"), glyph: Glyph.chevron-up } ]; property in-seconds; property out-seconds; + property loop-seconds; function seed() { root.in-seconds = root.selected.anim-in-duration; root.out-seconds = root.selected.anim-out-duration; + root.loop-seconds = root.selected.anim-loop-duration; } init => { root.seed(); } changed selected => { root.seed(); } @@ -1613,24 +1854,58 @@ export component AnimationPanel inherits VerticalLayout { Section { first: true; SectionHeader { - title: I18n.t("In"); + title: I18n.t("Animation"); HelpButton { - text: I18n.t("In and Out play over the clip's first and last seconds; Combo runs its whole length. Each moves the clip from where you placed it, so position it first."); + text: I18n.t("In and Out play over the clip's first and last seconds; Combo runs its whole length; Loop repeats continuously. Each moves the clip from where you placed it, so position it first."); } } SectionBody { Row { - label: I18n.t("Shape"); - Select { - options: root.in-shapes; - current: root.selected.anim-in + 1; - changed(index) => { root.set(ClipField.anim-in, index - 1); root.commit(); } + SegmentedControl { + options: root.categories; + current <=> root.active-tab; + } + } + + if root.active-tab == 0: AnimationCardGrid { + items: root.in-items; + selected-index: root.selected.anim-in + 1; + picked(index) => { + root.set(ClipField.anim-in, index - 1); + root.commit(); + } + } + + if root.active-tab == 1: AnimationCardGrid { + items: root.out-items; + selected-index: root.selected.anim-out + 1; + picked(index) => { + root.set(ClipField.anim-out, index - 1); + root.commit(); + } + } + + if root.active-tab == 2: AnimationCardGrid { + items: root.combo-items; + selected-index: root.selected.anim-combo + 1; + picked(index) => { + root.set(ClipField.anim-combo, index - 1); + root.commit(); + } + } + + if root.active-tab == 3: AnimationCardGrid { + items: root.loop-items; + selected-index: root.selected.anim-loop + 1; + picked(index) => { + root.set(ClipField.anim-loop, index - 1); + root.commit(); } } - if root.selected.anim-in >= 0: Param { - label: I18n.t("Length"); + if root.active-tab == 0 && root.selected.anim-in >= 0: Param { + label: I18n.t("Duration"); value <=> root.in-seconds; minimum: 0.1; maximum: root.limit; @@ -1640,26 +1915,9 @@ export component AnimationPanel inherits VerticalLayout { unit: "s"; changed(v) => { root.set(ClipField.anim-in-duration, v); root.commit(); } } - } - } - - Section { - SectionHeader { - title: I18n.t("Out"); - } - - SectionBody { - Row { - label: I18n.t("Shape"); - Select { - options: root.out-shapes; - current: root.selected.anim-out + 1; - changed(index) => { root.set(ClipField.anim-out, index - 1); root.commit(); } - } - } - if root.selected.anim-out >= 0: Param { - label: I18n.t("Length"); + if root.active-tab == 1 && root.selected.anim-out >= 0: Param { + label: I18n.t("Duration"); value <=> root.out-seconds; minimum: 0.1; maximum: root.limit; @@ -1669,22 +1927,17 @@ export component AnimationPanel inherits VerticalLayout { unit: "s"; changed(v) => { root.set(ClipField.anim-out-duration, v); root.commit(); } } - } - } - - Section { - SectionHeader { - title: I18n.t("Combo"); - } - SectionBody { - Row { - label: I18n.t("Shape"); - Select { - options: root.combo-shapes; - current: root.selected.anim-combo + 1; - changed(index) => { root.set(ClipField.anim-combo, index - 1); root.commit(); } - } + if root.active-tab == 3 && root.selected.anim-loop >= 0: Param { + label: I18n.t("Cycle"); + value <=> root.loop-seconds; + minimum: 0.2; + maximum: 10.0; + step: 0.1; + default-value: 1.0; + fmt: ParamFormat.seconds2; + unit: "s"; + changed(v) => { root.set(ClipField.anim-loop-duration, v); root.commit(); } } } } diff --git a/src/crates/concat/ui/timeline/lanes.slint b/src/crates/concat/ui/timeline/lanes.slint index 02cbf87b..990832f2 100644 --- a/src/crates/concat/ui/timeline/lanes.slint +++ b/src/crates/concat/ui/timeline/lanes.slint @@ -171,10 +171,36 @@ component Clip inherits Rectangle { /// whole bar. property fade-h: root.item.kind == ClipKind.audio ? root.body-h : root.body-h * 0.45; - /// -1 body, 0 leading edge, 1 trailing edge - property edge-at: touch.mouse-x < root.edge-grab ? 0 + /// -1 body, 0 leading edge, 1 trailing edge, 2 transition duration handle + property has-transition: root.item.transition-duration > 0; + property transition-w: Math.max(18px, root.item.transition-duration * root.px-per-second); + property half-w: root.transition-w / 2; + property trans-btn-hover: false; + property trans-btn-drag: false; + property left-handle-hover: false; + property left-handle-drag: false; + property right-handle-hover: false; + property right-handle-drag: false; + + property show-tooltip: root.has-transition + && (root.trans-btn-hover || root.trans-btn-drag + || root.right-handle-hover || root.right-handle-drag + || root.left-handle-hover || root.left-handle-drag); + property trans-active: root.trans-btn-hover || root.trans-btn-drag + || root.right-handle-hover || root.right-handle-drag + || root.left-handle-hover || root.left-handle-drag; + + property edge-at: (root.has-transition && touch.mouse-x < root.edge-grab) ? -1 + : touch.mouse-x < root.edge-grab ? 0 : touch.mouse-x > root.width - root.edge-grab ? 1 : -1; + + pure function format-seconds(s: float) -> string { + let rounded = Math.round(s * 100); + let sec = Math.floor(rounded / 100); + let hundredths = Math.mod(rounded, 100); + return "\{sec}.\{hundredths < 10 ? "0" : ""}\{hundredths}s"; + } /// Its lane is locked. Rust refuses the gestures either way; this is so /// the clip says why before you try, rather than feeling broken. in property locked; @@ -350,33 +376,7 @@ component Clip inherits Rectangle { ramp-h: root.fade-h; } - // A transition on the cut into this clip: the wedge pair every editor - // uses for "these two dissolve". - if root.item.transition-in && root.width > 16px: Rectangle { - x: 1px; - y: (parent.height - self.height) / 2; - width: Math.min(9px, parent.height / 2); - height: self.width * 2; - Path { - width: parent.width; - height: parent.height; - viewbox-width: 1; - viewbox-height: 1; - fit: fill; - fill: Theme.transition-in; - commands: "M 0 0 L 1 0.5 L 0 1 Z"; - } - Path { - width: parent.width; - height: parent.height; - viewbox-width: 1; - viewbox-height: 1; - fit: fill; - fill: Theme.transition-out; - commands: "M 1 0 L 0 0.5 L 1 1 Z"; - } - } } // ── header ─────────────────────────────────────────────────────────── @@ -533,6 +533,326 @@ component Clip inherits Rectangle { property origin-x; property origin-y; } + + // ── CapCut-style transition overlay & cut seam controls ─────────────── + // + // Centered symmetrically across the cut seam (x = 0): + // spans from x: -root.half-w to x: root.half-w. + // Rendered on top of `touch` so the seam button and slider handles receive + // direct, unhindered pointer events for 1:1 real-time drag adjustments. + if root.has-transition && root.width > 10px: Rectangle { + x: -root.half-w; + y: 0; + width: root.transition-w; + height: parent.height; + + // Translucent duration wash across the seam + Rectangle { + x: 0; + y: 0; + width: parent.width; + height: parent.height; + background: (root.trans-active || root.item.selected) + ? Theme.accent.with-alpha(0.18) + : Theme.transition-out.with-alpha(0.15); + } + + // Top and bottom accent guide borders + Rectangle { + x: 0; + y: 0; + width: parent.width; + height: 1px; + background: (root.trans-active || root.item.selected) + ? Theme.accent + : Theme.transition-in.with-alpha(0.4); + } + Rectangle { + x: 0; + y: parent.height - 1px; + width: parent.width; + height: 1px; + background: (root.trans-active || root.item.selected) + ? Theme.accent + : Theme.transition-in.with-alpha(0.4); + } + + // Center cut seam line + Rectangle { + x: root.half-w; + y: 0; + width: 1px; + height: parent.height; + background: Theme.line-strong.with-alpha(0.5); + } + } + + // ── Left draggable slider handle (at x: -root.half-w) ───────────────── + if root.has-transition && root.width > 10px: Rectangle { + x: -root.half-w - 5.5px; + y: 0; + width: 14px; + height: parent.height; + + // 3px wide rounded vertical handle bar + Rectangle { + x: 5.5px; + y: 3px; + width: 3px; + height: parent.height - 6px; + border-radius: 1.5px; + background: (root.left-handle-hover || root.left-handle-drag) + ? Theme.accent + : Theme.transition-in; + } + + // Tactile grip indicator pill + Rectangle { + x: 4.5px; + y: (parent.height - 12px) / 2; + width: 5px; + height: 12px; + border-radius: 2px; + background: (root.left-handle-hover || root.left-handle-drag) + ? Theme.accent + : Theme.transition-in; + + // Grip slits + Rectangle { + x: 1px; + y: 3px; + width: 3px; + height: 1px; + background: #00000059; + } + Rectangle { + x: 1px; + y: 7px; + width: 3px; + height: 1px; + background: #00000059; + } + } + + left-touch := TouchArea { + enabled: !root.locked; + mouse-cursor: MouseCursor.ew-resize; + property origin-x; + property origin-y; + property is-dragging: false; + + changed has-hover => { + root.left-handle-hover = self.has-hover; + } + + pointer-event(event) => { + if (event.kind == PointerEventKind.down && event.button == PointerEventButton.left) { + self.origin-x = self.absolute-position.x + self.mouse-x; + self.origin-y = self.absolute-position.y + self.mouse-y; + self.is-dragging = true; + root.left-handle-drag = true; + root.pressed(event.modifiers.shift || event.modifiers.control || event.modifiers.meta, 2); + } else if (event.kind == PointerEventKind.up || event.kind == PointerEventKind.cancel) { + self.is-dragging = false; + root.left-handle-drag = false; + root.released(); + } + } + + moved => { + if (self.is-dragging && root.tool == TimelineTool.select) { + let delta-x = self.absolute-position.x + self.mouse-x - self.origin-x; + root.dragged( + (-2.0 * delta-x) / root.px-per-second, + self.absolute-position.y + self.mouse-y - self.origin-y); + } + } + } + } + + // ── Right draggable slider handle (at x: root.half-w - 3px) ─────────── + if root.has-transition && root.width > 10px: Rectangle { + x: root.half-w - 8.5px; + y: 0; + width: 14px; + height: parent.height; + + // 3px wide rounded vertical handle bar + Rectangle { + x: 5.5px; + y: 3px; + width: 3px; + height: parent.height - 6px; + border-radius: 1.5px; + background: (root.right-handle-hover || root.right-handle-drag) + ? Theme.accent + : Theme.transition-in; + } + + // Tactile grip indicator pill + Rectangle { + x: 4.5px; + y: (parent.height - 12px) / 2; + width: 5px; + height: 12px; + border-radius: 2px; + background: (root.right-handle-hover || root.right-handle-drag) + ? Theme.accent + : Theme.transition-in; + + // Grip slits + Rectangle { + x: 1px; + y: 3px; + width: 3px; + height: 1px; + background: #00000059; + } + Rectangle { + x: 1px; + y: 7px; + width: 3px; + height: 1px; + background: #00000059; + } + } + + right-touch := TouchArea { + enabled: !root.locked; + mouse-cursor: MouseCursor.ew-resize; + property origin-x; + property origin-y; + property is-dragging: false; + + changed has-hover => { + root.right-handle-hover = self.has-hover; + } + + pointer-event(event) => { + if (event.kind == PointerEventKind.down && event.button == PointerEventButton.left) { + self.origin-x = self.absolute-position.x + self.mouse-x; + self.origin-y = self.absolute-position.y + self.mouse-y; + self.is-dragging = true; + root.right-handle-drag = true; + root.pressed(event.modifiers.shift || event.modifiers.control || event.modifiers.meta, 2); + } else if (event.kind == PointerEventKind.up || event.kind == PointerEventKind.cancel) { + self.is-dragging = false; + root.right-handle-drag = false; + root.released(); + } + } + + moved => { + if (self.is-dragging && root.tool == TimelineTool.select) { + let delta-x = self.absolute-position.x + self.mouse-x - self.origin-x; + root.dragged( + (2.0 * delta-x) / root.px-per-second, + self.absolute-position.y + self.mouse-y - self.origin-y); + } + } + } + } + + // ── Floating duration tooltip pill ─────────────────────────────────── + if root.show-tooltip: Rectangle { + x: -self.width / 2; + y: -20px; + width: dur-text.preferred-width + 12px; + height: 18px; + border-radius: 4px; + background: #18181c; + border-width: 1px; + border-color: (root.right-handle-drag || root.left-handle-drag || root.trans-btn-drag) + ? Theme.accent + : Theme.line-strong; + + dur-text := Text { + x: 6px; + y: (parent.height - self.height) / 2; + text: root.format-seconds(root.item.transition-duration); + color: (root.right-handle-drag || root.left-handle-drag || root.trans-btn-drag) + ? Theme.accent + : Theme.fg; + font-size: 10px; + font-weight: 600; + } + } + + // ── Sleek transition seam button [ ⧗ ] ──────────────────────────────── + if root.has-transition: Rectangle { + x: -11px; + y: (parent.height - 22px) / 2; + width: 22px; + height: 22px; + + // Subtle accent glow when selected + if root.item.selected: Rectangle { + x: 0; + y: 0; + width: parent.width; + height: parent.height; + border-radius: 5px; + background: Theme.accent.with-alpha(0.25); + } + + trans-btn := Rectangle { + x: 2px; + y: 2px; + width: 18px; + height: 18px; + border-radius: 3px; + background: (root.trans-btn-hover || root.item.selected) + ? Theme.field-hover + : #1e1e24; + border-width: (root.trans-btn-hover || root.item.selected) ? 2px : 1px; + border-color: (root.trans-btn-hover || root.item.selected) + ? Theme.accent + : Theme.line-strong; + + Icon { + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + glyph: Glyph.transition; + size: 12px; + tint: (root.trans-btn-hover || root.item.selected) ? Theme.accent : Theme.fg; + } + + trans-btn-touch := TouchArea { + enabled: !root.locked; + mouse-cursor: self.is-dragging ? MouseCursor.ew-resize : MouseCursor.pointer; + property origin-x; + property origin-y; + property is-dragging: false; + + changed has-hover => { + root.trans-btn-hover = self.has-hover; + } + + pointer-event(event) => { + if (event.kind == PointerEventKind.down && event.button == PointerEventButton.left) { + self.origin-x = self.absolute-position.x + self.mouse-x; + self.origin-y = self.absolute-position.y + self.mouse-y; + self.is-dragging = true; + root.trans-btn-drag = true; + root.pressed(event.modifiers.shift || event.modifiers.control || event.modifiers.meta, 2); + } else if (event.kind == PointerEventKind.up || event.kind == PointerEventKind.cancel) { + self.is-dragging = false; + root.trans-btn-drag = false; + root.released(); + } + } + + moved => { + if (self.is-dragging && root.tool == TimelineTool.select) { + let delta-x = self.absolute-position.x + self.mouse-x - self.origin-x; + root.dragged( + (2.0 * delta-x) / root.px-per-second, + self.absolute-position.y + self.mouse-y - self.origin-y); + } + } + } + } + } } // ── the lanes ──────────────────────────────────────────────────────────────── diff --git a/src/crates/concat/ui/timeline/model.slint b/src/crates/concat/ui/timeline/model.slint index b4803f4b..4b30944c 100644 --- a/src/crates/concat/ui/timeline/model.slint +++ b/src/crates/concat/ui/timeline/model.slint @@ -97,8 +97,10 @@ export struct ClipData { selected: bool, /// at least one video effect is live: draws the `fx` chip in the header fx: bool, - /// there is a transition on the cut into this clip - transition-in: bool, + /// Seconds of transition on the cut into this clip; zero for none. The + /// marker at the clip's head is sized to this, in step with what the + /// cut actually rides. + transition-duration: float, /// seconds of audio ramp at each end; zero for none fade-in: float, fade-out: float, @@ -155,7 +157,7 @@ export enum TextAlignment { left, center, right } export enum ClipField { scale, offset-x, offset-y, rotation, stretch-x, stretch-y, opacity, volume, speed, preserve-pitch, speed-curve, reverse, fade-in, fade-out, - anim-in, anim-out, anim-combo, anim-in-duration, anim-out-duration, + anim-in, anim-out, anim-combo, anim-loop, anim-in-duration, anim-out-duration, anim-loop-duration, flip-h, flip-v, blend, crop-left, crop-top, crop-right, crop-bottom, font-size, font-weight, italic, text-opacity, align, stroke-width, shadow, line-height, tracking, text-width, text-height, @@ -258,6 +260,11 @@ export struct SelectedClipData { /// two ramps would overlap. duration: float, + // the transition on the cut into this clip; empty id means none + transition-id: string, + transition-name: string, + transition-duration: float, + // transform — video and stills scale: float, offset-x: float, @@ -287,8 +294,10 @@ export struct SelectedClipData { anim-in: int, anim-out: int, anim-combo: int, + anim-loop: int, anim-in-duration: float, anim-out-duration: float, + anim-loop-duration: float, // the picture's edges and colour flip-h: bool, diff --git a/src/crates/concat/ui/workspace/config-pane.slint b/src/crates/concat/ui/workspace/config-pane.slint index e853214d..a2b9bf93 100644 --- a/src/crates/concat/ui/workspace/config-pane.slint +++ b/src/crates/concat/ui/workspace/config-pane.slint @@ -178,6 +178,8 @@ export component ConfigPane inherits Rectangle { callback cutout-painting(on: bool); callback cutout-subject(index: int); callback cutout-clear(); + callback transition-remove(); + callback transition-duration-set(seconds: float); // The tab strip follows the selection, and stays short: three tabs at @@ -186,8 +188,11 @@ export component ConfigPane inherits Rectangle { // picture - its placement, its speed, its animation, its colour; Audio // the level, the fades and the voice and room chain; Effects the two // shelves and the picture's chain. A still loses Audio, a sound clip - // keeps Audio alone, and a title gets Text alone — offering the others - // would be offering panels that can only say "nothing here". + // keeps Audio alone, and a title gets Text plus Effects — the export + // pipeline already runs a title's effect chain (it is rasterised to a + // picture before anything else touches it), so the tab has real work + // to do; offering Video or Audio on a title would still be offering + // panels that can only say "nothing here". property is-text: root.selected.kind == ClipKind.text; property is-image: root.selected.kind == ClipKind.image; property is-audio: root.selected.kind == ClipKind.audio; @@ -195,14 +200,14 @@ export component ConfigPane inherits Rectangle { /// it has no transform, no gain and no rate, and every other tab could /// only say "nothing here". property is-layer: root.selected.kind == ClipKind.filter; - property <[string]> tabs: root.is-text ? ["Text"] + property <[string]> tabs: root.is-text ? ["Text", "Effects"] : root.is-layer ? ["Filter"] : root.is-image ? ["Video", "Effects"] : root.is-audio ? ["Audio"] : ["Video", "Audio", "Effects"]; /// The same tabs in the interface's language. `tabs` stays English, /// because the panels are picked by comparing against it. - property <[string]> tabs-shown: root.is-text ? [I18n.t("Text")] + property <[string]> tabs-shown: root.is-text ? [I18n.t("Text"), I18n.t("Effects")] : root.is-layer ? [I18n.t("Filter")] : root.is-image ? [I18n.t("Video"), I18n.t("Effects")] : root.is-audio ? [I18n.t("Audio")] @@ -529,6 +534,8 @@ export component ConfigPane inherits Rectangle { cutout-painting(on) => { root.cutout-painting(on); } cutout-subject(index) => { root.cutout-subject(index); } cutout-clear => { root.cutout-clear(); } + transition-remove => { root.transition-remove(); } + transition-duration-set(seconds) => { root.transition-duration-set(seconds); } } if root.showing == "Animation": AnimationPanel { diff --git a/src/crates/concat/ui/workspace/media-pane.slint b/src/crates/concat/ui/workspace/media-pane.slint index 0235aa28..6ef3e08b 100644 --- a/src/crates/concat/ui/workspace/media-pane.slint +++ b/src/crates/concat/ui/workspace/media-pane.slint @@ -763,6 +763,7 @@ struct CatalogueItemData { // a plus on hover when it works, a "Soon" badge when it does not. component CatalogueCard inherits Rectangle { in property item; + in property glyph: root.item.glyph; /// A card that shows before it adds: a click is `auditioned`, and only /// a double-click or the plus is `applied`. Otherwise a click applies. in property auditions; @@ -783,6 +784,12 @@ component CatalogueCard inherits Rectangle { /// and a name in a box says it in a third of the room. in property compact; + property sweep: card.has-hover ? 1.0 : 0.0; + animate sweep { duration: 1.2s; easing: ease-in-out; iteration-count: -1; } + + property shimmer: card.has-hover ? 1.0 : 0.0; + animate shimmer { duration: 1.5s; easing: ease-in-out; iteration-count: -1; } + opacity: drag.dragging ? 0.45 : 1.0; drag := DragArea { @@ -851,6 +858,37 @@ component CatalogueCard inherits Rectangle { tint: Theme.fg-dim; } + // Transitions: animated sweep line / wipe overlay across card image + if (root.glyph == Glyph.transition || root.item.glyph == Glyph.transition) && card.has-hover: Rectangle { + x: 0; + y: 0; + width: parent.width * root.sweep; + height: parent.height; + background: Theme.accent-soft; + + Rectangle { + x: parent.width - 2px; + y: 0; + width: 2px; + height: parent.height; + background: Theme.accent; + } + } + + // Effects: subtle live shimmer effect on hover across thumbnail + if (root.glyph == Glyph.sparkle || root.item.glyph == Glyph.sparkle) && card.has-hover: Rectangle { + x: -parent.width + (2 * parent.width * root.shimmer); + y: 0; + width: parent.width; + height: parent.height; + background: @linear-gradient(90deg, + transparent 0%, + transparent 25%, + Theme.accent-soft 50%, + transparent 75%, + transparent 100%); + } + if !root.item.enabled: Rectangle { x: parent.width - self.width - 4px; y: parent.height - self.height - 4px; @@ -912,48 +950,6 @@ component CatalogueCard inherits Rectangle { } } -// The same auto-fill arithmetic as MediaGrid, over catalogue entries. -component CatalogueGrid inherits Rectangle { - in property <[CatalogueItemData]> items; - /// Names the page for the timeline: "filter" makes every card here a drag - /// source that leaves a layer clip, and empty leaves the page click-only. - /// The label rides along in the payload because it is written here, in the - /// catalogue, and nothing on the Rust side has a copy of it. - in property payload-kind; - - /// The label rides along with the id: a page that places something has to - /// say what to call it, and the catalogue is the only place that knows. - callback applied(id: string, label: string); - - property gap: 8px; - property min-card: 88px; - /// A page of artless entries is a page of compact cards; see - /// CatalogueCard. Decided once for the grid, so the rows line up. - property compact: root.items.length > 0 && root.items[0].art.width == 0; - - property cols: Math.max(1, - Math.floor((root.width + root.gap) / (root.min-card + root.gap))); - property card-w: (root.width - (root.cols - 1) * root.gap) / root.cols; - property card-h: root.compact ? root.card-w / 2 - : root.card-w * 9 / 16 + 4px + 14px; - property rows: Math.ceil(root.items.length / root.cols); - - height: root.rows > 0 ? root.rows * (root.card-h + root.gap) - root.gap : 0px; - - for entry[index] in root.items: CatalogueCard { - x: Math.mod(index, root.cols) * (root.card-w + root.gap); - y: Math.floor(index / root.cols) * (root.card-h + root.gap); - width: root.card-w; - height: root.card-h; - item: entry; - compact: root.compact; - payload: root.payload-kind == "" || !entry.enabled ? "" - : "\{root.payload-kind}:\{entry.id}:\{entry.label}"; - - applied => { root.applied(entry.id, entry.label); } - } -} - // The text page: the one title style that exists, as a card. Clicking adds a // title at the playhead. Sized like one grid cell so the card matches its // neighbours on the other pages. @@ -1129,14 +1125,15 @@ component PresetCard inherits Rectangle { // A shelf of the effect catalogue, as Rust narrows it - searched, shelved // and starred by the same `Library` view the inspector reads - with the -// same auto-fill arithmetic as CatalogueGrid. The artwork is looked up by +// same auto-fill arithmetic as MediaGrid. The artwork is looked up by // id through `art`, since the pictures live here in the pane and the // entries come from the manifests. component ShelfGrid inherits Rectangle { in property <[CatalogueEntryData]> entries; /// The mark for an entry with no artwork; see CatalogueItemData. in property glyph: Glyph.sparkle; - /// See CatalogueGrid. + /// Names the page for the timeline: "filter" makes every card here a drag + /// source that leaves a layer clip, and empty leaves the page click-only. in property payload-kind; pure callback art(id: string) -> image; @@ -1202,6 +1199,97 @@ component ShelfSearch inherits HorizontalLayout { } } +component CategoryPill inherits Rectangle { + in property text; + in property selected; + callback clicked(); + + height: 24px; + border-radius: 12px; + background: root.selected ? Theme.accent + : touch.has-hover ? Theme.field-hover + : Theme.field; + + animate background { duration: 120ms; easing: cubic-bezier(0.4, 0, 0.2, 1); } + + HorizontalLayout { + padding-left: 10px; + padding-right: 10px; + alignment: center; + + Text { + text: root.text; + color: root.selected ? Theme.on-accent + : touch.has-hover ? Theme.fg + : Theme.fg-muted; + font-size: Theme.fs-xs; + font-weight: root.selected ? Theme.weight-title : 400; + vertical-alignment: center; + } + } + + touch := TouchArea { + mouse-cursor: pointer; + clicked => { root.clicked(); } + } +} + +component CategoryPillBar inherits Rectangle { + in property <[string]> categories: [ + "All", + "Featured", + "Retro & Film", + "Optical & Lens", + "Distortion & Glitch", + "Party & Club", + "Light & Shadow", + ]; + in property current: "All"; + callback picked(category: string); + + height: 30px; + clip: true; + + property offset: 0px; + property content-width: row.preferred-width; + property max-scroll: Math.max(0px, root.content-width - root.width); + property press-x: 0px; + property press-offset: 0px; + + touch := TouchArea { + pointer-event(event) => { + if (event.kind == PointerEventKind.down) { + root.press-x = touch.mouse-x; + root.press-offset = root.offset; + } + } + moved => { + if (touch.pressed) { + root.offset = Math.max(-root.max-scroll, Math.min(0px, root.press-offset + touch.mouse-x - root.press-x)); + } + } + scroll-event(event) => { + root.offset = Math.max(-root.max-scroll, Math.min(0px, root.offset + event.delta-x + event.delta-y)); + return EventResult.accept; + } + + row := HorizontalLayout { + x: Math.max(-root.max-scroll, Math.min(0px, root.offset)); + y: 3px; + height: 24px; + spacing: 6px; + + for cat in root.categories: CategoryPill { + text: cat; + selected: root.current == cat || (root.current == "" && cat == "All" && Library.view(1).group < 0); + clicked => { + root.picked(cat); + } + } + } + } +} + // The Presets shelf: the same auto-fill arithmetic as the other grids. component PresetGrid inherits Rectangle { in property <[TextPresetData]> items; @@ -1292,13 +1380,15 @@ export component MediaPane inherits Rectangle { /// The looks the Text page's Presets shelf offers. in property <[TextPresetData]> presets; /// The effect catalogue's shelves, as Rust narrows them by the - /// `Library` view: what the Effects and Filters pages show. + /// `Library` view: what the Effects, Filters and Transitions pages show. in property <[CatalogueEntryData]> catalogue-effects; in property <[CatalogueEntryData]> catalogue-audio; in property <[CatalogueEntryData]> catalogue-filters; + in property <[CatalogueEntryData]> catalogue-transitions; in property <[string]> effect-groups; in property <[string]> audio-groups; in property <[string]> filter-groups; + in property <[string]> transition-groups; /// Text page: add a title at the playhead, in the look `preset` names. callback add-text(preset: string); /// Catalogue pages: apply to the selected clip; Rust validates & explains. @@ -1337,8 +1427,6 @@ export component MediaPane inherits Rectangle { property effects-open: true; property filters-open: true; property templates-open: true; - /// 0 = Basic, 1 = Motion - property transition-category; /// The Effects page shows the sound's shelves rather than the /// picture's: whichever heading's row was picked last. property audio-shelf; @@ -1415,6 +1503,90 @@ export component MediaPane inherits Rectangle { if (id == "concat.solarize") { return @image-url("../assets/effect-previews/solarize.jpg"); } if (id == "concat.scanlines") { return @image-url("../assets/effect-previews/scanlines.jpg"); } if (id == "concat.hologram") { return @image-url("../assets/effect-previews/hologram.jpg"); } + // New effects + if (id == "concat.bloom-pulse") { return @image-url("../assets/effect-previews/bloom-pulse.jpg"); } + if (id == "concat.blue-screen") { return @image-url("../assets/effect-previews/blue-screen.jpg"); } + if (id == "concat.bokeh") { return @image-url("../assets/effect-previews/bokeh.jpg"); } + if (id == "concat.bulge-pinch") { return @image-url("../assets/effect-previews/bulge-pinch.jpg"); } + if (id == "concat.crosshatch") { return @image-url("../assets/effect-previews/crosshatch.jpg"); } + if (id == "concat.datamosh") { return @image-url("../assets/effect-previews/datamosh.jpg"); } + if (id == "concat.duotone") { return @image-url("../assets/effect-previews/duotone.jpg"); } + if (id == "concat.dust") { return @image-url("../assets/effect-previews/dust.jpg"); } + if (id == "concat.fog") { return @image-url("../assets/effect-previews/fog.jpg"); } + if (id == "concat.green-screen") { return @image-url("../assets/effect-previews/green-screen.jpg"); } + if (id == "concat.halftone") { return @image-url("../assets/effect-previews/halftone.jpg"); } + if (id == "concat.kaleidoscope") { return @image-url("../assets/effect-previews/kaleidoscope.jpg"); } + if (id == "concat.lens-flare") { return @image-url("../assets/effect-previews/lens-flare.jpg"); } + if (id == "concat.light-leak") { return @image-url("../assets/effect-previews/light-leak.jpg"); } + if (id == "concat.mask-circle") { return @image-url("../assets/effect-previews/mask-circle.jpg"); } + if (id == "concat.mask-linear") { return @image-url("../assets/effect-previews/mask-linear.jpg"); } + if (id == "concat.mask-rectangle") { return @image-url("../assets/effect-previews/mask-rectangle.jpg"); } + if (id == "concat.neon-edges") { return @image-url("../assets/effect-previews/neon-edges.jpg"); } + if (id == "concat.oil-paint") { return @image-url("../assets/effect-previews/oil-paint.jpg"); } + if (id == "concat.rgb-split") { return @image-url("../assets/effect-previews/rgb-split.jpg"); } + if (id == "concat.ripple") { return @image-url("../assets/effect-previews/ripple.jpg"); } + if (id == "concat.snow") { return @image-url("../assets/effect-previews/snow.jpg"); } + if (id == "concat.twirl") { return @image-url("../assets/effect-previews/twirl.jpg"); } + // New filters + if (id == "concat.adjust") { return @image-url("../assets/effect-previews/adjust.jpg"); } + if (id == "concat.autumn") { return @image-url("../assets/effect-previews/autumn.jpg"); } + if (id == "concat.bleach-bypass") { return @image-url("../assets/effect-previews/bleach-bypass.jpg"); } + if (id == "concat.classic-chrome") { return @image-url("../assets/effect-previews/classic-chrome.jpg"); } + if (id == "concat.cyberpunk") { return @image-url("../assets/effect-previews/cyberpunk.jpg"); } + if (id == "concat.delicious") { return @image-url("../assets/effect-previews/delicious.jpg"); } + if (id == "concat.dreamy") { return @image-url("../assets/effect-previews/dreamy.jpg"); } + if (id == "concat.high-contrast-mono") { return @image-url("../assets/effect-previews/high-contrast-mono.jpg"); } + if (id == "concat.landscape-pop") { return @image-url("../assets/effect-previews/landscape-pop.jpg"); } + if (id == "concat.natural") { return @image-url("../assets/effect-previews/natural.jpg"); } + if (id == "concat.noir") { return @image-url("../assets/effect-previews/noir.jpg"); } + if (id == "concat.retro-70s") { return @image-url("../assets/effect-previews/retro-70s.jpg"); } + if (id == "concat.rosy") { return @image-url("../assets/effect-previews/rosy.jpg"); } + if (id == "concat.sci-fi") { return @image-url("../assets/effect-previews/sci-fi.jpg"); } + if (id == "concat.silver") { return @image-url("../assets/effect-previews/silver.jpg"); } + if (id == "concat.super-8") { return @image-url("../assets/effect-previews/super-8.jpg"); } + if (id == "concat.tropical") { return @image-url("../assets/effect-previews/tropical.jpg"); } + if (id == "concat.vaporwave") { return @image-url("../assets/effect-previews/vaporwave.jpg"); } + if (id == "concat.winter-cool") { return @image-url("../assets/effect-previews/winter-cool.jpg"); } + // Transitions + if (id == "concat.blur-dissolve") { return @image-url("../assets/effect-previews/blur-dissolve.jpg"); } + if (id == "concat.clock-wipe") { return @image-url("../assets/effect-previews/clock-wipe.jpg"); } + if (id == "concat.cube") { return @image-url("../assets/effect-previews/cube.jpg"); } + if (id == "concat.dissolve") { return @image-url("../assets/effect-previews/dissolve.jpg"); } + if (id == "concat.film-burn") { return @image-url("../assets/effect-previews/film-burn.jpg"); } + if (id == "concat.glitch-transition") { return @image-url("../assets/effect-previews/glitch-transition.jpg"); } + if (id == "concat.iris") { return @image-url("../assets/effect-previews/iris.jpg"); } + if (id == "concat.light-leak-transition") { return @image-url("../assets/effect-previews/light-leak-transition.jpg"); } + if (id == "concat.linear-wipe") { return @image-url("../assets/effect-previews/linear-wipe.jpg"); } + if (id == "concat.push") { return @image-url("../assets/effect-previews/push.jpg"); } + if (id == "concat.rgb-split-wipe") { return @image-url("../assets/effect-previews/rgb-split-wipe.jpg"); } + if (id == "concat.shape-wipe") { return @image-url("../assets/effect-previews/shape-wipe.jpg"); } + if (id == "concat.slide") { return @image-url("../assets/effect-previews/slide.jpg"); } + if (id == "concat.spin") { return @image-url("../assets/effect-previews/spin.jpg"); } + if (id == "concat.swirl") { return @image-url("../assets/effect-previews/swirl.jpg"); } + if (id == "concat.zoom-transition") { return @image-url("../assets/effect-previews/zoom-transition.jpg"); } + // Modern Video Effects + if (id == "concat.camera-shake") { return @image-url("../assets/effect-previews/camera-shake.jpg"); } + if (id == "concat.rgb-glitch") { return @image-url("../assets/effect-previews/rgb-glitch.jpg"); } + if (id == "concat.sparkle") { return @image-url("../assets/effect-previews/sparkle.jpg"); } + if (id == "concat.tilt-shift") { return @image-url("../assets/effect-previews/tilt-shift.jpg"); } + if (id == "concat.strobe-flash") { return @image-url("../assets/effect-previews/strobe-flash.jpg"); } + if (id == "concat.crt-scanlines") { return @image-url("../assets/effect-previews/crt-scanlines.jpg"); } + if (id == "concat.film-reel") { return @image-url("../assets/effect-previews/film-reel.jpg"); } + if (id == "concat.camcorder-90s") { return @image-url("../assets/effect-previews/camcorder-90s.jpg"); } + if (id == "concat.halation") { return @image-url("../assets/effect-previews/halation.jpg"); } + if (id == "concat.wave-warp") { return @image-url("../assets/effect-previews/wave-warp.jpg"); } + if (id == "concat.vortex-swirl") { return @image-url("../assets/effect-previews/vortex-swirl.jpg"); } + if (id == "concat.prism-dispersion") { return @image-url("../assets/effect-previews/prism-dispersion.jpg"); } + if (id == "concat.mirror-tile") { return @image-url("../assets/effect-previews/mirror-tile.jpg"); } + if (id == "concat.bass-shockwave") { return @image-url("../assets/effect-previews/bass-shockwave.jpg"); } + if (id == "concat.laser-beams") { return @image-url("../assets/effect-previews/laser-beams.jpg"); } + if (id == "concat.neon-glow") { return @image-url("../assets/effect-previews/neon-glow.jpg"); } + if (id == "concat.color-cycle") { return @image-url("../assets/effect-previews/color-cycle.jpg"); } + // Modern Transitions + if (id == "concat.whip-pan") { return @image-url("../assets/effect-previews/whip-pan.jpg"); } + if (id == "concat.zoom-blur-transition") { return @image-url("../assets/effect-previews/zoom-blur-transition.jpg"); } + if (id == "concat.light-leak-flash") { return @image-url("../assets/effect-previews/light-leak-flash.jpg"); } + if (id == "concat.glitch-warp") { return @image-url("../assets/effect-previews/glitch-warp.jpg"); } return root.no-art; } /// The category column. In-out, because it is dragged. @@ -1436,21 +1608,9 @@ export component MediaPane inherits Rectangle { // The catalogues. The ids are written into project files, so they are // stable forever - pick them like you mean them. The effect artwork is a // rendered preview, each effect's real FFmpeg chain run over one source - // still. - property <[CatalogueItemData]> transitions-basic: [ - { id: "cross-fade", label: "Cross Fade", enabled: true, glyph: Glyph.transition }, - { id: "fade-black", label: "Fade to Black", enabled: true, glyph: Glyph.transition }, - { id: "fade-white", label: "Fade to White", enabled: true, glyph: Glyph.transition }, - ]; - // The engine resolves these in concat-export's `resolve_transitions`: - // the slides and the zoom as animation keys the monitor plays too, the - // wipes as a mask the export bakes and the monitor shows as a dissolve. - property <[CatalogueItemData]> transitions-motion: [ - { id: "wipe-left", label: "Wipe Left", enabled: true, glyph: Glyph.transition }, - { id: "wipe-right", label: "Wipe Right", enabled: true, glyph: Glyph.transition }, - { id: "push", label: "Push", enabled: true, glyph: Glyph.transition }, - { id: "zoom", label: "Zoom", enabled: true, glyph: Glyph.transition }, - ]; + // still. Transitions used to be two hand-written lists here; they are + // now packages like any other effect, browsed the same way through + // `catalogue-transitions`/`transition-groups` below. function narrow(to: MediaFilter) { root.filter = to; @@ -1675,17 +1835,14 @@ export component MediaPane inherits Rectangle { open: root.transitions-open; toggled => { root.transitions-open = !root.transitions-open; } } - if root.transitions-open: CategoryRow { - height: 24px; - label: I18n.t("Basic"); - current: root.transition-category == 0; - clicked => { root.transition-category = 0; } - } - if root.transitions-open: CategoryRow { - height: 24px; - label: I18n.t("Motion"); - current: root.transition-category == 1; - clicked => { root.transition-category = 1; } + if root.transitions-open: VerticalLayout { + spacing: 1px; + for label[index] in root.transition-groups: CategoryRow { + height: 24px; + label: label; + current: Library.view(3).group == index; + clicked => { Library.group-changed(3, index); } + } } } @@ -1922,14 +2079,19 @@ export component MediaPane inherits Rectangle { } } - if root.tab == 2: HorizontalLayout { + if root.tab == 2: VerticalLayout { padding: 8px; - CatalogueGrid { - horizontal-stretch: 1; - items: root.transition-category == 0 - ? root.transitions-basic - : root.transitions-motion; + ShelfSearch { + shelf: 3; + placeholder: I18n.t1("Search {0}s", I18n.t("transition")); + } + ShelfGrid { + entries: root.catalogue-transitions; + glyph: Glyph.transition; + art(id) => { return root.art-of(id); } + // Transitions apply directly on single-click — no audition overlay. + auditions: false; applied(id, label) => { root.apply-transition(id); } } } @@ -1943,10 +2105,20 @@ export component MediaPane inherits Rectangle { ? I18n.t1("Search {0}s", I18n.t("audio effect")) : I18n.t1("Search {0}s", I18n.t("effect")); } + if !root.audio-shelf: CategoryPillBar { + current: Library.view(root.effects-shelf).category; + picked(cat) => { + Library.category-changed(root.effects-shelf, cat); + } + } + if !root.audio-shelf: Rectangle { height: 6px; } ShelfGrid { entries: root.audio-shelf ? root.catalogue-audio : root.catalogue-effects; glyph: root.audio-shelf ? Glyph.waveform : Glyph.sparkle; art(id) => { return root.art-of(id); } + // Effects apply directly on single-click to the selected clip — + // auditions:false means click = apply, no monitor overlay confusion. + auditions: false; applied(id, label) => { if (root.audio-shelf) { root.apply-audio(id); diff --git a/src/crates/concat/ui/workspace/seat.slint b/src/crates/concat/ui/workspace/seat.slint index 8f15ad75..acf6cc41 100644 --- a/src/crates/concat/ui/workspace/seat.slint +++ b/src/crates/concat/ui/workspace/seat.slint @@ -120,9 +120,11 @@ export component Seat inherits Rectangle { catalogue-effects: Editor.catalogue-effects; catalogue-audio: Editor.catalogue-audio; catalogue-filters: Editor.catalogue-filters; + catalogue-transitions: Editor.catalogue-transitions; effect-groups: Editor.effect-groups; audio-groups: Editor.audio-groups; filter-groups: Editor.filter-groups; + transition-groups: Editor.transition-groups; add-text(preset) => { Editor.library-add-text(preset); } apply-effect(id) => { Editor.library-apply-effect(id); } apply-transition(id) => { Editor.library-apply-transition(id); } @@ -229,6 +231,8 @@ export component Seat inherits Rectangle { cutout-painting(on) => { Editor.cutout-painting(on); } cutout-subject(index) => { Editor.cutout-subject(index); } cutout-clear => { Editor.cutout-clear(); } + transition-remove => { Editor.transition-remove(); } + transition-duration-set(seconds) => { Editor.transition-duration-set(seconds); } project-name: Editor.project-name; project-folder: Editor.project-folder;