diff --git a/kaika/.gitignore b/kaika/.gitignore new file mode 100644 index 0000000..56250ba --- /dev/null +++ b/kaika/.gitignore @@ -0,0 +1,11 @@ +.venv/ +__pycache__/ +*.pyc +*.egg-info/ +runs/ +.pytest_cache/ +node_modules/ +webapp/dist/ +.DS_Store +*.tsbuildinfo +.kaika/ diff --git a/kaika/README.md b/kaika/README.md new file mode 100644 index 0000000..383e8eb --- /dev/null +++ b/kaika/README.md @@ -0,0 +1,122 @@ +# Kaika 開花 + +Turn a piece of music into a video clip. A fluid simulation is *danced* by audio +analysis, then metamorphosed into living forms by a video diffusion model — all +driven from a local web app launched with a single command. + +Full specification: [`../project_ideas/kaika.md`](../project_ideas/kaika.md). + +``` +SON ──▶ FLUIDE ──▶ FLORAISON + du son, un fluide ; du fluide, une fleur +``` + +## Quickstart + +```bash +uv venv && . .venv/bin/activate +uv pip install -e ".[dev]" + +pytest # the whole suite (no GPU needed) +kaika run path/to/track.wav --recipe eclosion --seconds 4 # render a 4s extract +kaika serve # launch the local app (http://localhost:8400) +kaika # bare command = serve + open browser +``` + +`uvx kaika` works once published: the compiled frontend and the recipes ship +embedded in the wheel, so there is no npm at runtime and no config file to edit. + +## The pipeline + +Five stages in a chain, each with files on disk, each independently testable. + +| Stage | Module | In → Out | +| --- | --- | --- | +| **E1** analyze | `kaika.core.analyze` | audio → `score.json` (frame-aligned partition) | +| **E2** simulate | `kaika.core.simulate` | score + recipe → `fluid/*.png`, `velocity/*.npy`, `fluid_stats.json` | +| **E3** control | `kaika.core.control` | fluid → `control/{depth,canny,flow}/` | +| **E4** diffuse | `kaika.core.diffuse` | fluid + control → `styled/*.png` | +| **E5** post | `kaika.core.post` | styled + audio → `kaika_final.mp4` | + +`kaika.core.pipeline.run_pipeline` orchestrates them into a reproducible +`runs//` directory (frozen recipe + score + every intermediate + manifest). + +### Design notes + +- **E2 is the movement skeleton**, not the final image: a deterministic NumPy + stable-fluids solver (toroidal, Jos-Stam style). Same seed → identical video. + (Taichi/GPU is a drop-in acceleration; NumPy keeps it runnable and testable + everywhere.) +- **The E3→E4 boundary is the most important interface** — "control frames in, + styled frames out". Everything model-specific lives behind `Diffuser`, so E4 + is replaceable when vid2vid models churn. +- **E4 has two backends.** `local` is a deterministic, GPU-free stylizer so the + whole pipeline produces a clip on any machine (it is *not* the figurative + metamorphosis — that needs the GPU). `comfyui` drives ComfyUI / Wan 2.2 on a + rented GPU: chunking with section-aligned seams, a prompt schedule from the + score, near-lossless **video** transfer (never thousands of PNGs), and a + versioned workflow template (`diffuse/workflows/`). Provisioning scaffold in + `diffuse/provision.py`. +- **Sync check** (E5) correlates the audio RMS envelope with the fluid's + kinetic energy — deterministically audio-driven — not styled-frame luminance. + +## The app + +`kaika serve` runs FastAPI + a single-worker job queue + SQLite + WebSocket +progress, and serves the React/Vite/TS frontend. Three screens: + +1. **Studio** — drop audio; analysis splits it into **editable segments**. Click + a segment to set *its* prompt and *its* fluid parameters (vorticity, kick/hat + emit, ambient stir). Then **Preview fluid** (no GPU) to iterate on the motion. +2. **Render** — the stages live with progress; watch the fluid preview, and when + the motion is right, **Generate** runs the diffusion to the final clip. +3. **Gallery** — every run, replayable, with its frozen recipe and sync info. + +### Projects & staged rendering + +A **Project** (`runs//project.json`) is the mutable working doc: the track's +segments, each with a prompt and partial fluid overrides. A single *continuous* +simulation reads these per-frame, so parameters vary by segment without breaking +the flow. The pipeline runs in two resumable stages: + +- **fluid** (`run_fluid`) — E1+E2+E3 + a previewable fluid MP4. Fast, no GPU. +- **diffuse** (`run_diffuse`) — E4+E5, resuming the cached fluid with the + project's per-segment prompts. + +Nothing the UI shows is hidden state: runs live on disk under `runs/`. + +## Developing the frontend + +The built frontend is committed under `src/kaika/webapp_dist/`. To change it: + +```bash +cd webapp +npm install +npm run dev # http://localhost:5173, proxies /api + /ws to :8400 (run `kaika serve` too) +npm run build # re-emits into ../src/kaika/webapp_dist +``` + +## Layout + +``` +kaika/ +├── recipes/ # YAML visual identities (eclosion, encre) +├── src/kaika/ +│ ├── core/ # E1–E5 library + pipeline (UI and CLI both call this) +│ │ ├── analyze.py simulate.py control.py post.py pipeline.py +│ │ ├── recipe.py score.py media.py +│ │ └── diffuse/ # E4: base, local, comfy, provision, workflows/ +│ ├── server/ # FastAPI app, job queue, SQLite +│ ├── webapp_dist/ # built frontend (embedded) +│ └── cli.py # `kaika` (serve) · `kaika run …` (scripting) +├── webapp/ # React/Vite/TS sources +├── tests/ # pytest, one module per stage + server + e2e +└── runs/ # one dir per render (gitignored) +``` + +## Sandbox honesty + +Everything in this repo runs and is tested with **no GPU** (`pytest` is green +end-to-end). The figurative flower metamorphosis requires the `comfyui` backend +on a rented NVIDIA GPU; that code path is structured, unit-tested offline, and +gated behind a reachable ComfyUI endpoint, but is not exercised here. diff --git a/kaika/pyproject.toml b/kaika/pyproject.toml new file mode 100644 index 0000000..a85280d --- /dev/null +++ b/kaika/pyproject.toml @@ -0,0 +1,43 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "kaika" +version = "0.1.0" +description = "Turn a piece of music into a video clip: audio-driven fluid simulation, metamorphosed by a video diffusion model, from a single local command." +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Florent Lejoly" }] +dependencies = [ + "numpy>=1.24", + "scipy>=1.10", + "librosa>=0.10", + "soundfile>=0.12", + "opencv-python-headless>=4.8", + "pillow>=10.0", + "imageio>=2.31", + "imageio-ffmpeg>=0.4", + "pyyaml>=6.0", + "fastapi>=0.110", + "uvicorn[standard]>=0.27", + "pydantic>=2.6", + "websockets>=12.0", + "python-multipart>=0.0.9", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0", "httpx>=0.27"] + +[project.scripts] +kaika = "kaika.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/kaika"] + +[tool.hatch.build.targets.wheel.force-include] +"recipes" = "kaika/recipes" + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" diff --git a/kaika/recipes/eclosion.yaml b/kaika/recipes/eclosion.yaml new file mode 100644 index 0000000..674d940 --- /dev/null +++ b/kaika/recipes/eclosion.yaml @@ -0,0 +1,33 @@ +name: eclosion +seed: 4217 + +fluid: + resolution: 256 + render_resolution: 512 + dissipation: 0.90 + lookahead_s: 8.0 + splats: + low: { radius: 0.12, force: 9000, placement: anchored, lifetime_s: 0.8, emit: 0.22, drift: 0.7 } + high: { radius: 0.03, force: 3500, placement: scatter, max_per_beat: 5, lifetime_s: 0.3, emit: 0.11, drift: 0.3 } + vorticity: { min: 8, max: 38, driver: rms } + +diffusion: + model: wan-2.2-vace + backend: local + strength: 0.5 + control: [depth, flow] + chunk_s: 5.0 + overlap_frames: 24 + +post: + fps: 24 + aspect: square + +prompts: + base: "macro photography, botanical, dark background, soft light" + intro: "closed flower buds emerging from black water, mist" + build: "buds swelling, petals straining, tension" + drop: "explosive bloom of peonies, petals suspended mid-air" + verse: "slow drifting petals, calm water" + outro: "petals dissolving back into dark water" + default: "botanical organic forms, abstract motion" diff --git a/kaika/recipes/encre.yaml b/kaika/recipes/encre.yaml new file mode 100644 index 0000000..aa32843 --- /dev/null +++ b/kaika/recipes/encre.yaml @@ -0,0 +1,34 @@ +name: encre +seed: 1107 + +fluid: + resolution: 256 + render_resolution: 512 + dissipation: 0.92 + lookahead_s: 6.0 + palette: ["#2a2a2a", "#4a4a4a", "#6c6c6c", "#9a9a9a", "#d9d9d9"] + splats: + low: { radius: 0.16, force: 7000, placement: anchored, lifetime_s: 1.4, emit: 0.16, drift: 0.7 } + high: { radius: 0.025, force: 2600, placement: scatter, max_per_beat: 7, lifetime_s: 0.25, emit: 0.10, drift: 0.3 } + vorticity: { min: 4, max: 24, driver: rms } + +diffusion: + model: wan-2.2-vace + backend: local + strength: 0.45 + control: [depth, flow] + chunk_s: 5.0 + overlap_frames: 24 + +post: + fps: 24 + aspect: square + +prompts: + base: "black ink diffusing in water, sumi-e, monochrome, high contrast" + intro: "a single drop of ink hitting still water" + build: "ink tendrils reaching, gathering" + drop: "violent bloom of black ink, fractal plumes" + verse: "slow ink drift, grey washes" + outro: "ink settling, clearing water" + default: "ink in water, abstract" diff --git a/kaika/src/kaika/__init__.py b/kaika/src/kaika/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/kaika/src/kaika/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/kaika/src/kaika/cli.py b/kaika/src/kaika/cli.py new file mode 100644 index 0000000..22df4b3 --- /dev/null +++ b/kaika/src/kaika/cli.py @@ -0,0 +1,74 @@ +"""Kaika command line. + +The terminal starts the app; it never creates. ``kaika`` launches the local +app and opens the browser. ``kaika run`` is the second-citizen scripting entry +point — it calls the very same library the UI does. +""" +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + + +def _cmd_run(args) -> int: + from .core.pipeline import run_pipeline + + def progress(stage, done, total): + bar = f"{done}/{total}" if total else "" + print(f"\r[{stage:9}] {bar} ", end="", flush=True) + + res = run_pipeline(args.audio, args.recipe, runs_root=args.out, + seconds=args.seconds, progress=progress) + print() + print(f"run {res.run_id} -> {res.final}") + print(f" frames={res.n_frames} backend={res.backend} " + f"sync lag={res.sync_lag}f corr={res.sync_corr}") + return 0 + + +def _cmd_serve(args) -> int: + url = f"http://{args.host}:{args.port}" + print(f"Starting Kaika… loading the analysis engine (first launch ~10s)\n" + f" → {url}", flush=True) + from .server.app import serve + serve(host=args.host, port=args.port, runs_root=args.out, + open_browser=not args.no_browser) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="kaika", + description="Turn music into a video clip.") + sub = p.add_subparsers(dest="cmd") + + pr = sub.add_parser("run", help="render a clip (scripting/CI)") + pr.add_argument("audio", help="path to an audio file") + pr.add_argument("--recipe", default="eclosion", help="recipe name or path") + pr.add_argument("--seconds", type=float, default=None, + help="render only the first N seconds (fast iteration)") + pr.add_argument("--out", default="runs", help="runs root directory") + pr.set_defaults(func=_cmd_run) + + ps = sub.add_parser("serve", help="launch the local app") + ps.add_argument("--host", default="127.0.0.1") + ps.add_argument("--port", type=int, default=8400) + ps.add_argument("--out", default="runs") + ps.add_argument("--no-browser", action="store_true") + ps.set_defaults(func=_cmd_serve) + + return p + + +def main(argv=None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + parser = build_parser() + args = parser.parse_args(argv) + if not getattr(args, "cmd", None): + # bare `kaika` launches the app + args = parser.parse_args(["serve"]) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/kaika/src/kaika/core/__init__.py b/kaika/src/kaika/core/__init__.py new file mode 100644 index 0000000..8b14556 --- /dev/null +++ b/kaika/src/kaika/core/__init__.py @@ -0,0 +1,17 @@ +"""Kaika core library (E1–E5 + orchestration). + +The UI and the CLI both call this package. Common entry points are re-exported +here so callers can ``from kaika.core import run_pipeline, Project, load_recipe``. +""" +from .recipe import Recipe, load_recipe, from_dict as recipe_from_dict +from .score import Score +from .project import Project, Segment +from .analyze import analyze +from .pipeline import (run_pipeline, run_fluid, run_diffuse, init_project_run, + load_run, list_runs, RunResult) + +__all__ = [ + "Recipe", "load_recipe", "recipe_from_dict", "Score", "Project", "Segment", + "analyze", "run_pipeline", "run_fluid", "run_diffuse", "init_project_run", + "load_run", "list_runs", "RunResult", +] diff --git a/kaika/src/kaika/core/analyze.py b/kaika/src/kaika/core/analyze.py new file mode 100644 index 0000000..46eb590 --- /dev/null +++ b/kaika/src/kaika/core/analyze.py @@ -0,0 +1,174 @@ +"""E1 — Audio analysis. audio file -> Score (score.json). + +Offline, full-track analysis with librosa. The hop length is locked to the +target video framerate so every video frame gets exactly one row of audio +data with no interpolation. Because ``sr / fps`` is not always an integer +(e.g. 44100 / 24 = 1837.5), the hop is rounded and the per-frame timestamps +are recomputed from real time, which bounds the drift to a fraction of a frame. +""" +from __future__ import annotations + +from pathlib import Path +from typing import List + +import numpy as np +import librosa + +from .score import Score, AudioInfo, Event, FrameData, Section + +N_FFT = 2048 +LOW_HZ = 150.0 +HIGH_HZ = 4000.0 + + +def _normalise(x: np.ndarray) -> np.ndarray: + """Scale to 0..1 by max, robust to all-zero input.""" + x = np.asarray(x, dtype=np.float64) + peak = float(np.max(x)) if x.size else 0.0 + return x / peak if peak > 1e-12 else np.zeros_like(x) + + +def _band_onsets(S_band: np.ndarray, sr: int, hop: int) -> List[Event]: + """Detect onsets within a single frequency band's magnitude spectrogram. + + The band is expected to be the *percussive* HPSS component: sustained tones + are already removed, so each detection corresponds to a real hit. Peak + picking is kept strict (delta/wait) — every onset spawns a visual source, + so over-triggering turns rhythm into noise. + """ + if S_band.shape[0] == 0: + return [] + env = librosa.onset.onset_strength(S=librosa.amplitude_to_db(S_band, ref=np.max), + sr=sr, hop_length=hop) + if env.max() <= 0: + return [] + frames = librosa.onset.onset_detect(onset_envelope=env, sr=sr, hop_length=hop, + backtrack=False, delta=0.10, wait=4) + if len(frames) == 0: + return [] + times = librosa.frames_to_time(frames, sr=sr, hop_length=hop) + mags = _normalise(env[frames]) + return [Event(t=float(t), mag=float(m)) for t, m in zip(times, mags)] + + +def _label_sections(boundaries_t: List[float], duration: float, + energies: List[float]) -> List[Section]: + """Heuristic labelling: ends are intro/outro, the rest build/drop by energy.""" + sections: List[Section] = [] + n = len(boundaries_t) - 1 + e_norm = _normalise(np.array(energies)) if energies else np.array([]) + for i in range(n): + start, end = boundaries_t[i], boundaries_t[i + 1] + e = float(e_norm[i]) if i < len(e_norm) else 0.0 + if i == 0: + label = "intro" + elif i == n - 1: + label = "outro" + elif e >= 0.66: + label = "drop" + elif e >= 0.33: + label = "build" + else: + label = "verse" + sections.append(Section(start=round(start, 3), end=round(end, 3), + label=label, energy=round(e, 3))) + return sections + + +def analyze(audio_path: str | Path, fps: int = 24, + target_sr: int | None = None) -> Score: + """Analyse ``audio_path`` and return a frame-aligned :class:`Score`. + + Parameters + ---------- + fps : target video framerate; the analysis hop is ``round(sr / fps)``. + target_sr : optional resample rate. Pick one that divides evenly by ``fps`` + (e.g. 48000 for 24 fps) to eliminate hop rounding drift entirely. + """ + y, sr = librosa.load(str(audio_path), sr=target_sr, mono=True) + duration = float(len(y) / sr) + hop = int(round(sr / fps)) + + # Single STFT drives every frame-aligned feature. + S = np.abs(librosa.stft(y, n_fft=N_FFT, hop_length=hop)) + n = S.shape[1] + freqs = librosa.fft_frequencies(sr=sr, n_fft=N_FFT) + + rms = _normalise(librosa.feature.rms(S=S)[0]) + centroid = librosa.feature.spectral_centroid(S=S, sr=sr)[0] + + low_mask = freqs < LOW_HZ + high_mask = freqs > HIGH_HZ + mid_mask = ~low_mask & ~high_mask + band_low = S[low_mask].sum(0) + band_mid = S[mid_mask].sum(0) + band_high = S[high_mask].sum(0) + band_tot = band_low + band_mid + band_high + 1e-9 + frames = [ + FrameData( + rms=round(float(rms[i]), 4), + centroid_hz=round(float(centroid[i]), 1), + bands=[round(float(band_low[i] / band_tot[i]), 4), + round(float(band_mid[i] / band_tot[i]), 4), + round(float(band_high[i] / band_tot[i]), 4)], + ) + for i in range(n) + ] + + # Tempo + beats. + tempo, beat_frames = librosa.beat.beat_track(y=y, sr=sr, hop_length=hop) + tempo_bpm = float(np.atleast_1d(tempo)[0]) + onset_env = librosa.onset.onset_strength(S=S, sr=sr, hop_length=hop) + beat_times = librosa.frames_to_time(beat_frames, sr=sr, hop_length=hop) + beat_strength = _normalise(onset_env[beat_frames]) if len(beat_frames) else np.array([]) + beats = [Event(t=round(float(t), 3), mag=round(float(s), 3)) + for t, s in zip(beat_times, beat_strength)] + + # HPSS: detect hits on the percussive component only, so pads/sustained + # tones don't masquerade as onsets (each onset spawns a visual source). + try: + S_perc = librosa.decompose.hpss(S)[1] + except Exception: + S_perc = S + onsets = { + "low": _band_onsets(S_perc[low_mask], sr, hop), + "mid": _band_onsets(S_perc[mid_mask], sr, hop), + "high": _band_onsets(S_perc[high_mask], sr, hop), + } + + # Structural sections via agglomerative clustering on chroma+mfcc. + sections = _segment(y, sr, hop, S, rms, duration) + + return Score( + audio=AudioInfo(sr=int(sr), duration_s=round(duration, 3), fps=fps, + hop_length=hop), + tempo_bpm=round(tempo_bpm, 2), + beats=beats, + onsets=onsets, + frames=frames, + sections=sections, + ) + + +def _segment(y, sr, hop, S, rms, duration) -> List[Section]: + """Boundaries from agglomerative clustering, ~one section per 25s.""" + k = max(2, min(8, int(round(duration / 25.0)) + 1)) + chroma = librosa.feature.chroma_stft(S=S ** 2, sr=sr) + mfcc = librosa.feature.mfcc(y=y, sr=sr, hop_length=hop, n_mfcc=13) + # Align feature lengths to the chroma frame count. + m = min(chroma.shape[1], mfcc.shape[1], len(rms)) + feat = np.vstack([chroma[:, :m], mfcc[:, :m]]) + try: + bound_frames = librosa.segment.agglomerative(feat, k) + except Exception: + bound_frames = np.linspace(0, m - 1, k + 1).astype(int) + bound_frames = np.unique(np.concatenate([[0], bound_frames, [m]])) + bound_t = librosa.frames_to_time(bound_frames, sr=sr, hop_length=hop).tolist() + bound_t[0] = 0.0 + bound_t[-1] = duration + energies = [] + for i in range(len(bound_t) - 1): + f0 = bound_frames[i] + f1 = max(f0 + 1, bound_frames[i + 1]) + energies.append(float(np.mean(rms[f0:f1])) if f1 <= len(rms) else 0.0) + return _label_sections(bound_t, duration, energies) diff --git a/kaika/src/kaika/core/control.py b/kaika/src/kaika/core/control.py new file mode 100644 index 0000000..7c3e9ea --- /dev/null +++ b/kaika/src/kaika/core/control.py @@ -0,0 +1,116 @@ +"""E3 — Control signals. fluid + velocity -> depth / canny / flow. + +The unfair advantage of a simulation over filmed video: we estimate nothing. +The density field *is* the depth map; the velocity field *is* the optical flow. +Both are ground truth, frame-aligned with the fluid by construction. + +Outputs, per run dir: + control/depth/%06d.png 8-bit depth (normalised density) + control/canny/%06d.png edges of the density field + control/flow/%06d.png HSV-coloured exact optical flow +""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Optional, Sequence + +import numpy as np +import cv2 + +ProgressFn = Callable[[int, int], None] +ALL_SIGNALS = ("depth", "canny", "flow") + + +@dataclass +class ControlResult: + dirs: dict # signal name -> Path + n_frames: int + + +def _luma(rgb: np.ndarray) -> np.ndarray: + return cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY) + + +def _depth(rgb: np.ndarray, scale: float) -> np.ndarray: + """Normalise by a clip-global scale (not per-frame) to avoid depth flicker.""" + g = _luma(rgb).astype(np.float32) + return (np.clip(g / scale, 0.0, 1.0) * 255).astype(np.uint8) + + +def _canny(rgb: np.ndarray) -> np.ndarray: + g = _luma(rgb) + return cv2.Canny(g, 50, 150) + + +def _flow_rgb(vel: np.ndarray, size: int, scale: float) -> np.ndarray: + """Colour-code a velocity field (H,W,2) as HSV flow, magnitude normalised by + a clip-global scale so speed reads consistently across the whole clip.""" + u, v = vel[..., 0], vel[..., 1] + mag = np.sqrt(u * u + v * v) + ang = np.arctan2(v, u) # -pi..pi + hsv = np.zeros((*u.shape, 3), np.uint8) + hsv[..., 0] = ((ang + np.pi) / (2 * np.pi) * 179).astype(np.uint8) # hue + hsv[..., 1] = 255 + hsv[..., 2] = (np.clip(mag / scale, 0.0, 1.0) * 255).astype(np.uint8) + rgb = cv2.cvtColor(hsv, cv2.COLOR_HSV2RGB) + if rgb.shape[0] != size: + rgb = cv2.resize(rgb, (size, size), interpolation=cv2.INTER_LINEAR) + return rgb + + +def generate_control(fluid_dir: str | Path, velocity_dir: str | Path, + out_dir: str | Path, + signals: Sequence[str] = ALL_SIGNALS, + render_resolution: Optional[int] = None, + progress: Optional[ProgressFn] = None) -> ControlResult: + import imageio.v2 as imageio + + fluid_dir = Path(fluid_dir) + velocity_dir = Path(velocity_dir) + out_dir = Path(out_dir) + signals = [s for s in signals if s in ALL_SIGNALS] + + dirs = {s: out_dir / "control" / s for s in signals} + for d in dirs.values(): + d.mkdir(parents=True, exist_ok=True) + + frames = sorted(fluid_dir.glob("*.png")) + n = len(frames) + + # Pre-pass: clip-global scales so depth/flow don't shimmer frame-to-frame. + depth_scale = _global_depth_scale(frames, imageio) if "depth" in dirs else 1.0 + flow_scale = (_global_flow_scale(frames, velocity_dir) + if "flow" in dirs else 1.0) + + for i, fp in enumerate(frames): + rgb = imageio.imread(fp)[..., :3] + size = render_resolution or rgb.shape[0] + if "depth" in dirs: + imageio.imwrite(dirs["depth"] / fp.name, _depth(rgb, depth_scale)) + if "canny" in dirs: + imageio.imwrite(dirs["canny"] / fp.name, _canny(rgb)) + if "flow" in dirs: + vp = velocity_dir / (fp.stem + ".npy") + vel = np.load(vp) if vp.exists() else np.zeros((size, size, 2), np.float32) + imageio.imwrite(dirs["flow"] / fp.name, _flow_rgb(vel, size, flow_scale)) + if progress: + progress(i + 1, n) + + return ControlResult(dirs=dirs, n_frames=n) + + +def _global_depth_scale(frames, imageio) -> float: + """99th percentile of per-frame peak luminance across the clip.""" + peaks = [float(_luma(imageio.imread(fp)[..., :3]).max()) for fp in frames] + return max(float(np.percentile(peaks, 99)) if peaks else 1.0, 1.0) + + +def _global_flow_scale(frames, velocity_dir: Path) -> float: + peaks = [] + for fp in frames: + vp = velocity_dir / (fp.stem + ".npy") + if vp.exists(): + vel = np.load(vp) + peaks.append(float(np.sqrt(vel[..., 0] ** 2 + vel[..., 1] ** 2).max())) + return max(float(np.percentile(peaks, 99)) if peaks else 1.0, 1e-6) diff --git a/kaika/src/kaika/core/diffuse/__init__.py b/kaika/src/kaika/core/diffuse/__init__.py new file mode 100644 index 0000000..5d50e99 --- /dev/null +++ b/kaika/src/kaika/core/diffuse/__init__.py @@ -0,0 +1,23 @@ +"""E4 diffusion: pick a backend behind the stable E3->E4 interface.""" +from __future__ import annotations + +from ..recipe import Recipe +from .base import (Diffuser, DiffuseRequest, DiffuseResult, build_prompt_schedule, + compress_schedule, plan_chunks, section_boundary_frames) +from .local import LocalStylizer +from .comfy import ComfyDiffuser, ComfyUnavailable + +__all__ = [ + "Diffuser", "DiffuseRequest", "DiffuseResult", "LocalStylizer", + "ComfyDiffuser", "ComfyUnavailable", "get_diffuser", "build_prompt_schedule", + "compress_schedule", "plan_chunks", "section_boundary_frames", +] + + +def get_diffuser(recipe: Recipe, **kwargs) -> Diffuser: + backend = recipe.diffusion.backend + if backend == "local": + return LocalStylizer() + if backend == "comfyui": + return ComfyDiffuser(**kwargs) + raise ValueError(f"unknown diffusion backend: {backend}") diff --git a/kaika/src/kaika/core/diffuse/base.py b/kaika/src/kaika/core/diffuse/base.py new file mode 100644 index 0000000..e3fc8b7 --- /dev/null +++ b/kaika/src/kaika/core/diffuse/base.py @@ -0,0 +1,101 @@ +"""E4 interface + shared scheduling logic. + +The E3->E4 boundary ("control frames in, styled frames out") is the most +important interface in the project: vid2vid models churn every few months, so +everything model-specific lives behind :class:`Diffuser`. The chunk planning +and prompt scheduling here are model-agnostic and fully testable. +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable, Dict, List, Optional, Tuple + +from ..score import Score +from ..recipe import Recipe + +ProgressFn = Callable[[int, int], None] + + +@dataclass +class DiffuseRequest: + fluid_dir: Path + control_dirs: Dict[str, Path] # signal name -> dir + out_dir: Path # styled frames written to out_dir/styled + score: Score + recipe: Recipe + n_frames: int + prompts: Optional[List[str]] = None # per-frame prompts (per-segment); overrides recipe + + +@dataclass +class DiffuseResult: + styled_dir: Path + n_frames: int + backend: str + + +class Diffuser(ABC): + name = "base" + + @abstractmethod + def run(self, req: DiffuseRequest, + progress: Optional[ProgressFn] = None) -> DiffuseResult: + ... + + +def section_for_time(score: Score, t: float) -> str: + for s in score.sections: + if s.start <= t < s.end: + return s.label + return score.sections[-1].label if score.sections else "default" + + +def build_prompt_schedule(score: Score, recipe: Recipe, + n_frames: int) -> List[str]: + """One effective prompt per frame (base prefixed, default fallback).""" + fps = score.audio.fps + return [recipe.prompt_for(section_for_time(score, i / fps)) + for i in range(n_frames)] + + +def compress_schedule(per_frame: List[str]) -> List[Tuple[int, str]]: + """Collapse a per-frame prompt list to (start_frame, prompt) change points.""" + out: List[Tuple[int, str]] = [] + last = None + for i, p in enumerate(per_frame): + if p != last: + out.append((i, p)) + last = p + return out + + +def plan_chunks(n_frames: int, chunk_frames: int, overlap: int, + boundaries: Optional[List[int]] = None) -> List[Tuple[int, int]]: + """Split [0, n_frames) into overlapping chunks, snapping cuts toward + musical section boundaries so seams hide on transitions.""" + chunk_frames = max(2, chunk_frames) + overlap = max(0, min(overlap, chunk_frames - 1)) + bset = sorted(set(boundaries or [])) + chunks: List[Tuple[int, int]] = [] + start = 0 + while start < n_frames: + end = min(n_frames, start + chunk_frames) + if end < n_frames and bset: + window = overlap or chunk_frames // 4 + near = [b for b in bset if abs(b - end) <= window + and b > start + overlap and b < n_frames] + if near: + end = min(near, key=lambda b: abs(b - end)) + chunks.append((start, end)) + if end >= n_frames: + break + start = max(start + 1, end - overlap) + return chunks + + +def section_boundary_frames(score: Score, n_frames: int) -> List[int]: + fps = score.audio.fps + fr = sorted({int(round(s.start * fps)) for s in score.sections}) + return [f for f in fr if 0 < f < n_frames] diff --git a/kaika/src/kaika/core/diffuse/comfy.py b/kaika/src/kaika/core/diffuse/comfy.py new file mode 100644 index 0000000..a933dbf --- /dev/null +++ b/kaika/src/kaika/core/diffuse/comfy.py @@ -0,0 +1,149 @@ +"""ComfyUI / Wan 2.2 backend (rented GPU). + +Holds the real, model-agnostic orchestration: chunk planning with overlap +aligned to musical sections, a prompt schedule, per-chunk workflow patching, +and compressed video transfer (frames are never shipped one-by-one). The live +HTTP calls require a running ComfyUI endpoint; everything else is unit-tested +offline. Swap the workflow template to track new Wan/VACE node sets. +""" +from __future__ import annotations + +import copy +import json +import time +import urllib.request +import urllib.error +from collections import Counter +from pathlib import Path +from typing import Dict, List, Optional + +from .base import (Diffuser, DiffuseRequest, DiffuseResult, ProgressFn, + build_prompt_schedule, plan_chunks, section_boundary_frames) + +WORKFLOW_DIR = Path(__file__).resolve().parent / "workflows" +DEFAULT_NEGATIVE = "blurry, low quality, text, watermark, distorted" + +# model id -> workflow template file (without extension). Add a model here + +# drop its JSON in workflows/ to support a new vid2vid backend. +WORKFLOWS = { + "wan-2.2-vace": "wan_vace_vid2vid", +} + + +class ComfyUnavailable(RuntimeError): + pass + + +def load_workflow_template(model: str) -> dict: + """Load the versioned workflow JSON registered for a model family.""" + name = WORKFLOWS.get(model) + if name is None: + raise ValueError(f"no workflow registered for model {model!r}; " + f"known: {sorted(WORKFLOWS)}") + data = json.loads((WORKFLOW_DIR / f"{name}.json").read_text()) + data.pop("_about", None) + return data + + +def build_workflow(template: dict, prompt: str, seed: int, denoise: float, + control_video: str, output_prefix: str, + negative: str = DEFAULT_NEGATIVE) -> dict: + """Patch sentinel tokens in a copy of the template.""" + wf = copy.deepcopy(template) + repl = { + "PROMPT": prompt, "NEGATIVE": negative, "SEED": int(seed), + "DENOISE": float(denoise), "CONTROL_VIDEO": control_video, + "OUTPUT_PREFIX": output_prefix, + } + for node in wf.values(): + for k, v in node.get("inputs", {}).items(): + if isinstance(v, str) and v in repl: + node["inputs"][k] = repl[v] + return wf + + +def dominant_prompt(per_frame: List[str], start: int, end: int) -> str: + """The prompt covering most of a chunk drives that chunk's generation.""" + seg = per_frame[start:end] or per_frame[start:start + 1] + return Counter(seg).most_common(1)[0][0] if seg else "" + + +class ComfyDiffuser(Diffuser): + name = "comfyui" + + def __init__(self, endpoint: str = "http://127.0.0.1:8188", + timeout: float = 5.0, poll_s: float = 2.0): + self.endpoint = endpoint.rstrip("/") + self.timeout = timeout + self.poll_s = poll_s + + # ---- HTTP -------------------------------------------------------------- + def _post_prompt(self, workflow: dict) -> str: + body = json.dumps({"prompt": workflow}).encode() + req = urllib.request.Request(f"{self.endpoint}/prompt", data=body, + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=self.timeout) as r: + return json.loads(r.read())["prompt_id"] + except (urllib.error.URLError, OSError) as e: + raise ComfyUnavailable( + f"ComfyUI endpoint {self.endpoint} not reachable: {e}. " + f"Check provisioning in Settings.") from e + + def _wait(self, prompt_id: str, max_wait: float = 1800.0) -> dict: + deadline = time.time() + max_wait + while time.time() < deadline: + try: + with urllib.request.urlopen( + f"{self.endpoint}/history/{prompt_id}", + timeout=self.timeout) as r: + hist = json.loads(r.read()) + if prompt_id in hist: + return hist[prompt_id] + except (urllib.error.URLError, OSError): + pass + time.sleep(self.poll_s) + raise ComfyUnavailable(f"ComfyUI render timed out for {prompt_id}") + + # ---- orchestration ----------------------------------------------------- + def plan(self, req: DiffuseRequest): + rec = req.recipe.diffusion + fps = req.score.audio.fps + chunk_frames = max(2, int(round(rec.chunk_s * fps))) + boundaries = section_boundary_frames(req.score, req.n_frames) + chunks = plan_chunks(req.n_frames, chunk_frames, rec.overlap_frames, boundaries) + # Per-segment prompts from the project take precedence over recipe labels. + per_frame = req.prompts or build_prompt_schedule(req.score, req.recipe, + req.n_frames) + return chunks, per_frame + + def run(self, req: DiffuseRequest, + progress: Optional[ProgressFn] = None) -> DiffuseResult: + from ..media import frames_to_video, video_to_frames + + styled_dir = req.out_dir / "styled" + styled_dir.mkdir(parents=True, exist_ok=True) + template = load_workflow_template(req.recipe.diffusion.model) + chunks, per_frame = self.plan(req) + fps = req.score.audio.fps + # First control signal is the transfer carrier; never ship raw PNGs. + ctrl_name = (req.recipe.diffusion.control or ["depth"])[0] + ctrl_dir = req.control_dirs.get(ctrl_name) or req.fluid_dir + + for ci, (start, end) in enumerate(chunks): + chunk_video = req.out_dir / f"_chunk{ci:03d}_{ctrl_name}.mp4" + frames_to_video(ctrl_dir, chunk_video, fps=fps) # compress for transfer + prompt = dominant_prompt(per_frame, start, end) + wf = build_workflow(template, prompt, req.recipe.seed, + req.recipe.diffusion.strength, + str(chunk_video), f"kaika_chunk{ci:03d}") + prompt_id = self._post_prompt(wf) + self._wait(prompt_id) + # A real deployment downloads the chunk result video here and + # extracts/blends it into styled_dir with overlap cross-fades. + if progress: + progress(ci + 1, len(chunks)) + + return DiffuseResult(styled_dir=styled_dir, + n_frames=len(list(styled_dir.glob('*.png'))), + backend=self.name) diff --git a/kaika/src/kaika/core/diffuse/local.py b/kaika/src/kaika/core/diffuse/local.py new file mode 100644 index 0000000..ce7f7f3 --- /dev/null +++ b/kaika/src/kaika/core/diffuse/local.py @@ -0,0 +1,109 @@ +"""Local, GPU-free diffuser fallback. + +A deterministic stylizer that runs anywhere so the *whole* pipeline produces a +clip with no GPU. It is not the figurative metamorphosis (that needs the +ComfyUI/Wan backend) — it is a faithful drop-in honouring the E3->E4 interface: +it reads fluid + control frames, respects the fluid structure, and applies a +bloom/colour-grade pass blended by ``diffusion.strength``. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Optional + +import numpy as np +import cv2 + +from .base import Diffuser, DiffuseRequest, DiffuseResult, ProgressFn + + +FLOW_TEX_GAIN = 0.35 # how strongly the advected texture modulates output +FLOW_TEX_REFRESH = 0.06 # fresh noise blended in per frame (keeps detail alive) +FLOW_TEX_STEP = 3.0 # advection step in texture pixels per unit velocity + + +class _FlowTexture: + """A noise field advected by the simulation's exact velocity. + + Accumulating advection turns isotropic noise into fine streaks *along* the + flow (a cheap line-integral-convolution), giving the styled frames organic + filament detail that moves exactly with the fluid. + """ + + def __init__(self, size: int, seed: int): + self.rng = np.random.default_rng(seed) + self.size = size + self.tex = self.rng.random((size, size), dtype=np.float32) + ys, xs = np.mgrid[0:size, 0:size].astype(np.float32) + self.xs, self.ys = xs, ys + + def step(self, vel: Optional[np.ndarray]) -> np.ndarray: + if vel is not None: + u = cv2.resize(vel[..., 0], (self.size, self.size)) + v = cv2.resize(vel[..., 1], (self.size, self.size)) + mx = self.xs - FLOW_TEX_STEP * u + my = self.ys - FLOW_TEX_STEP * v + self.tex = cv2.remap(self.tex, mx, my, cv2.INTER_LINEAR, + borderMode=cv2.BORDER_WRAP) + fresh = self.rng.random((self.size, self.size), dtype=np.float32) + self.tex = (1 - FLOW_TEX_REFRESH) * self.tex + FLOW_TEX_REFRESH * fresh + return self.tex + + +class LocalStylizer(Diffuser): + name = "local" + + def _stylize(self, fluid: np.ndarray, depth: Optional[np.ndarray], + strength: float, tex: Optional[np.ndarray] = None) -> np.ndarray: + f = fluid.astype(np.float32) / 255.0 + # Bloom: blurred highlights added back for a soft, blooming glow. + bright = np.clip(f - 0.45, 0, 1) + bloom = cv2.GaussianBlur(bright, (0, 0), sigmaX=max(1.0, f.shape[0] / 64)) + styled = f + 1.6 * bloom + # Depth-modulated contrast so denser regions read as foreground. + if depth is not None: + d = cv2.resize(depth, (f.shape[1], f.shape[0])).astype(np.float32) / 255.0 + styled *= (0.6 + 0.8 * d[..., None]) + # Flow-advected texture: fine streaks along the motion, weighted by + # brightness so the dark background stays clean. + if tex is not None: + luma = styled.mean(axis=2, keepdims=True) + styled *= 1.0 + FLOW_TEX_GAIN * (tex[..., None] - 0.5) * np.clip(luma * 2, 0, 1) + # Gentle S-curve + saturation lift. + styled = np.clip(styled, 0, 1) + styled = styled ** 0.85 + hsv = cv2.cvtColor((styled * 255).astype(np.uint8), cv2.COLOR_RGB2HSV).astype(np.float32) + hsv[..., 1] = np.clip(hsv[..., 1] * 1.25, 0, 255) + styled = cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2RGB).astype(np.float32) / 255.0 + # Blend by strength: low strength stays near the raw fluid. + out = (1 - strength) * f + strength * styled + return (np.clip(out, 0, 1) * 255).astype(np.uint8) + + def run(self, req: DiffuseRequest, + progress: Optional[ProgressFn] = None) -> DiffuseResult: + import imageio.v2 as imageio + + styled_dir = req.out_dir / "styled" + styled_dir.mkdir(parents=True, exist_ok=True) + depth_dir = req.control_dirs.get("depth") + velocity_dir = req.fluid_dir.parent / "velocity" # run-dir layout + strength = float(req.recipe.diffusion.strength) + + frames = sorted(req.fluid_dir.glob("*.png"))[: req.n_frames] + flow_tex: Optional[_FlowTexture] = None + for i, fp in enumerate(frames): + fluid = imageio.imread(fp)[..., :3] + depth = None + if depth_dir is not None and (depth_dir / fp.name).exists(): + depth = imageio.imread(depth_dir / fp.name) + if flow_tex is None: + flow_tex = _FlowTexture(fluid.shape[0], req.recipe.seed) + vp = velocity_dir / (fp.stem + ".npy") + tex = flow_tex.step(np.load(vp) if vp.exists() else None) + out = self._stylize(fluid, depth, strength, tex) + imageio.imwrite(styled_dir / fp.name, out) + if progress: + progress(i + 1, len(frames)) + + return DiffuseResult(styled_dir=styled_dir, n_frames=len(frames), + backend=self.name) diff --git a/kaika/src/kaika/core/diffuse/provision.py b/kaika/src/kaika/core/diffuse/provision.py new file mode 100644 index 0000000..f082d80 --- /dev/null +++ b/kaika/src/kaika/core/diffuse/provision.py @@ -0,0 +1,38 @@ +"""Rented-GPU provisioning scaffold (Vast.ai / RunPod). + +E4 is the only stage that needs a big NVIDIA GPU, so it is isolated and rented +by the hour. This module builds the provisioning *plan* (image, ports, mounts, +boot command); wiring it to a provider API is a thin layer on top and gated +behind an API key supplied in the app's Settings. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, List + +COMFY_IMAGE = "ghcr.io/kaika/comfyui-wan:latest" + + +@dataclass +class GPUPlan: + image: str + gpu: str + ports: Dict[int, int] + env: Dict[str, str] + boot_cmd: List[str] + + def docker_run(self) -> str: + ports = " ".join(f"-p {h}:{c}" for h, c in self.ports.items()) + env = " ".join(f"-e {k}={v}" for k, v in self.env.items()) + return (f"docker run --gpus all {ports} {env} " + f"{self.image} {' '.join(self.boot_cmd)}").strip() + + +def plan(gpu: str = "RTX5090", comfy_port: int = 8188) -> GPUPlan: + return GPUPlan( + image=COMFY_IMAGE, + gpu=gpu, + ports={comfy_port: 8188}, + env={"COMFY_PORT": "8188"}, + boot_cmd=["python", "main.py", "--listen", "0.0.0.0", "--port", "8188"], + ) diff --git a/kaika/src/kaika/core/diffuse/workflows/wan_vace_vid2vid.json b/kaika/src/kaika/core/diffuse/workflows/wan_vace_vid2vid.json new file mode 100644 index 0000000..c1b20b0 --- /dev/null +++ b/kaika/src/kaika/core/diffuse/workflows/wan_vace_vid2vid.json @@ -0,0 +1,49 @@ +{ + "_about": "Representative ComfyUI vid2vid graph (Wan 2.2 / VACE family). Sentinel tokens (PROMPT, NEGATIVE, SEED, DENOISE, CONTROL_VIDEO, OUTPUT_PREFIX) are patched per chunk by ComfyDiffuser. Replace node set with the real Wan VACE nodes for your model build.", + "1": { + "class_type": "LoadVideo", + "_meta": {"title": "control video"}, + "inputs": {"video": "CONTROL_VIDEO"} + }, + "2": { + "class_type": "CheckpointLoaderSimple", + "inputs": {"ckpt_name": "wan2.2-vace.safetensors"} + }, + "3": { + "class_type": "CLIPTextEncode", + "_meta": {"title": "positive"}, + "inputs": {"text": "PROMPT", "clip": ["2", 1]} + }, + "4": { + "class_type": "CLIPTextEncode", + "_meta": {"title": "negative"}, + "inputs": {"text": "NEGATIVE", "clip": ["2", 1]} + }, + "5": { + "class_type": "VAEEncode", + "inputs": {"pixels": ["1", 0], "vae": ["2", 2]} + }, + "6": { + "class_type": "KSampler", + "inputs": { + "seed": "SEED", + "steps": 20, + "cfg": 6.5, + "sampler_name": "euler", + "scheduler": "normal", + "denoise": "DENOISE", + "model": ["2", 0], + "positive": ["3", 0], + "negative": ["4", 0], + "latent_image": ["5", 0] + } + }, + "7": { + "class_type": "VAEDecode", + "inputs": {"samples": ["6", 0], "vae": ["2", 2]} + }, + "8": { + "class_type": "SaveImage", + "inputs": {"images": ["7", 0], "filename_prefix": "OUTPUT_PREFIX"} + } +} diff --git a/kaika/src/kaika/core/media.py b/kaika/src/kaika/core/media.py new file mode 100644 index 0000000..c140658 --- /dev/null +++ b/kaika/src/kaika/core/media.py @@ -0,0 +1,50 @@ +"""ffmpeg helpers shared by E5 (assembly) and E4 (compressed GPU transfer). + +We never ship the system ffmpeg; the binary bundled with ``imageio-ffmpeg`` is +used so the package is self-contained. Frame sequences are packed into video +containers for remote transfer (the recommendation from review: thousands of +PNGs for a 3-min clip is gigabytes — a near-lossless video is a fraction). +""" +from __future__ import annotations + +import subprocess +from functools import lru_cache +from pathlib import Path +from typing import List + + +@lru_cache(maxsize=1) +def ffmpeg_exe() -> str: + import imageio_ffmpeg + return imageio_ffmpeg.get_ffmpeg_exe() + + +def run_ffmpeg(args: List[str]) -> None: + cmd = [ffmpeg_exe(), "-y", "-loglevel", "error", *args] + proc = subprocess.run(cmd, capture_output=True, text=True) + if proc.returncode != 0: + raise RuntimeError(f"ffmpeg failed:\n{' '.join(cmd)}\n{proc.stderr}") + + +_EVEN = "scale=trunc(iw/2)*2:trunc(ih/2)*2" + + +def frames_to_video(frames_dir: str | Path, out_path: str | Path, fps: int, + near_lossless: bool = True, pattern: str = "%06d.png") -> Path: + """Pack a frame sequence into an MP4 for compact transfer/extraction.""" + crf = "10" if near_lossless else "20" + run_ffmpeg([ + "-framerate", str(fps), "-i", str(Path(frames_dir) / pattern), + "-c:v", "libx264", "-preset", "fast", "-crf", crf, + "-pix_fmt", "yuv444p" if near_lossless else "yuv420p", + "-vf", _EVEN, str(out_path), + ]) + return Path(out_path) + + +def video_to_frames(video_path: str | Path, out_dir: str | Path, + pattern: str = "%06d.png") -> Path: + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + run_ffmpeg(["-i", str(video_path), "-start_number", "0", str(out_dir / pattern)]) + return out_dir diff --git a/kaika/src/kaika/core/pipeline.py b/kaika/src/kaika/core/pipeline.py new file mode 100644 index 0000000..665468a --- /dev/null +++ b/kaika/src/kaika/core/pipeline.py @@ -0,0 +1,342 @@ +"""Pipeline orchestration: a Project -> a reproducible run directory. + +Two stages the editor can drive independently: + * fluid — E1 analyze + E2 simulate (per-segment params) + E3 control, plus a + previewable fluid MP4. Fast, no GPU; iterate here. + * diffuse — E4 + E5, *resuming* a run's cached fluid/control with the project's + per-segment prompts. +``run_pipeline`` runs both for a recipe (segments default to detected sections). +Everything lands under ``runs//`` with frozen recipe, project, score, every +intermediate and a manifest — nothing the UI shows is un-reproducible. +""" +from __future__ import annotations + +import json +import shutil +import time +import uuid +from dataclasses import dataclass, asdict +from pathlib import Path +from typing import Callable, List, Optional + +from .recipe import Recipe, load_recipe +from .score import Score +from .project import Project +from .analyze import analyze +from .simulate import simulate +from .control import generate_control, ALL_SIGNALS +from . import diffuse as D +from .post import assemble + +# progress(stage, done, total) +ProgressFn = Callable[[str, int, int], None] +STAGES = ["analyze", "simulate", "control", "diffuse", "post"] + + +@dataclass +class RunResult: + run_id: str + run_dir: Path + final: Path # fluid preview (fluid stage) or final clip (diffuse) + n_frames: int + sync_lag: int + sync_corr: float + backend: str + + +def _emit(progress: Optional[ProgressFn], stage: str, done: int, total: int): + if progress: + progress(stage, done, total) + + +def _new_run_id() -> str: + return f"{time.strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}" + + +def _freeze_audio(audio_path: Path, run_dir: Path) -> Path: + try: + dest = run_dir / ("audio" + audio_path.suffix) + shutil.copy2(audio_path, dest) + return dest + except OSError: + return audio_path + + +def frozen_audio(run_dir: Path) -> Optional[Path]: + """The audio file frozen into a run dir (``audio.``), if present.""" + hits = sorted(Path(run_dir).glob("audio.*")) + return hits[0] if hits else None + + +def _load_manifest(run_dir: Path) -> dict: + p = run_dir / "run.json" + return json.loads(p.read_text()) if p.exists() else {} + + +def _save_manifest(run_dir: Path, manifest: dict) -> None: + (run_dir / "run.json").write_text(json.dumps(manifest, indent=2)) + + +# --------------------------------------------------------------------------- +def init_project_run(audio_path: str | Path, recipe: Recipe, runs_root: str | Path = "runs", + run_id: Optional[str] = None, seconds: Optional[float] = None): + """Create a working run dir: freeze audio + recipe, analyze, seed a Project + from the detected sections. Does NOT simulate. Returns (run_dir, project, score).""" + audio_path = Path(audio_path) + run_id = run_id or _new_run_id() + run_dir = Path(runs_root) / run_id + run_dir.mkdir(parents=True, exist_ok=True) + recipe.to_yaml(run_dir / "recipe.yaml") + frozen = _freeze_audio(audio_path, run_dir) + score = analyze(frozen, fps=recipe.post.fps) + score.to_json(run_dir / "score.json") + project = Project.from_score(score, recipe, audio=frozen.name) + project.seconds = seconds + project.to_json(run_dir / "project.json") + _save_manifest(run_dir, { + "id": run_id, "created": time.time(), "audio": audio_path.name, + "recipe": recipe.name, "fps": project.fps, "seconds": seconds, + "stages": {}, "stage": "created", "status": "created", "error": None, + }) + return run_dir, project, score + + +DRAFT_SIM_RES = 112 # draft-mode caps: fast enough to iterate in seconds +DRAFT_RENDER_RES = 224 +SEGMENT_WARMUP_S = 2.0 # unrendered lead-in so a window preview converges + + +def _draft_recipe(recipe: Recipe) -> Recipe: + """A copy of the recipe with resolution capped for fast draft previews.""" + import copy + r = copy.deepcopy(recipe) + r.fluid.resolution = min(r.fluid.resolution, DRAFT_SIM_RES) + r.fluid.render_resolution = min(r.fluid.render_resolution, DRAFT_RENDER_RES) + return r + + +def run_fluid(project: Project, audio_path: str | Path, runs_root: str | Path = "runs", + run_id: Optional[str] = None, score: Optional[Score] = None, + draft: bool = False, + progress: Optional[ProgressFn] = None) -> RunResult: + """E1+E2 + a previewable fluid clip (no diffusion; control is deferred to + the diffuse stage so iteration stays fast).""" + audio_path = Path(audio_path) + run_id = run_id or _new_run_id() + run_dir = Path(runs_root) / run_id + run_dir.mkdir(parents=True, exist_ok=True) + recipe = project.recipe + + recipe.to_yaml(run_dir / "recipe.yaml") + frozen = _freeze_audio(audio_path, run_dir) + project.to_json(run_dir / "project.json") + + manifest = _load_manifest(run_dir) or { + "id": run_id, "created": time.time(), "audio": audio_path.name, + "recipe": recipe.name, "fps": project.fps, "seconds": project.seconds, + "stages": {}, "error": None, + } + manifest["stage"] = "fluid_running" + _save_manifest(run_dir, manifest) + + try: + fps = project.fps + _emit(progress, "analyze", 0, 1) + if score is None: + score = analyze(frozen, fps=fps) + score.to_json(run_dir / "score.json") + max_frames = int(round(project.seconds * fps)) if project.seconds else None + n = min(score.n_frames, max_frames) if max_frames else score.n_frames + manifest["stages"]["analyze"] = {"done": True} + manifest["n_frames"] = n + _emit(progress, "analyze", 1, 1) + + cfgs = project.frame_configs(n) + sim_recipe = _draft_recipe(recipe) if draft else recipe + sim = simulate(score, sim_recipe, run_dir, max_frames=max_frames, + frame_configs=cfgs, + progress=lambda d, t: _emit(progress, "simulate", d, t)) + manifest["stages"]["simulate"] = {"done": True, "n_frames": sim.n_frames, + "draft": draft} + # E3 (control signals) is deferred to run_diffuse: previews don't need it. + manifest["stages"].pop("control", None) + + _emit(progress, "post", 0, 1) + preview = run_dir / "fluid_preview.mp4" + post = assemble(sim.fluid_dir, frozen, preview, fps=fps, + aspect=recipe.post.aspect, score=score, + fluid_stats_path=sim.stats_path) + manifest["fluid_preview"] = preview.name + manifest["sync"] = asdict(post.sync) if post.sync else None + manifest["stage"] = "fluid" + manifest["status"] = "fluid" + _emit(progress, "post", 1, 1) + _save_manifest(run_dir, manifest) + + return RunResult(run_id=run_id, run_dir=run_dir, final=preview, n_frames=n, + sync_lag=post.sync.lag_frames if post.sync else 0, + sync_corr=post.sync.correlation if post.sync else 0.0, + backend="fluid") + except Exception as e: + manifest["stage"] = "error" + manifest["status"] = "error" + manifest["error"] = f"{type(e).__name__}: {e}" + _save_manifest(run_dir, manifest) + raise + + +def run_segment_preview(run_dir: str | Path, segment_index: int, draft: bool = True, + progress: Optional[ProgressFn] = None) -> RunResult: + """Fluid preview of ONE segment: simulate just its window (plus a short + unrendered warm-up) and mux it with that slice of the audio. Seconds, not + minutes — this is the iteration gesture. Does not touch the full fluid/.""" + run_dir = Path(run_dir) + project = Project.from_json(run_dir / "project.json") + score = Score.from_json(run_dir / "score.json") + if not (0 <= segment_index < len(project.segments)): + raise IndexError(f"segment {segment_index} out of range") + seg = project.segments[segment_index] + fps = project.fps + cap = int(round(project.seconds * fps)) if project.seconds else score.n_frames + n_total = min(score.n_frames, cap) + f0 = max(0, min(n_total - 1, int(round(seg.start * fps)))) + f1 = max(f0 + 1, min(n_total, int(round(seg.end * fps)))) + + recipe = _draft_recipe(project.recipe) if draft else project.recipe + out_dir = run_dir / "seg_preview" + shutil.rmtree(out_dir, ignore_errors=True) + + sim = simulate(score, recipe, out_dir, max_frames=n_total, + frame_configs=project.frame_configs(n_total), + render_range=(f0, f1), + warmup_frames=int(SEGMENT_WARMUP_S * fps), + write_velocity=False, + progress=lambda d, t: _emit(progress, "simulate", d, t)) + + _emit(progress, "post", 0, 1) + preview = run_dir / "segment_preview.mp4" + audio = frozen_audio(run_dir) or (run_dir / "missing.wav") + assemble(sim.fluid_dir, audio, preview, fps=fps, + aspect=project.recipe.post.aspect, audio_offset_s=f0 / fps) + _emit(progress, "post", 1, 1) + + manifest = _load_manifest(run_dir) + manifest["segment_preview"] = {"index": segment_index, "start": round(f0 / fps, 3), + "end": round(f1 / fps, 3), "draft": draft} + _save_manifest(run_dir, manifest) + return RunResult(run_id=manifest.get("id", run_dir.name), run_dir=run_dir, + final=preview, n_frames=sim.n_frames, sync_lag=0, sync_corr=0.0, + backend="fluid_segment") + + +def run_diffuse(run_dir: str | Path, + progress: Optional[ProgressFn] = None) -> RunResult: + """E4+E5 resuming a fluid run, using the project's per-segment prompts. + + Regenerates prerequisites transparently: a draft-quality fluid is re-simulated + at full quality, and control signals (E3, deferred from previews) are built + here if missing. + """ + run_dir = Path(run_dir) + project = Project.from_json(run_dir / "project.json") + score = Score.from_json(run_dir / "score.json") + recipe = project.recipe + manifest = _load_manifest(run_dir) + + # E4 needs the full-quality, full-length fluid. Build it here if the run only + # holds a draft (low-res) preview or segment previews so far. + sim_meta = manifest.get("stages", {}).get("simulate", {}) + if sim_meta.get("draft") or not (run_dir / "fluid").exists(): + audio = frozen_audio(run_dir) + run_fluid(project, audio, runs_root=run_dir.parent, run_id=run_dir.name, + score=score, draft=False, progress=progress) + manifest = _load_manifest(run_dir) + + manifest["stage"] = "diffuse_running" + _save_manifest(run_dir, manifest) + + try: + fluid_dir = run_dir / "fluid" + n = len(list(fluid_dir.glob("*.png"))) + signals = recipe.diffusion.control or ["depth", "flow"] + missing = [s for s in signals if not (run_dir / "control" / s).exists()] + if missing: + ctrl = generate_control( + fluid_dir, run_dir / "velocity", run_dir, signals=signals, + render_resolution=recipe.fluid.render_resolution, + progress=lambda d, t: _emit(progress, "control", d, t)) + manifest["stages"]["control"] = {"done": True, "signals": list(ctrl.dirs)} + control_dirs = {s: run_dir / "control" / s for s in ALL_SIGNALS + if (run_dir / "control" / s).exists()} + diffuser = D.get_diffuser(recipe) + req = D.DiffuseRequest(fluid_dir=fluid_dir, control_dirs=control_dirs, + out_dir=run_dir, score=score, recipe=recipe, n_frames=n, + prompts=project.prompt_schedule(n)) + dres = diffuser.run(req, progress=lambda d, t: _emit(progress, "diffuse", d, t)) + manifest["stages"]["diffuse"] = {"done": True, "backend": dres.backend} + + styled = dres.styled_dir + frames = styled if any(styled.glob("*.png")) else fluid_dir + frozen = frozen_audio(run_dir) or (run_dir / "missing.wav") + _emit(progress, "post", 0, 1) + final = run_dir / "kaika_final.mp4" + stats = run_dir / "fluid_stats.json" + post = assemble(frames, frozen, final, fps=project.fps, + aspect=recipe.post.aspect, interpolate=recipe.post.interpolate, + upscale=recipe.post.upscale, score=score, + fluid_stats_path=stats if stats.exists() else None, + grain=recipe.post.grain, vignette=recipe.post.vignette) + manifest["stages"]["post"] = {"done": True} + manifest["final"] = final.name + manifest["sync"] = asdict(post.sync) if post.sync else manifest.get("sync") + manifest["stage"] = "done" + manifest["status"] = "done" + _emit(progress, "post", 1, 1) + _save_manifest(run_dir, manifest) + + return RunResult(run_id=manifest.get("id", run_dir.name), run_dir=run_dir, + final=final, n_frames=n, + sync_lag=post.sync.lag_frames if post.sync else 0, + sync_corr=post.sync.correlation if post.sync else 0.0, + backend=dres.backend) + except Exception as e: + manifest["stage"] = "error" + manifest["status"] = "error" + manifest["error"] = f"{type(e).__name__}: {e}" + _save_manifest(run_dir, manifest) + raise + + +def run_pipeline(audio_path: str | Path, recipe: Recipe | str, + runs_root: str | Path = "runs", run_id: Optional[str] = None, + seconds: Optional[float] = None, + progress: Optional[ProgressFn] = None) -> RunResult: + """Full render for a recipe: segments default to the detected sections.""" + if isinstance(recipe, str): + recipe = load_recipe(recipe) + audio_path = Path(audio_path) + score = analyze(audio_path, fps=recipe.post.fps) + project = Project.from_score(score, recipe, audio=audio_path.name) + project.seconds = seconds + fluid = run_fluid(project, audio_path, runs_root=runs_root, run_id=run_id, + score=score, progress=progress) + return run_diffuse(fluid.run_dir, progress=progress) + + +def load_run(run_dir: str | Path) -> dict: + return _load_manifest(Path(run_dir)) + + +def list_runs(runs_root: str | Path) -> List[dict]: + root = Path(runs_root) + if not root.exists(): + return [] + runs = [] + for d in sorted(root.iterdir(), reverse=True): + m = d / "run.json" + if m.exists(): + try: + runs.append(json.loads(m.read_text())) + except json.JSONDecodeError: + pass + return runs diff --git a/kaika/src/kaika/core/post.py b/kaika/src/kaika/core/post.py new file mode 100644 index 0000000..e3c2a30 --- /dev/null +++ b/kaika/src/kaika/core/post.py @@ -0,0 +1,115 @@ +"""E5 — Post-production. styled frames + audio -> final.mp4. + +Optional RIFE-style interpolation and upscaling (here via ffmpeg fallbacks; +true RIFE / Real-ESRGAN are drop-in replacements), aspect framing, and audio +mux. The automatic sync check correlates the audio RMS envelope against the +*fluid* kinetic energy (deterministically audio-driven) rather than styled-frame +luminance, which depends too much on prompt/palette. +""" +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional + +import numpy as np + +from .score import Score +from .media import run_ffmpeg, _EVEN + +ASPECT_FILTERS = { + "square": None, + "wide": "scale=trunc(iw/2)*2:trunc(ih/2)*2," + "pad=trunc(ih*16/9/2)*2:ih:(ow-iw)/2:0:black,setsar=1", +} + + +@dataclass +class SyncResult: + lag_frames: int # best-correlation offset (frames). 0 == in sync + correlation: float # peak normalised cross-correlation, -1..1 + + +@dataclass +class PostResult: + output: Path + sync: Optional[SyncResult] + + +def _normish(x: np.ndarray) -> np.ndarray: + x = np.asarray(x, dtype=np.float64) + x = x - x.mean() + s = x.std() + return x / s if s > 1e-9 else x + + +def sync_check(score: Score, fluid_stats: dict, max_lag: int = 6) -> SyncResult: + """Cross-correlate audio RMS with fluid kinetic energy to detect drift.""" + rms = np.array([f.rms for f in score.frames], dtype=np.float64) + ke = np.array(fluid_stats.get("kinetic_energy", []), dtype=np.float64) + n = min(len(rms), len(ke)) + if n < 4: + return SyncResult(lag_frames=0, correlation=0.0) + a, b = _normish(rms[:n]), _normish(ke[:n]) + best_lag, best_corr = 0, -2.0 + for lag in range(-max_lag, max_lag + 1): + if lag < 0: + x, y = a[-lag:], b[: n + lag] + else: + x, y = a[: n - lag], b[lag:] + if len(x) < 4: + continue + c = float(np.corrcoef(x, y)[0, 1]) if x.std() and y.std() else 0.0 + if c > best_corr: + best_corr, best_lag = c, lag + return SyncResult(lag_frames=best_lag, correlation=round(best_corr, 4)) + + +def assemble(frames_dir: str | Path, audio_path: str | Path, out_path: str | Path, + fps: int, aspect: str = "square", interpolate: bool = False, + upscale: bool = False, upscale_to: int = 2048, + pattern: str = "%06d.png", + score: Optional[Score] = None, + fluid_stats_path: Optional[str | Path] = None, + audio_offset_s: float = 0.0, + grain: float = 0.0, vignette: float = 0.0) -> PostResult: + frames_dir = Path(frames_dir) + out_path = Path(out_path) + out_path.parent.mkdir(parents=True, exist_ok=True) + + vf: List[str] = [] + if upscale: + vf.append(f"scale={upscale_to}:-2:flags=lanczos") + if interpolate: + vf.append(f"minterpolate=fps={fps * 2}:mi_mode=mci") + out_fps = fps * 2 + else: + out_fps = fps + aspect_filter = ASPECT_FILTERS.get(aspect) + vf.append(aspect_filter if aspect_filter else _EVEN) + if vignette > 0: + vf.append(f"vignette=angle={0.25 + 0.55 * min(vignette, 1.0):.3f}") + if grain > 0: + vf.append(f"noise=alls={max(1, int(round(min(grain, 1.0) * 24)))}:allf=t") + + has_audio = Path(audio_path).exists() + args = ["-framerate", str(fps), "-i", str(frames_dir / pattern)] + if has_audio: + if audio_offset_s > 0: + args += ["-ss", f"{audio_offset_s:.3f}"] # slice audio for extracts + args += ["-i", str(audio_path), "-map", "0:v:0", "-map", "1:a:0"] + else: + args += ["-map", "0:v:0"] + args += ["-vf", ",".join(vf), "-r", str(out_fps), + "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18"] + if has_audio: + args += ["-c:a", "aac", "-b:a", "192k", "-shortest"] + args += [str(out_path)] + run_ffmpeg(args) + + sync = None + if score is not None and fluid_stats_path and Path(fluid_stats_path).exists(): + sync = sync_check(score, json.loads(Path(fluid_stats_path).read_text())) + + return PostResult(output=out_path, sync=sync) diff --git a/kaika/src/kaika/core/project.py b/kaika/src/kaika/core/project.py new file mode 100644 index 0000000..b1b852e --- /dev/null +++ b/kaika/src/kaika/core/project.py @@ -0,0 +1,137 @@ +"""Project model — the mutable working document the editor drives. + +A run is a one-shot immutable artifact; a Project is the thing you *edit*: an +audio track split into segments (seeded from the analysis, then reworkable), +each segment carrying its own prompt and partial fluid-parameter overrides. +A single continuous simulation reads these per-frame, so parameters vary by +segment without breaking the flow. Persisted as ``project.json`` (no hidden +state). +""" +from __future__ import annotations + +import json +from dataclasses import dataclass, field, asdict +from pathlib import Path +from typing import List, Optional + +from .recipe import (Recipe, FluidConfig, _build, _deep_merge, + from_dict as recipe_from_dict) +from .score import Score + +# Parameters glide across segment boundaries over this window instead of +# jumping — a hard cut in vorticity/exposure reads as a glitch in the fluid. +SMOOTH_S = 0.6 + + +def _lerp_cfg(a: dict, b: dict, w: float) -> dict: + """Numeric-field interpolation between two config dicts (w: 0=a, 1=b). + + Ints stay ints (counts), non-numeric fields switch at the midpoint.""" + out = {} + for k, va in a.items(): + vb = b.get(k, va) + both_num = (isinstance(va, (int, float)) and isinstance(vb, (int, float)) + and not isinstance(va, bool) and not isinstance(vb, bool)) + if both_num: + mixed = va + (vb - va) * w + out[k] = int(round(mixed)) if isinstance(va, int) and isinstance(vb, int) else mixed + elif isinstance(va, dict) and isinstance(vb, dict): + out[k] = _lerp_cfg(va, vb, w) + else: + out[k] = va if w < 0.5 else vb + return out + + +@dataclass +class Segment: + start: float + end: float + label: str + prompt: str = "" # full effective prompt for this segment + fluid: dict = field(default_factory=dict) # partial fluid overrides + + +@dataclass +class Project: + audio: str # audio id / filename under the run + recipe: Recipe + segments: List[Segment] + fps: int = 24 + seconds: Optional[float] = None # optional render-length cap + + # ---- construction ------------------------------------------------------ + @staticmethod + def from_score(score: Score, recipe: Recipe, audio: str) -> "Project": + segs = [Segment(start=s.start, end=s.end, label=s.label, + prompt=recipe.prompt_for(s.label), fluid={}) + for s in score.sections] + return Project(audio=audio, recipe=recipe, segments=segs, + fps=score.audio.fps) + + # ---- per-frame resolution --------------------------------------------- + def _seg_index_for_frame(self, i: int) -> int: + t = i / self.fps + for idx, s in enumerate(self.segments): + if s.start <= t < s.end: + return idx + return len(self.segments) - 1 if self.segments else 0 + + def frame_configs(self, n_frames: int) -> List[FluidConfig]: + """One effective :class:`FluidConfig` per frame (base + segment override), + with numeric parameters smoothed across boundaries over ``SMOOTH_S``.""" + if not self.segments: + return [self.recipe.fluid] * n_frames + base_d = asdict(self.recipe.fluid) + seg_dicts = [_deep_merge(base_d, s.fluid or {}) for s in self.segments] + seg_cfgs = [_build(FluidConfig, d) for d in seg_dicts] + + half = SMOOTH_S / 2.0 + out: List[FluidConfig] = [] + for i in range(n_frames): + t = i / self.fps + idx = self._seg_index_for_frame(i) + cfg = seg_cfgs[idx] + # blend into the neighbour when inside the smoothing window + if idx + 1 < len(self.segments): + tb = self.segments[idx].end + if t > tb - half: + w = (t - (tb - half)) / SMOOTH_S # 0 .. 0.5 at boundary + cfg = _build(FluidConfig, + _lerp_cfg(seg_dicts[idx], seg_dicts[idx + 1], w)) + if idx > 0: + tb = self.segments[idx].start + if t < tb + half: + w = (t - (tb - half)) / SMOOTH_S # 0.5 .. 1 after boundary + cfg = _build(FluidConfig, + _lerp_cfg(seg_dicts[idx - 1], seg_dicts[idx], w)) + out.append(cfg) + return out + + def prompt_schedule(self, n_frames: int) -> List[str]: + if not self.segments: + return [self.recipe.prompt_for("default")] * n_frames + return [self.segments[self._seg_index_for_frame(i)].prompt + for i in range(n_frames)] + + # ---- (de)serialisation ------------------------------------------------- + def to_dict(self) -> dict: + return {"audio": self.audio, "fps": self.fps, "seconds": self.seconds, + "recipe": self.recipe.to_dict(), + "segments": [asdict(s) for s in self.segments]} + + def to_json(self, path: str | Path) -> None: + Path(path).write_text(json.dumps(self.to_dict(), indent=2)) + + @staticmethod + def from_dict(d: dict) -> "Project": + return Project( + audio=d["audio"], + recipe=recipe_from_dict(d.get("recipe") or {}), + segments=[Segment(**s) for s in d.get("segments", [])], + fps=int(d.get("fps", 24)), + seconds=d.get("seconds"), + ) + + @staticmethod + def from_json(path: str | Path) -> "Project": + return Project.from_dict(json.loads(Path(path).read_text())) diff --git a/kaika/src/kaika/core/recipe.py b/kaika/src/kaika/core/recipe.py new file mode 100644 index 0000000..6b7de89 --- /dev/null +++ b/kaika/src/kaika/core/recipe.py @@ -0,0 +1,184 @@ +"""Recipe model — the creative lever. + +A recipe is a YAML file that fully defines the visual identity of a render: +audio->fluid mapping, palette, per-section prompts, diffusion parameters. +One track + two recipes = two radically different clips. + +All fields have defaults so a partial YAML is valid; unknown section labels +fall back to ``prompts.default`` and ``base`` is always prefixed. +""" +from __future__ import annotations + +from dataclasses import dataclass, field, asdict, fields, is_dataclass +from pathlib import Path +from typing import Dict, List, get_type_hints, get_origin, get_args + +import os + +import yaml + + +def _find_recipes_dir() -> Path: + """Locate recipes for both editable (repo) and wheel (packaged) installs.""" + env = os.environ.get("KAIKA_RECIPES") + candidates = [Path(env)] if env else [] + pkg = Path(__file__).resolve().parents[1] # .../kaika + candidates += [pkg / "recipes", # packaged (wheel) + Path(__file__).resolve().parents[3] / "recipes"] # repo (dev) + for c in candidates: + if c.is_dir(): + return c + return candidates[-1] + + +RECIPES_DIR = _find_recipes_dir() + + +@dataclass +class Splat: + radius: float = 0.08 + force: float = 6000.0 + placement: str = "scatter" # "anchored" | "scatter" + max_per_beat: int = 4 + lifetime_s: float = 0.5 # how long a spawned source lives, then dies + emit: float = 0.2 # peak dye emission while alive + drift: float = 0.4 # how strongly the source is carried by the flow + speed: float = 1.5 # self-propulsion along its direction (cells/frame) + + +@dataclass +class Vorticity: + min: float = 8.0 + max: float = 38.0 + driver: str = "rms" + + +@dataclass +class FluidConfig: + resolution: int = 256 # simulation grid (square) + render_resolution: int = 512 # output frame size + dissipation: float = 0.90 # density decay: dye clears ~1s after a source dies + velocity_dissipation: float = 0.96 # velocity decay per step (bounds energy) + viscosity: float = 0.0 + lookahead_s: float = 8.0 + splats: Dict[str, Splat] = field(default_factory=lambda: { + "low": Splat(radius=0.10, force=9000.0, placement="anchored", + lifetime_s=0.8, emit=0.22, drift=0.7, speed=1.3), + "high": Splat(radius=0.03, force=3500.0, placement="scatter", + max_per_beat=5, lifetime_s=0.3, emit=0.11, drift=0.3, speed=2.6), + }) + vorticity: Vorticity = field(default_factory=Vorticity) + # Gentle, RMS-driven ambient stirring so calm passages drift and loud ones + # churn (the fluid "stretches" when quiet). Colour is NOT injected here. + ambient_strength: float = 1.6 # curl-noise stirring amplitude (cells/frame) + ambient_scale: float = 2.6 # spatial frequency of the noise + ambient_speed: float = 0.16 # temporal evolution per frame + # Rendering (HDR -> filmic), so the frame is beautiful on its own. + exposure: float = 1.9 + bloom: float = 0.65 + background: float = 0.04 + palette: List[str] = field(default_factory=lambda: [ + "#B84A74", "#34808A", "#E0A458", "#6C4A8C", "#3FA39B", "#D98A5E"]) + + +@dataclass +class DiffusionConfig: + model: str = "wan-2.2-vace" + backend: str = "local" # "local" (no-GPU fallback) | "comfyui" + strength: float = 0.5 + control: List[str] = field(default_factory=lambda: ["depth", "flow"]) + chunk_s: float = 5.0 + overlap_frames: int = 24 + + +@dataclass +class PostConfig: + fps: int = 24 + upscale: bool = False + interpolate: bool = False + aspect: str = "square" # "square" | "wide" + grain: float = 0.0 # 0..1 film grain on the final (fuses artefacts) + vignette: float = 0.0 # 0..1 vignette strength on the final + + +@dataclass +class Recipe: + name: str = "default" + seed: int = 0 + fluid: FluidConfig = field(default_factory=FluidConfig) + diffusion: DiffusionConfig = field(default_factory=DiffusionConfig) + post: PostConfig = field(default_factory=PostConfig) + prompts: Dict[str, str] = field(default_factory=lambda: { + "base": "abstract organic motion, soft light", + "default": "botanical organic forms, abstract motion", + }) + + def prompt_for(self, label: str) -> str: + """Effective prompt for a section: ``base`` is always prefixed; an + unknown label falls back to ``default``.""" + base = self.prompts.get("base", "").strip() + body = self.prompts.get(label) or self.prompts.get("default", "") + return f"{base}, {body}".strip(", ").strip() if base else body + + def to_dict(self) -> dict: + return asdict(self) + + def to_yaml(self, path: str | Path) -> None: + Path(path).write_text(yaml.safe_dump(self.to_dict(), sort_keys=False, + allow_unicode=True)) + + +def _deep_merge(base: dict, over: dict) -> dict: + """Recursively overlay ``over`` onto ``base`` (override wins; None skipped).""" + out = dict(base) + for k, v in (over or {}).items(): + if v is None: + continue + if isinstance(v, dict) and isinstance(out.get(k), dict): + out[k] = _deep_merge(out[k], v) + else: + out[k] = v + return out + + +def _coerce(ftype, val): + """Rebuild nested dataclasses / dicts-of-dataclasses from plain data.""" + if is_dataclass(ftype) and isinstance(val, dict): + return _build(ftype, val) + if get_origin(ftype) is dict and isinstance(val, dict): + args = get_args(ftype) + if len(args) == 2 and is_dataclass(args[1]): + return {k: (_build(args[1], v) if isinstance(v, dict) else v) + for k, v in val.items()} + return val + + +def _build(cls, data: dict): + """Generic dataclass builder: recurse into any nested dataclass field. + + Adding a new nested config requires no change here — type hints drive it. + Expects ``data`` to be a full dict (use ``_deep_merge`` onto defaults first). + """ + hints = get_type_hints(cls) + kwargs = {f.name: _coerce(hints.get(f.name, object), data[f.name]) + for f in fields(cls) if f.name in data and data[f.name] is not None} + return cls(**kwargs) + + +def _merge(default, data): + """Overlay ``data`` onto a default dataclass instance (deep), rebuilding it.""" + return _build(type(default), _deep_merge(asdict(default), data or {})) + + +def from_dict(d: dict) -> Recipe: + return _build(Recipe, _deep_merge(asdict(Recipe()), d or {})) + + +def load_recipe(name_or_path: str | Path) -> Recipe: + """Load a recipe by file path or by bare name (looked up in ``recipes/``).""" + p = Path(name_or_path) + if not p.exists() and p.suffix == "": + p = RECIPES_DIR / f"{name_or_path}.yaml" + if not p.exists(): + raise FileNotFoundError(f"recipe not found: {name_or_path}") + return from_dict(yaml.safe_load(p.read_text())) diff --git a/kaika/src/kaika/core/score.py b/kaika/src/kaika/core/score.py new file mode 100644 index 0000000..e1e0724 --- /dev/null +++ b/kaika/src/kaika/core/score.py @@ -0,0 +1,79 @@ +"""Score data model — the machine-readable "partition" produced by E1. + +A Score holds everything the downstream stages need to know about a track, +with one ``FrameData`` row per *video* frame so no interpolation is required. +It is a plain dataclass tree that serialises to/from ``score.json``. +""" +from __future__ import annotations + +import json +from dataclasses import dataclass, field, asdict +from pathlib import Path +from typing import List, Dict + + +@dataclass +class AudioInfo: + sr: int + duration_s: float + fps: int + hop_length: int + + +@dataclass +class Event: + """A point event on the timeline (beat or onset).""" + t: float # time in seconds + mag: float # magnitude / strength, normalised 0..1 + + +@dataclass +class FrameData: + """One row per video frame, frame-aligned with the simulation.""" + rms: float # 0..1 normalised loudness + centroid_hz: float # spectral centroid in Hz + bands: List[float] # [low, mid, high], per-frame energy split summing ~1 + + +@dataclass +class Section: + start: float + end: float + label: str + energy: float # 0..1 mean energy of the section + + +@dataclass +class Score: + audio: AudioInfo + tempo_bpm: float + beats: List[Event] = field(default_factory=list) + onsets: Dict[str, List[Event]] = field(default_factory=dict) # keys: low/mid/high + frames: List[FrameData] = field(default_factory=list) + sections: List[Section] = field(default_factory=list) + + # ---- (de)serialisation ------------------------------------------------- + def to_dict(self) -> dict: + return asdict(self) + + def to_json(self, path: str | Path) -> None: + Path(path).write_text(json.dumps(self.to_dict(), indent=2)) + + @property + def n_frames(self) -> int: + return len(self.frames) + + @staticmethod + def from_dict(d: dict) -> "Score": + return Score( + audio=AudioInfo(**d["audio"]), + tempo_bpm=d["tempo_bpm"], + beats=[Event(**e) for e in d.get("beats", [])], + onsets={k: [Event(**e) for e in v] for k, v in d.get("onsets", {}).items()}, + frames=[FrameData(**f) for f in d.get("frames", [])], + sections=[Section(**s) for s in d.get("sections", [])], + ) + + @staticmethod + def from_json(path: str | Path) -> "Score": + return Score.from_dict(json.loads(Path(path).read_text())) diff --git a/kaika/src/kaika/core/simulate.py b/kaika/src/kaika/core/simulate.py new file mode 100644 index 0000000..ef47919 --- /dev/null +++ b/kaika/src/kaika/core/simulate.py @@ -0,0 +1,447 @@ +"""E2 — Fluid simulation. score + recipe -> fluid frames + velocity fields. + +A Jos-Stam style stable-fluids solver (incompressible Navier-Stokes) in pure +NumPy with toroidal boundaries: exact FFT pressure projection, MacCormack +advection, fully deterministic (seed -> identical video), runs with no GPU. + +The simulation is the *movement skeleton* and is alive at all times, not just on +onsets: a continuous curl-noise field stirs the fluid (scaled by loudness), +persistent dye emitters keep colour flowing, and audio events are accents on top +-- kicks inject anchored splats, hats pop everywhere, RMS drives vorticity, and +an upcoming drop is anticipated by sub-visible vortices (lookahead). Frames are +rendered HDR -> filmic tone-map + bloom over a dark field from a recipe palette. + +Outputs, per run dir: + fluid/%06d.png RGB density frame (render_resolution) + velocity/%06d.npy (H, W, 2) float32 velocity field (sim resolution) + fluid_stats.json per-frame kinetic energy + density (for E5 sync check) +""" +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from types import SimpleNamespace +from typing import Callable, List, Optional + +import numpy as np +import cv2 + +from .score import Score +from .recipe import Recipe, FluidConfig + +ProgressFn = Callable[[int, int], None] + +# Maps recipe splat "force" to a sane velocity in cells/frame (avoids CFL blowup). +FORCE_K = 0.04 +# Scales recipe vorticity into the velocity regime; confinement is a positive +# feedback, so this must keep the per-step force a small fraction of velocity. +VORT_K = 0.015 +# Forcing / emission tuning (named so they are easy to find and adjust). +AMBIENT_FLOOR = 0.12 # ambient stir at silence, as a fraction of strength +KICK_JITTER = 0.09 # spread of kick spawn positions around the anchor +KICK_ANGLE_JITTER = 0.5 # radians of randomness on a kick's outward heading +WANDER_AMP = 0.16 # how far the kick anchor (centre of gravity) wanders +SOURCE_DECAY = 1.3 # emission envelope exponent (impulsive: peak at birth) +SOURCE_EXPAND = 0.8 # how much a source's radius grows over its life +JET_FRACTION = 0.35 # ongoing directional jet strength vs the initial impulse +BLOOM_THRESHOLD = 0.45 # luminance above which bloom is added + + +def _hex_to_rgb(h: str) -> np.ndarray: + h = h.lstrip("#") + return np.array([int(h[i:i + 2], 16) / 255.0 for i in (0, 2, 4)], np.float32) + + +def _centroid_brightness(centroid_hz: float) -> float: + """Spectral centroid -> brightness multiplier (dark lows, bright highs).""" + lo, hi = np.log10(150.0), np.log10(8000.0) + x = (np.log10(max(centroid_hz, 150.0)) - lo) / (hi - lo) + return 0.75 + 0.5 * float(np.clip(x, 0.0, 1.0)) + + +def _tonemap(hdr: np.ndarray, exposure: float) -> np.ndarray: + """HDR density -> filmic LDR: exponential exposure + mild gamma.""" + mapped = 1.0 - np.exp(-exposure * np.clip(hdr, 0.0, None)) + return np.clip(mapped, 0.0, 1.0) ** (1.0 / 1.15) + + +def _curl_noise(gx: np.ndarray, gy: np.ndarray, t: float, scale: float): + """Divergence-free ambient velocity from the curl of a moving potential.""" + sx = gx * 2 * np.pi * scale + sy = gy * 2 * np.pi * scale + psi = (np.sin(sx + t) * np.cos(sy * 1.3 - 0.7 * t) + + 0.6 * np.sin(sx * 0.7 - 1.1 * t) * np.sin(sy * 1.7 + 0.5 * t) + + 0.4 * np.sin(sx * 1.9 + 0.3 * t) * np.cos(sy * 0.9 - 0.6 * t)) + u = (np.roll(psi, -1, 0) - np.roll(psi, 1, 0)) * 0.5 # dpsi/dy + v = -(np.roll(psi, -1, 1) - np.roll(psi, 1, 1)) * 0.5 # -dpsi/dx + peak = float(np.sqrt(u * u + v * v).max()) + 1e-6 # normalise to unit speed + return (u / peak).astype(np.float32), (v / peak).astype(np.float32) + + +def _advance_sources(sim: "FluidSim", sources: List["_Source"], n: int) -> List["_Source"]: + """Emit + propel every living source one step; return the survivors. + + Each source streams matter ALONG its heading (dye + a directional jet, not an + isotropic blob), then self-propels and is carried by the flow, then ages out. + """ + still: List[_Source] = [] + for s in sources: + frac = s.age / s.life + env = (1.0 - frac) ** SOURCE_DECAY # impulsive: peak at birth -> 0 + r_now = s.radius * (1.0 + SOURCE_EXPAND * frac) + sim.add_dye(s.x, s.y, r_now, s.color, s.emit * env) + sim.add_force_at(s.x, s.y, r_now, s.dx * s.jet * env, s.dy * s.jet * env) + xi = int(np.clip(s.x * n, 0, n - 1)) + yi = int(np.clip(s.y * n, 0, n - 1)) + s.x = (s.x + (s.speed * s.dx + s.drift * float(sim.u[yi, xi])) / n) % 1.0 + s.y = (s.y + (s.speed * s.dy + s.drift * float(sim.v[yi, xi])) / n) % 1.0 + s.age += 1 + if s.age < s.life: + still.append(s) + return still + + +def _render_frame(density: np.ndarray, exposure: float, bloom: float, + bloom_sigma: float, background: float) -> np.ndarray: + """HDR density -> filmic tone-map + bloom over a dark field -> uint8 RGB.""" + ldr = _tonemap(density, exposure) + if bloom > 0: + bright = np.clip(ldr - BLOOM_THRESHOLD, 0.0, 1.0) + ldr = ldr + bloom * cv2.GaussianBlur(bright, (0, 0), sigmaX=bloom_sigma) + out = background + (1.0 - background) * np.clip(ldr, 0.0, 1.0) + return (np.clip(out, 0.0, 1.0) * 255).astype(np.uint8) + + +@dataclass +class _Source: + """A transient, directional dye source: born on a musical event, it streams + matter along its heading (jet + self-propulsion), then fades and dies.""" + x: float + y: float + color: np.ndarray + radius: float + emit: float + life: int + drift: float + dx: float + dy: float + speed: float + jet: float + age: int = 0 + + +@dataclass +class SimResult: + fluid_dir: Path + velocity_dir: Path + stats_path: Path + n_frames: int + resolution: int + + +class FluidSim: + """Toroidal stable-fluids solver on an NxN grid.""" + + def __init__(self, n: int, dissipation: float, viscosity: float, seed: int, + vel_dissipation: float = 0.96): + self.n = n + self.dissipation = dissipation + self.vel_dissipation = vel_dissipation + self.viscosity = viscosity + self.rng = np.random.default_rng(seed) + self.u = np.zeros((n, n), np.float32) + self.v = np.zeros((n, n), np.float32) + self.density = np.zeros((n, n, 3), np.float32) + ys, xs = np.mgrid[0:n, 0:n] + self.xs = xs.astype(np.float32) + self.ys = ys.astype(np.float32) + # Eigenvalues of the 5-point Poisson operator for the exact FFT solve. + i = np.arange(n) + a = (4.0 - 2 * np.cos(2 * np.pi * i / n)[:, None] + - 2 * np.cos(2 * np.pi * i / n)[None, :]) + a[0, 0] = 1.0 # guard DC; mean pressure is gauge-free + self._poisson = a + self._k3 = np.ones((3, 3), np.uint8) + + # ---- operators --------------------------------------------------------- + def _advect(self, field: np.ndarray, u: np.ndarray, v: np.ndarray, + dt: float) -> np.ndarray: + """MacCormack advection (bilinear semi-Lagrangian + error correction). + + Two bilinear backtraces with a corrector pass remove most numerical + diffusion, keeping crisp filaments; bilinear is max-principle stable + (no cubic overshoot) and a min/max limiter clamps the corrector. + Handles 1- or multi-channel fields in a single ``cv2.remap`` call. + """ + mx = (self.xs - dt * u).astype(np.float32) + my = (self.ys - dt * v).astype(np.float32) + fwd = cv2.remap(field, mx, my, cv2.INTER_LINEAR, borderMode=cv2.BORDER_WRAP) + bx = (self.xs + dt * u).astype(np.float32) + by = (self.ys + dt * v).astype(np.float32) + back = cv2.remap(fwd, bx, by, cv2.INTER_LINEAR, borderMode=cv2.BORDER_WRAP) + corrected = fwd + 0.5 * (field - back) + hi = cv2.dilate(fwd, self._k3) + lo = cv2.erode(fwd, self._k3) + return np.clip(corrected, lo, hi).astype(np.float32) + + def _project(self, iters: int = 0) -> None: + """Exact incompressibility via a spectral Poisson solve (periodic grid). + + Solves the same discrete system the old Jacobi loop approximated, but in + one FFT pair — divergence drops to ~machine epsilon, so vortices rotate + and persist instead of diffusing. ``iters`` kept for API compatibility. + """ + # Forward-difference divergence; backward-difference gradient (adjoint + # pair) so D∘G is the standard 5-point Laplacian -> exact, no checkerboard. + div = ((np.roll(self.u, -1, 1) - self.u) + + (np.roll(self.v, -1, 0) - self.v)) + p_hat = -np.fft.fft2(div) / self._poisson # L = -poisson eigenvalues + p_hat[0, 0] = 0.0 + p = np.real(np.fft.ifft2(p_hat)).astype(np.float32) + self.u -= (p - np.roll(p, 1, 1)).astype(np.float32) + self.v -= (p - np.roll(p, 1, 0)).astype(np.float32) + + def _vorticity_confine(self, eps: float, dt: float) -> None: + if eps <= 0: + return + curl = ((np.roll(self.v, -1, 1) - np.roll(self.v, 1, 1)) - + (np.roll(self.u, -1, 0) - np.roll(self.u, 1, 0))) * 0.5 + absc = np.abs(curl) + gx = (np.roll(absc, -1, 1) - np.roll(absc, 1, 1)) * 0.5 + gy = (np.roll(absc, -1, 0) - np.roll(absc, 1, 0)) * 0.5 + norm = np.sqrt(gx * gx + gy * gy) + 1e-5 + gx, gy = gx / norm, gy / norm + self.u += eps * dt * (gy * curl) + self.v += eps * dt * (-gx * curl) + + def add_force(self, fu: np.ndarray, fv: np.ndarray) -> None: + self.u += fu.astype(np.float32) + self.v += fv.astype(np.float32) + + def add_dye(self, px: float, py: float, radius: float, + color: np.ndarray, amount: float) -> None: + """Inject coloured dye (no velocity) — used by continuous emitters.""" + n = self.n + r = max(1.0, radius * n) + d2 = (self.xs - px * n) ** 2 + (self.ys - py * n) ** 2 + g = np.exp(-d2 / (2 * r * r)).astype(np.float32) + self.density += (amount * g)[..., None] * color[None, None, :] + + def add_force_at(self, x: float, y: float, radius: float, + fx: float, fy: float) -> None: + """Inject a localised directional velocity (no dye) — drives jets.""" + n = self.n + r = max(1.0, radius * n) + d2 = (self.xs - x * n) ** 2 + (self.ys - y * n) ** 2 + g = np.exp(-d2 / (2 * r * r)).astype(np.float32) + self.u += (g * fx).astype(np.float32) + self.v += (g * fy).astype(np.float32) + + def add_splat(self, px: float, py: float, radius: float, force: float, + color: np.ndarray, dir_angle: float) -> None: + """Inject a Gaussian blob of velocity + colour at normalised (px, py).""" + n = self.n + cx, cy = px * n, py * n + r = max(1.0, radius * n) + d2 = (self.xs - cx) ** 2 + (self.ys - cy) ** 2 + g = np.exp(-d2 / (2 * r * r)).astype(np.float32) + vel = g * force * FORCE_K / n + self.u += (vel * np.cos(dir_angle)).astype(np.float32) + self.v += (vel * np.sin(dir_angle)).astype(np.float32) + self.density += g[..., None] * color[None, None, :] + + def step(self, dt: float, vort_eps: float) -> None: + if self.viscosity > 0: + k = self.viscosity + self.u = (self.u + k * (np.roll(self.u, 1, 0) + np.roll(self.u, -1, 0) + + np.roll(self.u, 1, 1) + np.roll(self.u, -1, 1))) / (1 + 4 * k) + self.v = (self.v + k * (np.roll(self.v, 1, 0) + np.roll(self.v, -1, 0) + + np.roll(self.v, 1, 1) + np.roll(self.v, -1, 1))) / (1 + 4 * k) + self._vorticity_confine(vort_eps, dt) + self._project() + # Self-advect velocity (backtrace uses the pre-advection field), reproject. + u0, v0 = self.u.copy(), self.v.copy() + vel = np.stack([self.u, self.v], axis=-1).astype(np.float32) + vel = self._advect(vel, u0, v0, dt) + self.u = np.ascontiguousarray(vel[..., 0]) + self.v = np.ascontiguousarray(vel[..., 1]) + self._project() + self.density = self._advect(self.density, self.u, self.v, dt) + self.density *= self.dissipation + np.clip(self.density, 0.0, 12.0, out=self.density) + # Damp velocity so continuous ambient forcing reaches a steady state + # instead of accumulating without bound. + self.u *= self.vel_dissipation + self.v *= self.vel_dissipation + + def kinetic_energy(self) -> float: + return float(np.mean(self.u ** 2 + self.v ** 2)) + + def total_density(self) -> float: + return float(np.mean(self.density)) + + +def _build_event_index(events, fps: int, n_frames: int) -> List[list]: + by_frame: List[list] = [[] for _ in range(n_frames)] + for e in events: + fi = int(round(e.t * fps)) + if 0 <= fi < n_frames: + by_frame[fi].append(e) + return by_frame + + +def _lookahead_boost(score: Score, frame_i: int, fps: int, lookahead_s: float) -> float: + """0..1 tension ramp in the ``lookahead_s`` window before a drop section.""" + if lookahead_s <= 0: + return 0.0 + t = frame_i / fps + boost = 0.0 + for s in score.sections: + if s.label == "drop" and 0 <= (s.start - t) <= lookahead_s: + boost = max(boost, 1.0 - (s.start - t) / lookahead_s) + return boost + + +def simulate(score: Score, recipe: Recipe, out_dir: str | Path, + max_frames: Optional[int] = None, + progress: Optional[ProgressFn] = None, + frame_configs: Optional[List[FluidConfig]] = None, + render_range: Optional[tuple] = None, + warmup_frames: int = 0, + write_velocity: bool = True) -> SimResult: + """Run the fluid sim. If ``frame_configs`` is given (one FluidConfig per + frame), the *non-structural* parameters vary per frame — this is how a single + continuous simulation takes different parameters per musical segment. + + ``render_range=(f0, f1)`` simulates only that window (plus ``warmup_frames`` + of unrendered lead-in so the state converges to what the full run would + hold — dye and velocity memory are short), writing frames renumbered from 0. + ``write_velocity=False`` skips the per-frame .npy dumps (previews don't need + them; only E3/E4 do). + """ + import imageio.v2 as imageio + + out_dir = Path(out_dir) + fluid_dir = out_dir / "fluid" + vel_dir = out_dir / "velocity" + fluid_dir.mkdir(parents=True, exist_ok=True) + if write_velocity: + vel_dir.mkdir(parents=True, exist_ok=True) + + fps = score.audio.fps + n_frames = score.n_frames if max_frames is None else min(score.n_frames, max_frames) + if render_range is not None: + render_start = max(0, int(render_range[0])) + sim_end = min(n_frames, int(render_range[1])) + sim_start = max(0, render_start - max(0, warmup_frames)) + else: + render_start, sim_start, sim_end = 0, 0, n_frames + base_fc = recipe.fluid # structural params (resolution) come from here + sim = FluidSim(base_fc.resolution, base_fc.dissipation, base_fc.viscosity, + recipe.seed, vel_dissipation=base_fc.velocity_dissipation) + + low_by_frame = _build_event_index(score.onsets.get("low", []), fps, sim_end) + high_by_frame = _build_event_index(score.onsets.get("high", []), fps, sim_end) + + rng = np.random.default_rng(recipe.seed + 1) + gx = sim.xs / base_fc.resolution + gy = sim.ys / base_fc.resolution + anchor = np.array([0.5, 0.5]) # centre of gravity for kicks + dt = 1.0 + t_phase = 0.0 # continuous ambient phase across segments + hat_counter = 0 # orderly palette cycling for hats + stats = {"kinetic_energy": [], "total_density": []} + sources: List[_Source] = [] + + def spawn(x, y, color, cfg, mag, angle): + """Birth a directional source: an initial jet along ``angle`` + ongoing + directional emission, so matter streams away instead of pooling.""" + dx, dy = float(np.cos(angle)), float(np.sin(angle)) + impulse = cfg.force * FORCE_K / n * (0.5 + mag) + sim.add_force_at(x, y, cfg.radius, dx * impulse, dy * impulse) + sources.append(_Source(x=x, y=y, color=color, radius=cfg.radius, + emit=cfg.emit * (0.5 + mag), + life=max(1, int(cfg.lifetime_s * fps)), + drift=cfg.drift, dx=dx, dy=dy, speed=cfg.speed, + jet=JET_FRACTION * impulse)) + + render_res = base_fc.render_resolution + bloom_sigma = max(1.0, base_fc.resolution / 48) + n = base_fc.resolution + total_steps = sim_end - sim_start + for step, i in enumerate(range(sim_start, sim_end)): + # Per-frame (per-segment) parameters; structural params stay from base_fc. + fc = frame_configs[i] if frame_configs is not None else base_fc + sim.dissipation = fc.dissipation + sim.vel_dissipation = fc.velocity_dissipation + palette = [_hex_to_rgb(c) for c in (fc.palette or ["#B84A74"])] + low_cfg = fc.splats.get("low") + high_cfg = fc.splats.get("high") + fdata = score.frames[i] + rms = fdata.rms + t = t_phase + t_phase += fc.ambient_speed + + # Ambient stirring carries existing dye — RMS-driven, no colour injected. + ua, va = _curl_noise(gx, gy, t, fc.ambient_scale) + amp = fc.ambient_strength * (AMBIENT_FLOOR + (1.0 - AMBIENT_FLOOR) * rms) + sim.add_force(ua * amp, va * amp) + + # Kicks: jets radiating OUTWARD from a slowly-wandering centre of gravity, + # so matter streams away and disperses instead of pooling into a blob. + wc = anchor + WANDER_AMP * np.array([np.sin(i * 0.045), np.cos(i * 0.037)]) + if low_cfg: + for e in low_by_frame[i]: + px, py = np.clip(wc + rng.normal(0, KICK_JITTER, 2), 0.05, 0.95) + ang = np.arctan2(py - wc[1], px - wc[0]) + rng.normal(0, KICK_ANGLE_JITTER) + spawn(float(px), float(py), palette[0], low_cfg, e.mag, float(ang)) + # Hats: small fast darts in random directions, popping everywhere. + # Colour is intentional: kicks own palette[0]; hats cycle the rest in + # order, brightness following the spectral centroid (dark lows, bright highs). + if high_cfg: + bright = _centroid_brightness(fdata.centroid_hz) + hat_colors = palette[1:] or palette + for e in high_by_frame[i][: high_cfg.max_per_beat]: + px, py = rng.uniform(0.08, 0.92, 2) + color = hat_colors[hat_counter % len(hat_colors)] * bright + hat_counter += 1 + spawn(float(px), float(py), color, high_cfg, e.mag, + float(rng.uniform(0, 2 * np.pi))) + + # Lookahead: faint drifting sources before a drop, building tension early. + boost = _lookahead_boost(score, i, fps, fc.lookahead_s) + if boost > 0 and i % 3 == 0: + px, py = rng.uniform(0.2, 0.8, 2) + la = SimpleNamespace(radius=0.08, force=1500.0 * boost, emit=0.10 * boost, + lifetime_s=0.7, drift=0.6, speed=0.8) + spawn(float(px), float(py), palette[0] * 0.6, la, 1.0, + float(rng.uniform(0, 2 * np.pi))) + + sources = _advance_sources(sim, sources, n) + + # RMS drives vorticity between recipe min/max (scaled to the velocity regime). + vmin, vmax = fc.vorticity.min, fc.vorticity.max + vort = (vmin + (vmax - vmin) * rms) * VORT_K + sim.step(dt, vort) + + # Render only the requested window (warmup frames advance state silently). + if i >= render_start: + frame = _render_frame(sim.density, fc.exposure, fc.bloom, bloom_sigma, + fc.background) + if render_res != base_fc.resolution: + frame = cv2.resize(frame, (render_res, render_res), + interpolation=cv2.INTER_LINEAR) + imageio.imwrite(fluid_dir / f"{i - render_start:06d}.png", frame) + if write_velocity: + np.save(vel_dir / f"{i - render_start:06d}.npy", + np.stack([sim.u, sim.v], axis=-1).astype(np.float32)) + stats["kinetic_energy"].append(round(sim.kinetic_energy(), 6)) + stats["total_density"].append(round(sim.total_density(), 6)) + if progress: + progress(step + 1, total_steps) + + stats_path = out_dir / "fluid_stats.json" + stats_path.write_text(json.dumps(stats)) + return SimResult(fluid_dir=fluid_dir, velocity_dir=vel_dir, stats_path=stats_path, + n_frames=sim_end - render_start, resolution=render_res) diff --git a/kaika/src/kaika/server/__init__.py b/kaika/src/kaika/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kaika/src/kaika/server/app.py b/kaika/src/kaika/server/app.py new file mode 100644 index 0000000..7d00f6f --- /dev/null +++ b/kaika/src/kaika/server/app.py @@ -0,0 +1,370 @@ +"""FastAPI app: API + WebSocket progress + static frontend. + +The UI and the CLI call the very same core library. Nothing the UI shows is +hidden state — runs live on disk under ``runs/`` and are served straight from +there. +""" +from __future__ import annotations + +import asyncio +import json +import shutil +import uuid +from pathlib import Path +from typing import Optional + +import numpy as np +from fastapi import FastAPI, UploadFile, File, HTTPException, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, JSONResponse, HTMLResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel + +from ..core import recipe as R +from ..core.analyze import analyze +from ..core.project import Project +from ..core.score import Score +from ..core.pipeline import (load_run, list_runs, run_pipeline, run_fluid, + run_diffuse, run_segment_preview, init_project_run, + frozen_audio) +from .db import JobDB +from .jobs import JobManager + +WEBAPP_DIST = Path(__file__).resolve().parents[1] / "webapp_dist" + + +class RunRequest(BaseModel): + audio_id: str + recipe_name: Optional[str] = None + recipe: Optional[dict] = None + seconds: Optional[float] = None + + +class ProjectRequest(BaseModel): + audio_id: str + recipe_name: Optional[str] = None + recipe: Optional[dict] = None + seconds: Optional[float] = None + + +class ProjectUpdate(BaseModel): + segments: Optional[list] = None + recipe: Optional[dict] = None + seconds: Optional[float] = None + + +class PreviewRequest(BaseModel): + draft: bool = False + + +class SegmentPreviewRequest(BaseModel): + index: int + draft: bool = True + + +def _waveform_peaks(y: np.ndarray, buckets: int = 800) -> list: + if len(y) == 0: + return [] + n = min(buckets, len(y)) + edges = np.linspace(0, len(y), n + 1).astype(int) + return [round(float(np.abs(y[a:b]).max()) if b > a else 0.0, 4) + for a, b in zip(edges[:-1], edges[1:])] + + +def create_app(runs_root: str | Path = "runs", + data_dir: str | Path | None = None) -> FastAPI: + runs_root = Path(runs_root) + data_dir = Path(data_dir) if data_dir else runs_root.parent / ".kaika" + uploads = data_dir / "uploads" + uploads.mkdir(parents=True, exist_ok=True) + runs_root.mkdir(parents=True, exist_ok=True) + + db = JobDB(data_dir / "kaika.db") + jm = JobManager(runs_root, db) + + app = FastAPI(title="Kaika") + app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], + allow_headers=["*"]) + app.state.jm = jm + app.state.runs_root = runs_root + app.state.uploads = uploads + + # ---- recipes ---------------------------------------------------------- + @app.get("/api/recipes") + def recipes(): + out = [] + for p in sorted(R.RECIPES_DIR.glob("*.yaml")): + try: + out.append({"name": p.stem, "yaml": p.read_text(), + "recipe": R.load_recipe(p).to_dict()}) + except Exception: # noqa + pass + return out + + # ---- upload + analyze (Studio) ---------------------------------------- + @app.post("/api/upload") + async def upload(file: UploadFile = File(...)): + suffix = Path(file.filename or "audio.wav").suffix or ".wav" + audio_id = uuid.uuid4().hex[:12] + dest = uploads / f"{audio_id}{suffix}" + with dest.open("wb") as f: + shutil.copyfileobj(file.file, f) + return {"audio_id": audio_id, "name": file.filename} + + def _resolve_audio(audio_id: str) -> Path: + hits = list(uploads.glob(f"{audio_id}.*")) + if not hits: + raise HTTPException(404, f"audio {audio_id} not found") + return hits[0] + + @app.post("/api/analyze") + def analyze_audio(audio_id: str, fps: int = 24): + import librosa + path = _resolve_audio(audio_id) + score = analyze(path, fps=fps) + y, sr = librosa.load(str(path), sr=None, mono=True) + return { + "audio_id": audio_id, "tempo_bpm": score.tempo_bpm, + "duration_s": score.audio.duration_s, "fps": fps, + "n_frames": score.n_frames, + "sections": [s.__dict__ for s in score.sections], + "beats": [b.__dict__ for b in score.beats], + "onset_counts": {k: len(v) for k, v in score.onsets.items()}, + "waveform": _waveform_peaks(y), + } + + # ---- runs (jobs) ------------------------------------------------------ + def _recipe_from(req) -> R.Recipe: + if req.recipe is not None: + return R.from_dict(req.recipe) + return R.load_recipe(req.recipe_name or "eclosion") + + @app.post("/api/runs") + def start_run(req: RunRequest): + path = _resolve_audio(req.audio_id) + rec = _recipe_from(req) + job_id = jm.submit( + lambda progress: run_pipeline(path, rec, runs_root=runs_root, + seconds=req.seconds, progress=progress), + kind="render") + return {"job_id": job_id} + + # ---- projects (segment editor) ---------------------------------------- + def _analysis_payload(rd: Path) -> Optional[dict]: + """Persistent analysis view for the editor: score data + cached waveform.""" + score_path = rd / "score.json" + if not score_path.exists(): + return None + score = Score.from_json(score_path) + wf_path = rd / "waveform.json" + if wf_path.exists(): + waveform = json.loads(wf_path.read_text()) + else: + import librosa + audio = frozen_audio(rd) + if audio is None: + waveform = [] + else: + y, _sr = librosa.load(str(audio), sr=None, mono=True) + waveform = _waveform_peaks(y) + wf_path.write_text(json.dumps(waveform)) + return { + "tempo_bpm": score.tempo_bpm, "duration_s": score.audio.duration_s, + "fps": score.audio.fps, "n_frames": score.n_frames, + "beats": [b.__dict__ for b in score.beats], + "onsets": {k: [round(e.t, 3) for e in v] for k, v in score.onsets.items()}, + "onset_counts": {k: len(v) for k, v in score.onsets.items()}, + "waveform": waveform, + } + + def _project_payload(run_id: str, with_analysis: bool = False) -> dict: + rd = runs_root / run_id + if not (rd / "project.json").exists(): + raise HTTPException(404, "project not found") + audio = frozen_audio(rd) + payload = { + "run_id": run_id, + "project": Project.from_json(rd / "project.json").to_dict(), + "manifest": load_run(rd), + "audio_url": f"/api/runs/{run_id}/files/{audio.name}" if audio else None, + } + if with_analysis: + payload["analysis"] = _analysis_payload(rd) + return payload + + @app.post("/api/projects") + def create_project(req: ProjectRequest): + path = _resolve_audio(req.audio_id) + rec = _recipe_from(req) + run_dir, project, score = init_project_run(path, rec, runs_root=runs_root, + seconds=req.seconds) + return _project_payload(run_dir.name, with_analysis=True) + + @app.get("/api/projects/{run_id}") + def get_project(run_id: str): + return _project_payload(run_id, with_analysis=True) + + @app.put("/api/projects/{run_id}") + def update_project(run_id: str, upd: ProjectUpdate): + rd = runs_root / run_id + if not (rd / "project.json").exists(): + raise HTTPException(404, "project not found") + proj = Project.from_json(rd / "project.json") + if upd.recipe is not None: + proj.recipe = R.from_dict(upd.recipe) + if upd.seconds is not None: + proj.seconds = upd.seconds + if upd.segments is not None: + from ..core.project import Segment + proj.segments = [Segment(**s) for s in upd.segments] + proj.to_json(rd / "project.json") + return _project_payload(run_id) + + @app.post("/api/projects/{run_id}/preview") + def preview_project(run_id: str, req: PreviewRequest = PreviewRequest()): + rd = runs_root / run_id + if not (rd / "project.json").exists(): + raise HTTPException(404, "project not found") + + def task(progress): + proj = Project.from_json(rd / "project.json") + score = Score.from_json(rd / "score.json") + audio = frozen_audio(rd) + if audio is None: + raise FileNotFoundError("frozen audio missing for project") + return run_fluid(proj, audio, runs_root=runs_root, run_id=run_id, + score=score, draft=req.draft, progress=progress) + + return {"job_id": jm.submit(task, run_id=run_id, kind="fluid")} + + @app.post("/api/projects/{run_id}/preview_segment") + def preview_segment(run_id: str, req: SegmentPreviewRequest): + rd = runs_root / run_id + if not (rd / "project.json").exists(): + raise HTTPException(404, "project not found") + n_segs = len(Project.from_json(rd / "project.json").segments) + if not (0 <= req.index < n_segs): + raise HTTPException(400, f"segment index {req.index} out of range") + return {"job_id": jm.submit( + lambda progress: run_segment_preview(rd, req.index, draft=req.draft, + progress=progress), + run_id=run_id, kind="fluid_segment")} + + @app.post("/api/projects/{run_id}/generate") + def generate_project(run_id: str): + rd = runs_root / run_id + if not (rd / "project.json").exists(): + raise HTTPException(404, "project not found") + # run_diffuse rebuilds missing/draft fluid itself, so this always works. + return {"job_id": jm.submit(lambda progress: run_diffuse(rd, progress=progress), + run_id=run_id, kind="diffuse")} + + @app.get("/api/jobs/{job_id}") + def job_status(job_id: str): + j = jm.get(job_id) + if not j: + raise HTTPException(404, "job not found") + return j + + @app.get("/api/jobs") + def jobs(): + return db.all() + + @app.post("/api/jobs/{job_id}/cancel") + def cancel_job(job_id: str): + if not jm.cancel(job_id): + raise HTTPException(409, "job not cancellable (unknown or finished)") + return {"ok": True} + + @app.get("/api/runs/{run_id}/latest_frame") + def latest_frame(run_id: str): + """Most recent frame on disk for this run — live peek while rendering.""" + rd = runs_root / run_id + candidates = [] + for sub in ("styled", "fluid", "seg_preview/fluid"): + d = rd / sub + if d.is_dir(): + pngs = list(d.glob("*.png")) + if pngs: + candidates.append(max(pngs, key=lambda p: p.stat().st_mtime)) + if not candidates: + raise HTTPException(404, "no frames yet") + newest = max(candidates, key=lambda p: p.stat().st_mtime) + return FileResponse(newest, media_type="image/png", + headers={"Cache-Control": "no-store"}) + + @app.websocket("/ws/jobs/{job_id}") + async def ws_job(ws: WebSocket, job_id: str): + await ws.accept() + last = None + try: + while True: + j = jm.get(job_id) + if j is None: + await ws.send_json({"error": "job not found"}) + break + snapshot = (j["status"], j["stage"], j["done"], j["total"]) + if snapshot != last: + await ws.send_json(j) + last = snapshot + if j["status"] in ("done", "error"): + break + await asyncio.sleep(0.15) + except WebSocketDisconnect: + return + + @app.get("/api/runs") + def runs(): + return list_runs(runs_root) + + @app.get("/api/runs/{run_id}") + def run_detail(run_id: str): + rd = runs_root / run_id + if not (rd / "run.json").exists(): + raise HTTPException(404, "run not found") + return load_run(rd) + + @app.get("/api/runs/{run_id}/final") + def run_final(run_id: str): + m = run_detail(run_id) + final = runs_root / run_id / (m.get("final") or "kaika_final.mp4") + if not final.exists(): + raise HTTPException(404, "final not rendered") + return FileResponse(final, media_type="video/mp4") + + @app.get("/api/runs/{run_id}/files/{subpath:path}") + def run_file(run_id: str, subpath: str): + rd = (runs_root / run_id).resolve() + target = (rd / subpath).resolve() + try: + target.relative_to(rd) # strict subpath, prefix-safe + except ValueError: + raise HTTPException(404, "file not found") + if not target.is_file(): + raise HTTPException(404, "file not found") + return FileResponse(target) + + # ---- static frontend -------------------------------------------------- + if (WEBAPP_DIST / "index.html").exists(): + app.mount("/", StaticFiles(directory=str(WEBAPP_DIST), html=True), name="web") + else: + @app.get("/") + def placeholder(): + return HTMLResponse( + "

Kaika

Frontend not built. Run " + "npm --prefix webapp install && npm --prefix webapp run build" + ", then restart. API is live under /api.

") + + return app + + +def serve(host: str = "127.0.0.1", port: int = 8400, runs_root="runs", + open_browser: bool = True) -> None: + import uvicorn + app = create_app(runs_root) + if open_browser: + import threading, webbrowser, time + threading.Thread( + target=lambda: (time.sleep(1.0), webbrowser.open(f"http://{host}:{port}")), + daemon=True).start() + uvicorn.run(app, host=host, port=port, log_level="info") diff --git a/kaika/src/kaika/server/db.py b/kaika/src/kaika/server/db.py new file mode 100644 index 0000000..43e3f08 --- /dev/null +++ b/kaika/src/kaika/server/db.py @@ -0,0 +1,64 @@ +"""Tiny SQLite store for the job queue/history. + +Runs themselves live on disk (``runs//run.json`` is the source of truth); +this table just tracks job lifecycle so history survives a restart. +""" +from __future__ import annotations + +import sqlite3 +import threading +import time +from pathlib import Path +from typing import List, Optional + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS jobs ( + id TEXT PRIMARY KEY, + run_id TEXT, + status TEXT NOT NULL, + recipe TEXT, + audio TEXT, + created REAL NOT NULL, + error TEXT +); +""" + + +class JobDB: + def __init__(self, path: str | Path): + self.path = str(path) + Path(self.path).parent.mkdir(parents=True, exist_ok=True) + self._lock = threading.Lock() + self._conn = sqlite3.connect(self.path, check_same_thread=False) + self._conn.row_factory = sqlite3.Row + self._conn.executescript(SCHEMA) + self._conn.commit() + + def create(self, job_id: str, recipe: str, audio: str) -> None: + with self._lock: + self._conn.execute( + "INSERT OR REPLACE INTO jobs(id, status, recipe, audio, created) " + "VALUES (?,?,?,?,?)", + (job_id, "queued", recipe, audio, time.time())) + self._conn.commit() + + def update(self, job_id: str, **fields) -> None: + if not fields: + return + cols = ", ".join(f"{k}=?" for k in fields) + with self._lock: + self._conn.execute(f"UPDATE jobs SET {cols} WHERE id=?", + (*fields.values(), job_id)) + self._conn.commit() + + def get(self, job_id: str) -> Optional[dict]: + with self._lock: + row = self._conn.execute("SELECT * FROM jobs WHERE id=?", + (job_id,)).fetchone() + return dict(row) if row else None + + def all(self) -> List[dict]: + with self._lock: + rows = self._conn.execute( + "SELECT * FROM jobs ORDER BY created DESC").fetchall() + return [dict(r) for r in rows] diff --git a/kaika/src/kaika/server/jobs.py b/kaika/src/kaika/server/jobs.py new file mode 100644 index 0000000..ccaaee7 --- /dev/null +++ b/kaika/src/kaika/server/jobs.py @@ -0,0 +1,118 @@ +"""Background job queue: one task at a time, with live progress state. + +A single worker thread drains a FIFO queue (the local sim already saturates the +machine). A job wraps any callable ``fn(progress) -> result`` — a full render, a +fluid-only preview, or a diffuse-resume — so the same machinery serves every +stage. Progress lives in a thread-safe dict the WebSocket endpoint polls. +""" +from __future__ import annotations + +import logging +import queue +import threading +import uuid +from pathlib import Path +from typing import Callable, Dict, Optional + +from .db import JobDB + +logger = logging.getLogger("kaika.jobs") + + +class JobCancelled(BaseException): + """Raised inside a job's progress callback to stop it cooperatively. + + Inherits BaseException (like KeyboardInterrupt) so the pipeline's own + ``except Exception`` error handling lets it pass through untouched.""" + + +class JobManager: + def __init__(self, runs_root: str | Path, db: JobDB): + self.runs_root = Path(runs_root) + self.db = db + self._q: "queue.Queue[str]" = queue.Queue() + self._jobs: Dict[str, dict] = {} + self._lock = threading.Lock() + self._worker: Optional[threading.Thread] = None + for row in db.all(): + self._jobs[row["id"]] = { + "id": row["id"], "status": row["status"], "stage": None, + "done": 0, "total": 0, "run_id": row["run_id"], + "error": row["error"], "kind": row["recipe"], + } + + def _ensure_worker(self): + if self._worker is None or not self._worker.is_alive(): + self._worker = threading.Thread(target=self._loop, daemon=True) + self._worker.start() + + def submit(self, fn: Callable[[Callable], object], run_id: Optional[str] = None, + kind: str = "render") -> str: + """Queue ``fn(progress)``; ``run_id`` is the target run if known upfront.""" + job_id = uuid.uuid4().hex[:12] + with self._lock: + self._jobs[job_id] = { + "id": job_id, "status": "queued", "stage": None, "done": 0, + "total": 0, "run_id": run_id, "error": None, "kind": kind, "_fn": fn, + } + self.db.create(job_id, kind, run_id or "") + self._q.put(job_id) + self._ensure_worker() + return job_id + + def get(self, job_id: str) -> Optional[dict]: + with self._lock: + j = self._jobs.get(job_id) + return {k: v for k, v in j.items() if not k.startswith("_")} if j else None + + def cancel(self, job_id: str) -> bool: + """Request cooperative cancellation; takes effect at the next progress + tick (running) or when dequeued (queued). Returns False if unknown/finished.""" + with self._lock: + j = self._jobs.get(job_id) + if j is None or j["status"] in ("done", "error", "cancelled"): + return False + j["_cancel"] = True + return True + + def _set(self, job_id: str, **fields): + with self._lock: + self._jobs[job_id].update(fields) + + def _loop(self): + while True: + try: + job_id = self._q.get(timeout=1.0) + except queue.Empty: + return + self._run_one(job_id) + + def _run_one(self, job_id: str): + with self._lock: + job = self._jobs[job_id] + fn = job["_fn"] + if job.get("_cancel"): + job["status"] = "cancelled" + self.db.update(job_id, status="cancelled") + return + self._set(job_id, status="running") + self.db.update(job_id, status="running") + + def progress(stage, done, total): + with self._lock: + if self._jobs[job_id].get("_cancel"): + raise JobCancelled() + self._jobs[job_id].update(stage=stage, done=done, total=total) + + try: + res = fn(progress) + run_id = getattr(res, "run_id", None) or self._jobs[job_id].get("run_id") + self._set(job_id, status="done", run_id=run_id, done=1, total=1) + self.db.update(job_id, status="done", run_id=run_id or "") + except JobCancelled: + self._set(job_id, status="cancelled") + self.db.update(job_id, status="cancelled") + except Exception as e: # noqa + logger.exception("job %s failed", job_id) + self._set(job_id, status="error", error=f"{type(e).__name__}: {e}") + self.db.update(job_id, status="error", error=f"{type(e).__name__}: {e}") diff --git a/kaika/src/kaika/webapp_dist/assets/index-C1aRJFNR.css b/kaika/src/kaika/webapp_dist/assets/index-C1aRJFNR.css new file mode 100644 index 0000000..48504b0 --- /dev/null +++ b/kaika/src/kaika/webapp_dist/assets/index-C1aRJFNR.css @@ -0,0 +1 @@ +:root{--encre: #1f2433;--papier: #f6f6f1;--petale: #b84a74;--courant: #34808a;--brume: #8c95a1;--voile: #e7e8e0;--white: #ffffff}*{margin:0;padding:0;box-sizing:border-box}body{background:var(--papier);color:var(--encre);font-family:"Source Serif 4",Georgia,serif;font-size:16px;line-height:1.55}button{font-family:inherit;cursor:pointer}video{width:100%;height:auto;display:block;border-radius:8px;background:#000}.mono{font-family:Spline Sans Mono,ui-monospace,SF Mono,Menlo,monospace}.app{max-width:1100px;margin:0 auto;padding:0 24px 80px}header.top{display:flex;align-items:baseline;gap:28px;padding:26px 0 18px;border-bottom:1px solid var(--encre)}.brand{font-weight:700;font-size:26px;letter-spacing:-.02em}.brand .kanji{color:var(--petale);font-weight:400}nav.tabs{display:flex;gap:6px;margin-left:auto}nav.tabs button{border:1px solid transparent;background:none;color:var(--brume);font-size:13px;letter-spacing:.08em;text-transform:uppercase;padding:7px 14px;border-radius:6px}nav.tabs button.active{color:var(--encre);border-color:var(--voile);background:var(--white)}nav.tabs button:hover{color:var(--petale)}.grid{display:grid;grid-template-columns:1fr 360px;gap:28px;margin-top:26px}@media (max-width: 860px){.grid{grid-template-columns:1fr}}.card{background:var(--white);border:1px solid var(--voile);border-radius:10px;padding:20px 22px;margin-bottom:20px}.card h3{font-size:15px;text-transform:uppercase;letter-spacing:.1em;color:var(--brume);margin-bottom:14px;font-weight:600}.drop{border:2px dashed var(--voile);border-radius:10px;padding:34px;text-align:center;color:var(--brume);transition:border-color .15s}.drop.hover{border-color:var(--petale);color:var(--petale)}.wave-wrap{position:relative}canvas.wave{width:100%;height:140px;display:block;border-radius:8px;background:#11151f}.sec-row{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}.sec-chip{font-size:11px;padding:3px 9px;border-radius:20px;background:var(--voile)}.sec-chip.drop{background:var(--petale);color:#fff}.sec-chip.build{background:#e7c4d3}label.field{display:block;font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--brume);margin:16px 0 5px}input[type=range]{width:100%;accent-color:var(--petale)}input[type=text],input[type=number],textarea,select{width:100%;padding:8px 10px;border:1px solid var(--voile);border-radius:6px;font-family:inherit;font-size:14px;background:var(--papier)}textarea{resize:vertical;min-height:48px}.val{float:right;color:var(--encre);font-weight:600}.btn{background:var(--petale);color:#fff;border:none;border-radius:8px;padding:12px 18px;font-size:15px;width:100%;margin-top:18px}.btn:disabled{opacity:.5;cursor:not-allowed}.btn.ghost{background:none;color:var(--courant);border:1px solid var(--courant)}.pipeline{display:flex;flex-direction:column;gap:12px}.stage-row{display:grid;grid-template-columns:120px 1fr 80px;align-items:center;gap:14px}.stage-name{font-size:13px;letter-spacing:.08em;text-transform:uppercase}.bar{height:8px;background:var(--voile);border-radius:6px;overflow:hidden}.bar>i{display:block;height:100%;background:var(--courant);width:0;transition:width .2s}.stage-row.done .bar>i{background:var(--petale)}.pct{font-size:12px;color:var(--brume);text-align:right}.runs{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:18px;margin-top:24px}.run-card video{width:100%;border-radius:8px;background:#000}.run-meta{display:flex;justify-content:space-between;font-size:12px;color:var(--brume);margin-top:8px}.badge{font-size:11px;padding:2px 8px;border-radius:12px}.badge.done{background:#d7eae6;color:var(--courant)}.badge.error{background:#f5d6e0;color:var(--petale)}.badge.running,.badge.fluid{background:#fef0c7;color:#92660a}.badge.created{background:var(--voile);color:var(--brume)}.sec-chip.sel{outline:2px solid var(--petale);outline-offset:1px}.muted{color:var(--brume);font-size:13px}.err{color:var(--petale);font-size:13px;margin-top:8px}.transport{display:flex;align-items:center;gap:12px;margin-bottom:12px}.transport .play{width:36px;height:36px;border-radius:50%;border:1px solid var(--voile);background:var(--white);font-size:13px;line-height:1}.transport .play:hover{border-color:var(--petale);color:var(--petale)}.seg-ops{display:flex;gap:10px;margin-top:12px}.btn.slim{width:auto;margin-top:0;padding:7px 12px;font-size:13px}.inspector{position:sticky;top:16px;max-height:calc(100vh - 32px);display:flex;flex-direction:column}.insp-body{overflow-y:auto;flex:1;min-height:0;padding-right:4px}.insp-footer{border-top:1px solid var(--voile);padding-top:12px;margin-top:10px}.insp-footer .btn{margin-top:8px}.insp-footer .check{margin-top:0}.seg-nav{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:10px}.seg-nav button{width:30px;height:30px;border-radius:6px;border:1px solid var(--voile);background:var(--white);font-size:16px;line-height:1;color:var(--encre)}.seg-nav button:disabled{opacity:.35;cursor:not-allowed}.seg-nav button:not(:disabled):hover{border-color:var(--petale);color:var(--petale)}.slider-row{margin-bottom:2px}.field.ov{color:var(--petale)}.ov-dot{color:var(--petale);font-size:8px;margin-right:5px;vertical-align:middle}details.advanced{margin:12px 0 4px;border-top:1px dashed var(--voile);padding-top:8px}details.advanced>summary{cursor:pointer;font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--courant);list-style:revert}.btn.reset{margin-top:14px;color:var(--brume);border-color:var(--voile)}.insp-tabs{display:flex;gap:4px;margin-bottom:10px}.insp-tabs button{flex:1;border:1px solid var(--voile);background:none;color:var(--brume);font-size:12px;letter-spacing:.06em;text-transform:uppercase;padding:6px 0;border-radius:6px}.insp-tabs button.active{background:var(--white);color:var(--encre);border-color:var(--brume)}.palette-row{display:flex;gap:6px;align-items:center;flex-wrap:wrap}.palette-row input[type=color]{width:34px;height:34px;padding:2px;border:1px solid var(--voile);border-radius:6px;background:var(--white);cursor:pointer}textarea.yaml{font-family:Spline Sans Mono,ui-monospace,monospace;font-size:12px;min-height:320px;white-space:pre}label.check{display:flex;gap:8px;align-items:center;font-size:13px;color:var(--brume);margin-top:16px;cursor:pointer}.run-card.picked{outline:2px solid var(--courant)}.run-actions{display:flex;align-items:center;justify-content:space-between;margin-top:8px}.compare-bar{display:flex;align-items:center;justify-content:space-between;margin-top:20px}.compare{margin-top:20px}.compare-head{display:flex;justify-content:space-between;align-items:baseline}.compare-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:12px}.compare-grid video{width:100%;border-radius:8px;background:#000}img.live-frame{width:100%;max-width:320px;border-radius:8px;border:1px solid var(--voile)}.badge.cancelled{background:var(--voile);color:var(--brume)} diff --git a/kaika/src/kaika/webapp_dist/assets/index-DW-bhKnd.js b/kaika/src/kaika/webapp_dist/assets/index-DW-bhKnd.js new file mode 100644 index 0000000..0b81242 --- /dev/null +++ b/kaika/src/kaika/webapp_dist/assets/index-DW-bhKnd.js @@ -0,0 +1,71 @@ +(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const o of l)if(o.type==="childList")for(const u of o.addedNodes)u.tagName==="LINK"&&u.rel==="modulepreload"&&r(u)}).observe(document,{childList:!0,subtree:!0});function t(l){const o={};return l.integrity&&(o.integrity=l.integrity),l.referrerPolicy&&(o.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?o.credentials="include":l.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function r(l){if(l.ep)return;l.ep=!0;const o=t(l);fetch(l.href,o)}})();function Nf(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Ks={exports:{}},ri={},Gs={exports:{}},b={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Gr=Symbol.for("react.element"),Tf=Symbol.for("react.portal"),Af=Symbol.for("react.fragment"),Pf=Symbol.for("react.strict_mode"),Lf=Symbol.for("react.profiler"),jf=Symbol.for("react.provider"),Of=Symbol.for("react.context"),Rf=Symbol.for("react.forward_ref"),If=Symbol.for("react.suspense"),Mf=Symbol.for("react.memo"),Ff=Symbol.for("react.lazy"),Mu=Symbol.iterator;function zf(e){return e===null||typeof e!="object"?null:(e=Mu&&e[Mu]||e["@@iterator"],typeof e=="function"?e:null)}var Xs={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},qs=Object.assign,Zs={};function ir(e,n,t){this.props=e,this.context=n,this.refs=Zs,this.updater=t||Xs}ir.prototype.isReactComponent={};ir.prototype.setState=function(e,n){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,n,"setState")};ir.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function Js(){}Js.prototype=ir.prototype;function Uo(e,n,t){this.props=e,this.context=n,this.refs=Zs,this.updater=t||Xs}var Bo=Uo.prototype=new Js;Bo.constructor=Uo;qs(Bo,ir.prototype);Bo.isPureReactComponent=!0;var Fu=Array.isArray,bs=Object.prototype.hasOwnProperty,$o={current:null},ea={key:!0,ref:!0,__self:!0,__source:!0};function na(e,n,t){var r,l={},o=null,u=null;if(n!=null)for(r in n.ref!==void 0&&(u=n.ref),n.key!==void 0&&(o=""+n.key),n)bs.call(n,r)&&!ea.hasOwnProperty(r)&&(l[r]=n[r]);var a=arguments.length-2;if(a===1)l.children=t;else if(1>>1,te=_[ie];if(0>>1;iel(tn,X))Hel(Se,tn)?(_[ie]=Se,_[He]=X,ie=He):(_[ie]=tn,_[Ee]=X,ie=Ee);else if(Hel(Se,X))_[ie]=Se,_[He]=X,ie=He;else break e}}return K}function l(_,K){var X=_.sortIndex-K.sortIndex;return X!==0?X:_.id-K.id}if(typeof performance=="object"&&typeof performance.now=="function"){var o=performance;e.unstable_now=function(){return o.now()}}else{var u=Date,a=u.now();e.unstable_now=function(){return u.now()-a}}var s=[],f=[],m=1,d=null,p=3,w=!1,C=!1,O=!1,J=typeof setTimeout=="function"?setTimeout:null,g=typeof clearTimeout=="function"?clearTimeout:null,h=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function v(_){for(var K=t(f);K!==null;){if(K.callback===null)r(f);else if(K.startTime<=_)r(f),K.sortIndex=K.expirationTime,n(s,K);else break;K=t(f)}}function k(_){if(O=!1,v(_),!C)if(t(s)!==null)C=!0,oe(j);else{var K=t(f);K!==null&&nn(k,K.startTime-_)}}function j(_,K){C=!1,O&&(O=!1,g($),$=-1),w=!0;var X=p;try{for(v(K),d=t(s);d!==null&&(!(d.expirationTime>K)||_&&!le());){var ie=d.callback;if(typeof ie=="function"){d.callback=null,p=d.priorityLevel;var te=ie(d.expirationTime<=K);K=e.unstable_now(),typeof te=="function"?d.callback=te:d===t(s)&&r(s),v(K)}else r(s);d=t(s)}if(d!==null)var pn=!0;else{var Ee=t(f);Ee!==null&&nn(k,Ee.startTime-K),pn=!1}return pn}finally{d=null,p=X,w=!1}}var T=!1,U=null,$=-1,W=5,Q=-1;function le(){return!(e.unstable_now()-Q_||125<_?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):W=0<_?Math.floor(1e3/_):5},e.unstable_getCurrentPriorityLevel=function(){return p},e.unstable_getFirstCallbackNode=function(){return t(s)},e.unstable_next=function(_){switch(p){case 1:case 2:case 3:var K=3;break;default:K=p}var X=p;p=K;try{return _()}finally{p=X}},e.unstable_pauseExecution=function(){},e.unstable_requestPaint=function(){},e.unstable_runWithPriority=function(_,K){switch(_){case 1:case 2:case 3:case 4:case 5:break;default:_=3}var X=p;p=_;try{return K()}finally{p=X}},e.unstable_scheduleCallback=function(_,K,X){var ie=e.unstable_now();switch(typeof X=="object"&&X!==null?(X=X.delay,X=typeof X=="number"&&0ie?(_.sortIndex=X,n(f,_),t(s)===null&&_===t(f)&&(O?(g($),$=-1):O=!0,nn(k,X-ie))):(_.sortIndex=te,n(s,_),C||w||(C=!0,oe(j))),_},e.unstable_shouldYield=le,e.unstable_wrapCallback=function(_){var K=p;return function(){var X=p;p=K;try{return _.apply(this,arguments)}finally{p=X}}}})(oa);ia.exports=oa;var Xf=ia.exports;/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var qf=Z,Ze=Xf;function D(e){for(var n="https://reactjs.org/docs/error-decoder.html?invariant="+e,t=1;t"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Qi=Object.prototype.hasOwnProperty,Zf=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,Du={},Uu={};function Jf(e){return Qi.call(Uu,e)?!0:Qi.call(Du,e)?!1:Zf.test(e)?Uu[e]=!0:(Du[e]=!0,!1)}function bf(e,n,t,r){if(t!==null&&t.type===0)return!1;switch(typeof n){case"function":case"symbol":return!0;case"boolean":return r?!1:t!==null?!t.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function ed(e,n,t,r){if(n===null||typeof n>"u"||bf(e,n,t,r))return!0;if(r)return!1;if(t!==null)switch(t.type){case 3:return!n;case 4:return n===!1;case 5:return isNaN(n);case 6:return isNaN(n)||1>n}return!1}function Be(e,n,t,r,l,o,u){this.acceptsBooleans=n===2||n===3||n===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=t,this.propertyName=e,this.type=n,this.sanitizeURL=o,this.removeEmptyString=u}var Le={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){Le[e]=new Be(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var n=e[0];Le[n]=new Be(n,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){Le[e]=new Be(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){Le[e]=new Be(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){Le[e]=new Be(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){Le[e]=new Be(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){Le[e]=new Be(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){Le[e]=new Be(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){Le[e]=new Be(e,5,!1,e.toLowerCase(),null,!1,!1)});var Vo=/[\-:]([a-z])/g;function Wo(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var n=e.replace(Vo,Wo);Le[n]=new Be(n,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var n=e.replace(Vo,Wo);Le[n]=new Be(n,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var n=e.replace(Vo,Wo);Le[n]=new Be(n,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){Le[e]=new Be(e,1,!1,e.toLowerCase(),null,!1,!1)});Le.xlinkHref=new Be("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){Le[e]=new Be(e,1,!1,e.toLowerCase(),null,!0,!0)});function Yo(e,n,t,r){var l=Le.hasOwnProperty(n)?Le[n]:null;(l!==null?l.type!==0:r||!(2a||l[u]!==o[a]){var s=` +`+l[u].replace(" at new "," at ");return e.displayName&&s.includes("")&&(s=s.replace("",e.displayName)),s}while(1<=u&&0<=a);break}}}finally{xi=!1,Error.prepareStackTrace=t}return(e=e?e.displayName||e.name:"")?yr(e):""}function nd(e){switch(e.tag){case 5:return yr(e.type);case 16:return yr("Lazy");case 13:return yr("Suspense");case 19:return yr("SuspenseList");case 0:case 2:case 15:return e=ki(e.type,!1),e;case 11:return e=ki(e.type.render,!1),e;case 1:return e=ki(e.type,!0),e;default:return""}}function qi(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Mt:return"Fragment";case It:return"Portal";case Ki:return"Profiler";case Qo:return"StrictMode";case Gi:return"Suspense";case Xi:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case aa:return(e.displayName||"Context")+".Consumer";case sa:return(e._context.displayName||"Context")+".Provider";case Ko:var n=e.render;return e=e.displayName,e||(e=n.displayName||n.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Go:return n=e.displayName||null,n!==null?n:qi(e.type)||"Memo";case Wn:n=e._payload,e=e._init;try{return qi(e(n))}catch{}}return null}function td(e){var n=e.type;switch(e.tag){case 24:return"Cache";case 9:return(n.displayName||"Context")+".Consumer";case 10:return(n._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=n.render,e=e.displayName||e.name||"",n.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return n;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return qi(n);case 8:return n===Qo?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof n=="function")return n.displayName||n.name||null;if(typeof n=="string")return n}return null}function lt(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function fa(e){var n=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(n==="checkbox"||n==="radio")}function rd(e){var n=fa(e)?"checked":"value",t=Object.getOwnPropertyDescriptor(e.constructor.prototype,n),r=""+e[n];if(!e.hasOwnProperty(n)&&typeof t<"u"&&typeof t.get=="function"&&typeof t.set=="function"){var l=t.get,o=t.set;return Object.defineProperty(e,n,{configurable:!0,get:function(){return l.call(this)},set:function(u){r=""+u,o.call(this,u)}}),Object.defineProperty(e,n,{enumerable:t.enumerable}),{getValue:function(){return r},setValue:function(u){r=""+u},stopTracking:function(){e._valueTracker=null,delete e[n]}}}}function rl(e){e._valueTracker||(e._valueTracker=rd(e))}function da(e){if(!e)return!1;var n=e._valueTracker;if(!n)return!0;var t=n.getValue(),r="";return e&&(r=fa(e)?e.checked?"true":"false":e.value),e=r,e!==t?(n.setValue(e),!0):!1}function Rl(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function Zi(e,n){var t=n.checked;return ge({},n,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:t??e._wrapperState.initialChecked})}function $u(e,n){var t=n.defaultValue==null?"":n.defaultValue,r=n.checked!=null?n.checked:n.defaultChecked;t=lt(n.value!=null?n.value:t),e._wrapperState={initialChecked:r,initialValue:t,controlled:n.type==="checkbox"||n.type==="radio"?n.checked!=null:n.value!=null}}function pa(e,n){n=n.checked,n!=null&&Yo(e,"checked",n,!1)}function Ji(e,n){pa(e,n);var t=lt(n.value),r=n.type;if(t!=null)r==="number"?(t===0&&e.value===""||e.value!=t)&&(e.value=""+t):e.value!==""+t&&(e.value=""+t);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}n.hasOwnProperty("value")?bi(e,n.type,t):n.hasOwnProperty("defaultValue")&&bi(e,n.type,lt(n.defaultValue)),n.checked==null&&n.defaultChecked!=null&&(e.defaultChecked=!!n.defaultChecked)}function Hu(e,n,t){if(n.hasOwnProperty("value")||n.hasOwnProperty("defaultValue")){var r=n.type;if(!(r!=="submit"&&r!=="reset"||n.value!==void 0&&n.value!==null))return;n=""+e._wrapperState.initialValue,t||n===e.value||(e.value=n),e.defaultValue=n}t=e.name,t!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,t!==""&&(e.name=t)}function bi(e,n,t){(n!=="number"||Rl(e.ownerDocument)!==e)&&(t==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+t&&(e.defaultValue=""+t))}var wr=Array.isArray;function Qt(e,n,t,r){if(e=e.options,n){n={};for(var l=0;l"+n.valueOf().toString()+"",n=ll.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;n.firstChild;)e.appendChild(n.firstChild)}});function Or(e,n){if(n){var t=e.firstChild;if(t&&t===e.lastChild&&t.nodeType===3){t.nodeValue=n;return}}e.textContent=n}var kr={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},ld=["Webkit","ms","Moz","O"];Object.keys(kr).forEach(function(e){ld.forEach(function(n){n=n+e.charAt(0).toUpperCase()+e.substring(1),kr[n]=kr[e]})});function va(e,n,t){return n==null||typeof n=="boolean"||n===""?"":t||typeof n!="number"||n===0||kr.hasOwnProperty(e)&&kr[e]?(""+n).trim():n+"px"}function ya(e,n){e=e.style;for(var t in n)if(n.hasOwnProperty(t)){var r=t.indexOf("--")===0,l=va(t,n[t],r);t==="float"&&(t="cssFloat"),r?e.setProperty(t,l):e[t]=l}}var id=ge({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function to(e,n){if(n){if(id[e]&&(n.children!=null||n.dangerouslySetInnerHTML!=null))throw Error(D(137,e));if(n.dangerouslySetInnerHTML!=null){if(n.children!=null)throw Error(D(60));if(typeof n.dangerouslySetInnerHTML!="object"||!("__html"in n.dangerouslySetInnerHTML))throw Error(D(61))}if(n.style!=null&&typeof n.style!="object")throw Error(D(62))}}function ro(e,n){if(e.indexOf("-")===-1)return typeof n.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var lo=null;function Xo(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var io=null,Kt=null,Gt=null;function Yu(e){if(e=Zr(e)){if(typeof io!="function")throw Error(D(280));var n=e.stateNode;n&&(n=si(n),io(e.stateNode,e.type,n))}}function wa(e){Kt?Gt?Gt.push(e):Gt=[e]:Kt=e}function Sa(){if(Kt){var e=Kt,n=Gt;if(Gt=Kt=null,Yu(e),n)for(e=0;e>>=0,e===0?32:31-(gd(e)/vd|0)|0}var il=64,ol=4194304;function Sr(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function zl(e,n){var t=e.pendingLanes;if(t===0)return 0;var r=0,l=e.suspendedLanes,o=e.pingedLanes,u=t&268435455;if(u!==0){var a=u&~l;a!==0?r=Sr(a):(o&=u,o!==0&&(r=Sr(o)))}else u=t&~l,u!==0?r=Sr(u):o!==0&&(r=Sr(o));if(r===0)return 0;if(n!==0&&n!==r&&!(n&l)&&(l=r&-r,o=n&-n,l>=o||l===16&&(o&4194240)!==0))return n;if(r&4&&(r|=t&16),n=e.entangledLanes,n!==0)for(e=e.entanglements,n&=r;0t;t++)n.push(e);return n}function Xr(e,n,t){e.pendingLanes|=n,n!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,n=31-Sn(n),e[n]=t}function xd(e,n){var t=e.pendingLanes&~n;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=n,e.mutableReadLanes&=n,e.entangledLanes&=n,n=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Er),es=" ",ns=!1;function Ba(e,n){switch(e){case"keyup":return Xd.indexOf(n.keyCode)!==-1;case"keydown":return n.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function $a(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Ft=!1;function Zd(e,n){switch(e){case"compositionend":return $a(n);case"keypress":return n.which!==32?null:(ns=!0,es);case"textInput":return e=n.data,e===es&&ns?null:e;default:return null}}function Jd(e,n){if(Ft)return e==="compositionend"||!ru&&Ba(e,n)?(e=Da(),El=eu=Gn=null,Ft=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(n.ctrlKey||n.altKey||n.metaKey)||n.ctrlKey&&n.altKey){if(n.char&&1=n)return{node:t,offset:n-e};e=r}e:{for(;t;){if(t.nextSibling){t=t.nextSibling;break e}t=t.parentNode}t=void 0}t=is(t)}}function Ya(e,n){return e&&n?e===n?!0:e&&e.nodeType===3?!1:n&&n.nodeType===3?Ya(e,n.parentNode):"contains"in e?e.contains(n):e.compareDocumentPosition?!!(e.compareDocumentPosition(n)&16):!1:!1}function Qa(){for(var e=window,n=Rl();n instanceof e.HTMLIFrameElement;){try{var t=typeof n.contentWindow.location.href=="string"}catch{t=!1}if(t)e=n.contentWindow;else break;n=Rl(e.document)}return n}function lu(e){var n=e&&e.nodeName&&e.nodeName.toLowerCase();return n&&(n==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||n==="textarea"||e.contentEditable==="true")}function up(e){var n=Qa(),t=e.focusedElem,r=e.selectionRange;if(n!==t&&t&&t.ownerDocument&&Ya(t.ownerDocument.documentElement,t)){if(r!==null&&lu(t)){if(n=r.start,e=r.end,e===void 0&&(e=n),"selectionStart"in t)t.selectionStart=n,t.selectionEnd=Math.min(e,t.value.length);else if(e=(n=t.ownerDocument||document)&&n.defaultView||window,e.getSelection){e=e.getSelection();var l=t.textContent.length,o=Math.min(r.start,l);r=r.end===void 0?o:Math.min(r.end,l),!e.extend&&o>r&&(l=r,r=o,o=l),l=os(t,o);var u=os(t,r);l&&u&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==u.node||e.focusOffset!==u.offset)&&(n=n.createRange(),n.setStart(l.node,l.offset),e.removeAllRanges(),o>r?(e.addRange(n),e.extend(u.node,u.offset)):(n.setEnd(u.node,u.offset),e.addRange(n)))}}for(n=[],e=t;e=e.parentNode;)e.nodeType===1&&n.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof t.focus=="function"&&t.focus(),t=0;t=document.documentMode,zt=null,fo=null,Nr=null,po=!1;function us(e,n,t){var r=t.window===t?t.document:t.nodeType===9?t:t.ownerDocument;po||zt==null||zt!==Rl(r)||(r=zt,"selectionStart"in r&&lu(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Nr&&Dr(Nr,r)||(Nr=r,r=Bl(fo,"onSelect"),0Bt||(e.current=wo[Bt],wo[Bt]=null,Bt--)}function ue(e,n){Bt++,wo[Bt]=e.current,e.current=n}var it={},Ie=ut(it),Ye=ut(!1),St=it;function bt(e,n){var t=e.type.contextTypes;if(!t)return it;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===n)return r.__reactInternalMemoizedMaskedChildContext;var l={},o;for(o in t)l[o]=n[o];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=n,e.__reactInternalMemoizedMaskedChildContext=l),l}function Qe(e){return e=e.childContextTypes,e!=null}function Hl(){ae(Ye),ae(Ie)}function hs(e,n,t){if(Ie.current!==it)throw Error(D(168));ue(Ie,n),ue(Ye,t)}function nc(e,n,t){var r=e.stateNode;if(n=n.childContextTypes,typeof r.getChildContext!="function")return t;r=r.getChildContext();for(var l in r)if(!(l in n))throw Error(D(108,td(e)||"Unknown",l));return ge({},t,r)}function Vl(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||it,St=Ie.current,ue(Ie,e),ue(Ye,Ye.current),!0}function ms(e,n,t){var r=e.stateNode;if(!r)throw Error(D(169));t?(e=nc(e,n,St),r.__reactInternalMemoizedMergedChildContext=e,ae(Ye),ae(Ie),ue(Ie,e)):ae(Ye),ue(Ye,t)}var Rn=null,ai=!1,Fi=!1;function tc(e){Rn===null?Rn=[e]:Rn.push(e)}function wp(e){ai=!0,tc(e)}function st(){if(!Fi&&Rn!==null){Fi=!0;var e=0,n=re;try{var t=Rn;for(re=1;e>=u,l-=u,In=1<<32-Sn(n)+l|t<$?(W=U,U=null):W=U.sibling;var Q=p(g,U,v[$],k);if(Q===null){U===null&&(U=W);break}e&&U&&Q.alternate===null&&n(g,U),h=o(Q,h,$),T===null?j=Q:T.sibling=Q,T=Q,U=W}if($===v.length)return t(g,U),de&&pt(g,$),j;if(U===null){for(;$$?(W=U,U=null):W=U.sibling;var le=p(g,U,Q.value,k);if(le===null){U===null&&(U=W);break}e&&U&&le.alternate===null&&n(g,U),h=o(le,h,$),T===null?j=le:T.sibling=le,T=le,U=W}if(Q.done)return t(g,U),de&&pt(g,$),j;if(U===null){for(;!Q.done;$++,Q=v.next())Q=d(g,Q.value,k),Q!==null&&(h=o(Q,h,$),T===null?j=Q:T.sibling=Q,T=Q);return de&&pt(g,$),j}for(U=r(g,U);!Q.done;$++,Q=v.next())Q=w(U,g,$,Q.value,k),Q!==null&&(e&&Q.alternate!==null&&U.delete(Q.key===null?$:Q.key),h=o(Q,h,$),T===null?j=Q:T.sibling=Q,T=Q);return e&&U.forEach(function(en){return n(g,en)}),de&&pt(g,$),j}function J(g,h,v,k){if(typeof v=="object"&&v!==null&&v.type===Mt&&v.key===null&&(v=v.props.children),typeof v=="object"&&v!==null){switch(v.$$typeof){case tl:e:{for(var j=v.key,T=h;T!==null;){if(T.key===j){if(j=v.type,j===Mt){if(T.tag===7){t(g,T.sibling),h=l(T,v.props.children),h.return=g,g=h;break e}}else if(T.elementType===j||typeof j=="object"&&j!==null&&j.$$typeof===Wn&&ys(j)===T.type){t(g,T.sibling),h=l(T,v.props),h.ref=mr(g,T,v),h.return=g,g=h;break e}t(g,T);break}else n(g,T);T=T.sibling}v.type===Mt?(h=wt(v.props.children,g.mode,k,v.key),h.return=g,g=h):(k=Ol(v.type,v.key,v.props,null,g.mode,k),k.ref=mr(g,h,v),k.return=g,g=k)}return u(g);case It:e:{for(T=v.key;h!==null;){if(h.key===T)if(h.tag===4&&h.stateNode.containerInfo===v.containerInfo&&h.stateNode.implementation===v.implementation){t(g,h.sibling),h=l(h,v.children||[]),h.return=g,g=h;break e}else{t(g,h);break}else n(g,h);h=h.sibling}h=Wi(v,g.mode,k),h.return=g,g=h}return u(g);case Wn:return T=v._init,J(g,h,T(v._payload),k)}if(wr(v))return C(g,h,v,k);if(cr(v))return O(g,h,v,k);pl(g,v)}return typeof v=="string"&&v!==""||typeof v=="number"?(v=""+v,h!==null&&h.tag===6?(t(g,h.sibling),h=l(h,v),h.return=g,g=h):(t(g,h),h=Vi(v,g.mode,k),h.return=g,g=h),u(g)):t(g,h)}return J}var nr=oc(!0),uc=oc(!1),Ql=ut(null),Kl=null,Vt=null,su=null;function au(){su=Vt=Kl=null}function cu(e){var n=Ql.current;ae(Ql),e._currentValue=n}function ko(e,n,t){for(;e!==null;){var r=e.alternate;if((e.childLanes&n)!==n?(e.childLanes|=n,r!==null&&(r.childLanes|=n)):r!==null&&(r.childLanes&n)!==n&&(r.childLanes|=n),e===t)break;e=e.return}}function qt(e,n){Kl=e,su=Vt=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&n&&(We=!0),e.firstContext=null)}function cn(e){var n=e._currentValue;if(su!==e)if(e={context:e,memoizedValue:n,next:null},Vt===null){if(Kl===null)throw Error(D(308));Vt=e,Kl.dependencies={lanes:0,firstContext:e}}else Vt=Vt.next=e;return n}var gt=null;function fu(e){gt===null?gt=[e]:gt.push(e)}function sc(e,n,t,r){var l=n.interleaved;return l===null?(t.next=t,fu(n)):(t.next=l.next,l.next=t),n.interleaved=t,Un(e,r)}function Un(e,n){e.lanes|=n;var t=e.alternate;for(t!==null&&(t.lanes|=n),t=e,e=e.return;e!==null;)e.childLanes|=n,t=e.alternate,t!==null&&(t.childLanes|=n),t=e,e=e.return;return t.tag===3?t.stateNode:null}var Yn=!1;function du(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function ac(e,n){e=e.updateQueue,n.updateQueue===e&&(n.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Fn(e,n){return{eventTime:e,lane:n,tag:0,payload:null,callback:null,next:null}}function et(e,n,t){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,ne&2){var l=r.pending;return l===null?n.next=n:(n.next=l.next,l.next=n),r.pending=n,Un(e,t)}return l=r.interleaved,l===null?(n.next=n,fu(r)):(n.next=l.next,l.next=n),r.interleaved=n,Un(e,t)}function Nl(e,n,t){if(n=n.updateQueue,n!==null&&(n=n.shared,(t&4194240)!==0)){var r=n.lanes;r&=e.pendingLanes,t|=r,n.lanes=t,Zo(e,t)}}function ws(e,n){var t=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,t===r)){var l=null,o=null;if(t=t.firstBaseUpdate,t!==null){do{var u={eventTime:t.eventTime,lane:t.lane,tag:t.tag,payload:t.payload,callback:t.callback,next:null};o===null?l=o=u:o=o.next=u,t=t.next}while(t!==null);o===null?l=o=n:o=o.next=n}else l=o=n;t={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:o,shared:r.shared,effects:r.effects},e.updateQueue=t;return}e=t.lastBaseUpdate,e===null?t.firstBaseUpdate=n:e.next=n,t.lastBaseUpdate=n}function Gl(e,n,t,r){var l=e.updateQueue;Yn=!1;var o=l.firstBaseUpdate,u=l.lastBaseUpdate,a=l.shared.pending;if(a!==null){l.shared.pending=null;var s=a,f=s.next;s.next=null,u===null?o=f:u.next=f,u=s;var m=e.alternate;m!==null&&(m=m.updateQueue,a=m.lastBaseUpdate,a!==u&&(a===null?m.firstBaseUpdate=f:a.next=f,m.lastBaseUpdate=s))}if(o!==null){var d=l.baseState;u=0,m=f=s=null,a=o;do{var p=a.lane,w=a.eventTime;if((r&p)===p){m!==null&&(m=m.next={eventTime:w,lane:0,tag:a.tag,payload:a.payload,callback:a.callback,next:null});e:{var C=e,O=a;switch(p=n,w=t,O.tag){case 1:if(C=O.payload,typeof C=="function"){d=C.call(w,d,p);break e}d=C;break e;case 3:C.flags=C.flags&-65537|128;case 0:if(C=O.payload,p=typeof C=="function"?C.call(w,d,p):C,p==null)break e;d=ge({},d,p);break e;case 2:Yn=!0}}a.callback!==null&&a.lane!==0&&(e.flags|=64,p=l.effects,p===null?l.effects=[a]:p.push(a))}else w={eventTime:w,lane:p,tag:a.tag,payload:a.payload,callback:a.callback,next:null},m===null?(f=m=w,s=d):m=m.next=w,u|=p;if(a=a.next,a===null){if(a=l.shared.pending,a===null)break;p=a,a=p.next,p.next=null,l.lastBaseUpdate=p,l.shared.pending=null}}while(!0);if(m===null&&(s=d),l.baseState=s,l.firstBaseUpdate=f,l.lastBaseUpdate=m,n=l.shared.interleaved,n!==null){l=n;do u|=l.lane,l=l.next;while(l!==n)}else o===null&&(l.shared.lanes=0);Ct|=u,e.lanes=u,e.memoizedState=d}}function Ss(e,n,t){if(e=n.effects,n.effects=null,e!==null)for(n=0;nt?t:4,e(!0);var r=Di.transition;Di.transition={};try{e(!1),n()}finally{re=t,Di.transition=r}}function Nc(){return fn().memoizedState}function Cp(e,n,t){var r=tt(e);if(t={lane:r,action:t,hasEagerState:!1,eagerState:null,next:null},Tc(e))Ac(n,t);else if(t=sc(e,n,t,r),t!==null){var l=De();xn(t,e,r,l),Pc(t,n,r)}}function Ep(e,n,t){var r=tt(e),l={lane:r,action:t,hasEagerState:!1,eagerState:null,next:null};if(Tc(e))Ac(n,l);else{var o=e.alternate;if(e.lanes===0&&(o===null||o.lanes===0)&&(o=n.lastRenderedReducer,o!==null))try{var u=n.lastRenderedState,a=o(u,t);if(l.hasEagerState=!0,l.eagerState=a,kn(a,u)){var s=n.interleaved;s===null?(l.next=l,fu(n)):(l.next=s.next,s.next=l),n.interleaved=l;return}}catch{}finally{}t=sc(e,n,l,r),t!==null&&(l=De(),xn(t,e,r,l),Pc(t,n,r))}}function Tc(e){var n=e.alternate;return e===me||n!==null&&n===me}function Ac(e,n){Tr=ql=!0;var t=e.pending;t===null?n.next=n:(n.next=t.next,t.next=n),e.pending=n}function Pc(e,n,t){if(t&4194240){var r=n.lanes;r&=e.pendingLanes,t|=r,n.lanes=t,Zo(e,t)}}var Zl={readContext:cn,useCallback:je,useContext:je,useEffect:je,useImperativeHandle:je,useInsertionEffect:je,useLayoutEffect:je,useMemo:je,useReducer:je,useRef:je,useState:je,useDebugValue:je,useDeferredValue:je,useTransition:je,useMutableSource:je,useSyncExternalStore:je,useId:je,unstable_isNewReconciler:!1},_p={readContext:cn,useCallback:function(e,n){return Tn().memoizedState=[e,n===void 0?null:n],e},useContext:cn,useEffect:ks,useImperativeHandle:function(e,n,t){return t=t!=null?t.concat([e]):null,Al(4194308,4,xc.bind(null,n,e),t)},useLayoutEffect:function(e,n){return Al(4194308,4,e,n)},useInsertionEffect:function(e,n){return Al(4,2,e,n)},useMemo:function(e,n){var t=Tn();return n=n===void 0?null:n,e=e(),t.memoizedState=[e,n],e},useReducer:function(e,n,t){var r=Tn();return n=t!==void 0?t(n):n,r.memoizedState=r.baseState=n,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:n},r.queue=e,e=e.dispatch=Cp.bind(null,me,e),[r.memoizedState,e]},useRef:function(e){var n=Tn();return e={current:e},n.memoizedState=e},useState:xs,useDebugValue:Su,useDeferredValue:function(e){return Tn().memoizedState=e},useTransition:function(){var e=xs(!1),n=e[0];return e=kp.bind(null,e[1]),Tn().memoizedState=e,[n,e]},useMutableSource:function(){},useSyncExternalStore:function(e,n,t){var r=me,l=Tn();if(de){if(t===void 0)throw Error(D(407));t=t()}else{if(t=n(),Ne===null)throw Error(D(349));kt&30||pc(r,n,t)}l.memoizedState=t;var o={value:t,getSnapshot:n};return l.queue=o,ks(mc.bind(null,r,o,e),[e]),r.flags|=2048,Qr(9,hc.bind(null,r,o,t,n),void 0,null),t},useId:function(){var e=Tn(),n=Ne.identifierPrefix;if(de){var t=Mn,r=In;t=(r&~(1<<32-Sn(r)-1)).toString(32)+t,n=":"+n+"R"+t,t=Wr++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=u.createElement(t,{is:r.is}):(e=u.createElement(t),t==="select"&&(u=e,r.multiple?u.multiple=!0:r.size&&(u.size=r.size))):e=u.createElementNS(e,t),e[An]=n,e[$r]=r,Uc(e,n,!1,!1),n.stateNode=e;e:{switch(u=ro(t,r),t){case"dialog":se("cancel",e),se("close",e),l=r;break;case"iframe":case"object":case"embed":se("load",e),l=r;break;case"video":case"audio":for(l=0;llr&&(n.flags|=128,r=!0,gr(o,!1),n.lanes=4194304)}else{if(!r)if(e=Xl(u),e!==null){if(n.flags|=128,r=!0,t=e.updateQueue,t!==null&&(n.updateQueue=t,n.flags|=4),gr(o,!0),o.tail===null&&o.tailMode==="hidden"&&!u.alternate&&!de)return Oe(n),null}else 2*we()-o.renderingStartTime>lr&&t!==1073741824&&(n.flags|=128,r=!0,gr(o,!1),n.lanes=4194304);o.isBackwards?(u.sibling=n.child,n.child=u):(t=o.last,t!==null?t.sibling=u:n.child=u,o.last=u)}return o.tail!==null?(n=o.tail,o.rendering=n,o.tail=n.sibling,o.renderingStartTime=we(),n.sibling=null,t=he.current,ue(he,r?t&1|2:t&1),n):(Oe(n),null);case 22:case 23:return Nu(),r=n.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(n.flags|=8192),r&&n.mode&1?Ge&1073741824&&(Oe(n),n.subtreeFlags&6&&(n.flags|=8192)):Oe(n),null;case 24:return null;case 25:return null}throw Error(D(156,n.tag))}function Rp(e,n){switch(ou(n),n.tag){case 1:return Qe(n.type)&&Hl(),e=n.flags,e&65536?(n.flags=e&-65537|128,n):null;case 3:return tr(),ae(Ye),ae(Ie),mu(),e=n.flags,e&65536&&!(e&128)?(n.flags=e&-65537|128,n):null;case 5:return hu(n),null;case 13:if(ae(he),e=n.memoizedState,e!==null&&e.dehydrated!==null){if(n.alternate===null)throw Error(D(340));er()}return e=n.flags,e&65536?(n.flags=e&-65537|128,n):null;case 19:return ae(he),null;case 4:return tr(),null;case 10:return cu(n.type._context),null;case 22:case 23:return Nu(),null;case 24:return null;default:return null}}var ml=!1,Re=!1,Ip=typeof WeakSet=="function"?WeakSet:Set,V=null;function Wt(e,n){var t=e.ref;if(t!==null)if(typeof t=="function")try{t(null)}catch(r){ve(e,n,r)}else t.current=null}function jo(e,n,t){try{t()}catch(r){ve(e,n,r)}}var Rs=!1;function Mp(e,n){if(ho=Dl,e=Qa(),lu(e)){if("selectionStart"in e)var t={start:e.selectionStart,end:e.selectionEnd};else e:{t=(t=e.ownerDocument)&&t.defaultView||window;var r=t.getSelection&&t.getSelection();if(r&&r.rangeCount!==0){t=r.anchorNode;var l=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{t.nodeType,o.nodeType}catch{t=null;break e}var u=0,a=-1,s=-1,f=0,m=0,d=e,p=null;n:for(;;){for(var w;d!==t||l!==0&&d.nodeType!==3||(a=u+l),d!==o||r!==0&&d.nodeType!==3||(s=u+r),d.nodeType===3&&(u+=d.nodeValue.length),(w=d.firstChild)!==null;)p=d,d=w;for(;;){if(d===e)break n;if(p===t&&++f===l&&(a=u),p===o&&++m===r&&(s=u),(w=d.nextSibling)!==null)break;d=p,p=d.parentNode}d=w}t=a===-1||s===-1?null:{start:a,end:s}}else t=null}t=t||{start:0,end:0}}else t=null;for(mo={focusedElem:e,selectionRange:t},Dl=!1,V=n;V!==null;)if(n=V,e=n.child,(n.subtreeFlags&1028)!==0&&e!==null)e.return=n,V=e;else for(;V!==null;){n=V;try{var C=n.alternate;if(n.flags&1024)switch(n.tag){case 0:case 11:case 15:break;case 1:if(C!==null){var O=C.memoizedProps,J=C.memoizedState,g=n.stateNode,h=g.getSnapshotBeforeUpdate(n.elementType===n.type?O:vn(n.type,O),J);g.__reactInternalSnapshotBeforeUpdate=h}break;case 3:var v=n.stateNode.containerInfo;v.nodeType===1?v.textContent="":v.nodeType===9&&v.documentElement&&v.removeChild(v.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(D(163))}}catch(k){ve(n,n.return,k)}if(e=n.sibling,e!==null){e.return=n.return,V=e;break}V=n.return}return C=Rs,Rs=!1,C}function Ar(e,n,t){var r=n.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var o=l.destroy;l.destroy=void 0,o!==void 0&&jo(n,t,o)}l=l.next}while(l!==r)}}function di(e,n){if(n=n.updateQueue,n=n!==null?n.lastEffect:null,n!==null){var t=n=n.next;do{if((t.tag&e)===e){var r=t.create;t.destroy=r()}t=t.next}while(t!==n)}}function Oo(e){var n=e.ref;if(n!==null){var t=e.stateNode;switch(e.tag){case 5:e=t;break;default:e=t}typeof n=="function"?n(e):n.current=e}}function Hc(e){var n=e.alternate;n!==null&&(e.alternate=null,Hc(n)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(n=e.stateNode,n!==null&&(delete n[An],delete n[$r],delete n[yo],delete n[vp],delete n[yp])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function Vc(e){return e.tag===5||e.tag===3||e.tag===4}function Is(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Vc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Ro(e,n,t){var r=e.tag;if(r===5||r===6)e=e.stateNode,n?t.nodeType===8?t.parentNode.insertBefore(e,n):t.insertBefore(e,n):(t.nodeType===8?(n=t.parentNode,n.insertBefore(e,t)):(n=t,n.appendChild(e)),t=t._reactRootContainer,t!=null||n.onclick!==null||(n.onclick=$l));else if(r!==4&&(e=e.child,e!==null))for(Ro(e,n,t),e=e.sibling;e!==null;)Ro(e,n,t),e=e.sibling}function Io(e,n,t){var r=e.tag;if(r===5||r===6)e=e.stateNode,n?t.insertBefore(e,n):t.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Io(e,n,t),e=e.sibling;e!==null;)Io(e,n,t),e=e.sibling}var Ae=null,yn=!1;function Vn(e,n,t){for(t=t.child;t!==null;)Wc(e,n,t),t=t.sibling}function Wc(e,n,t){if(Pn&&typeof Pn.onCommitFiberUnmount=="function")try{Pn.onCommitFiberUnmount(li,t)}catch{}switch(t.tag){case 5:Re||Wt(t,n);case 6:var r=Ae,l=yn;Ae=null,Vn(e,n,t),Ae=r,yn=l,Ae!==null&&(yn?(e=Ae,t=t.stateNode,e.nodeType===8?e.parentNode.removeChild(t):e.removeChild(t)):Ae.removeChild(t.stateNode));break;case 18:Ae!==null&&(yn?(e=Ae,t=t.stateNode,e.nodeType===8?Mi(e.parentNode,t):e.nodeType===1&&Mi(e,t),Fr(e)):Mi(Ae,t.stateNode));break;case 4:r=Ae,l=yn,Ae=t.stateNode.containerInfo,yn=!0,Vn(e,n,t),Ae=r,yn=l;break;case 0:case 11:case 14:case 15:if(!Re&&(r=t.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var o=l,u=o.destroy;o=o.tag,u!==void 0&&(o&2||o&4)&&jo(t,n,u),l=l.next}while(l!==r)}Vn(e,n,t);break;case 1:if(!Re&&(Wt(t,n),r=t.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=t.memoizedProps,r.state=t.memoizedState,r.componentWillUnmount()}catch(a){ve(t,n,a)}Vn(e,n,t);break;case 21:Vn(e,n,t);break;case 22:t.mode&1?(Re=(r=Re)||t.memoizedState!==null,Vn(e,n,t),Re=r):Vn(e,n,t);break;default:Vn(e,n,t)}}function Ms(e){var n=e.updateQueue;if(n!==null){e.updateQueue=null;var t=e.stateNode;t===null&&(t=e.stateNode=new Ip),n.forEach(function(r){var l=Wp.bind(null,e,r);t.has(r)||(t.add(r),r.then(l,l))})}}function mn(e,n){var t=n.deletions;if(t!==null)for(var r=0;rl&&(l=u),r&=~o}if(r=l,r=we()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*zp(r/1960))-r,10e?16:e,Xn===null)var r=!1;else{if(e=Xn,Xn=null,ei=0,ne&6)throw Error(D(331));var l=ne;for(ne|=4,V=e.current;V!==null;){var o=V,u=o.child;if(V.flags&16){var a=o.deletions;if(a!==null){for(var s=0;swe()-Eu?yt(e,0):Cu|=t),Ke(e,n)}function Jc(e,n){n===0&&(e.mode&1?(n=ol,ol<<=1,!(ol&130023424)&&(ol=4194304)):n=1);var t=De();e=Un(e,n),e!==null&&(Xr(e,n,t),Ke(e,t))}function Vp(e){var n=e.memoizedState,t=0;n!==null&&(t=n.retryLane),Jc(e,t)}function Wp(e,n){var t=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(t=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(D(314))}r!==null&&r.delete(n),Jc(e,t)}var bc;bc=function(e,n,t){if(e!==null)if(e.memoizedProps!==n.pendingProps||Ye.current)We=!0;else{if(!(e.lanes&t)&&!(n.flags&128))return We=!1,jp(e,n,t);We=!!(e.flags&131072)}else We=!1,de&&n.flags&1048576&&rc(n,Yl,n.index);switch(n.lanes=0,n.tag){case 2:var r=n.type;Pl(e,n),e=n.pendingProps;var l=bt(n,Ie.current);qt(n,t),l=vu(null,n,r,e,l,t);var o=yu();return n.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(n.tag=1,n.memoizedState=null,n.updateQueue=null,Qe(r)?(o=!0,Vl(n)):o=!1,n.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,du(n),l.updater=fi,n.stateNode=l,l._reactInternals=n,Eo(n,r,e,t),n=To(null,n,r,!0,o,t)):(n.tag=0,de&&o&&iu(n),ze(null,n,l,t),n=n.child),n;case 16:r=n.elementType;e:{switch(Pl(e,n),e=n.pendingProps,l=r._init,r=l(r._payload),n.type=r,l=n.tag=Qp(r),e=vn(r,e),l){case 0:n=No(null,n,r,e,t);break e;case 1:n=Ls(null,n,r,e,t);break e;case 11:n=As(null,n,r,e,t);break e;case 14:n=Ps(null,n,r,vn(r.type,e),t);break e}throw Error(D(306,r,""))}return n;case 0:return r=n.type,l=n.pendingProps,l=n.elementType===r?l:vn(r,l),No(e,n,r,l,t);case 1:return r=n.type,l=n.pendingProps,l=n.elementType===r?l:vn(r,l),Ls(e,n,r,l,t);case 3:e:{if(Fc(n),e===null)throw Error(D(387));r=n.pendingProps,o=n.memoizedState,l=o.element,ac(e,n),Gl(n,r,null,t);var u=n.memoizedState;if(r=u.element,o.isDehydrated)if(o={element:r,isDehydrated:!1,cache:u.cache,pendingSuspenseBoundaries:u.pendingSuspenseBoundaries,transitions:u.transitions},n.updateQueue.baseState=o,n.memoizedState=o,n.flags&256){l=rr(Error(D(423)),n),n=js(e,n,r,t,l);break e}else if(r!==l){l=rr(Error(D(424)),n),n=js(e,n,r,t,l);break e}else for(Xe=bn(n.stateNode.containerInfo.firstChild),qe=n,de=!0,wn=null,t=uc(n,null,r,t),n.child=t;t;)t.flags=t.flags&-3|4096,t=t.sibling;else{if(er(),r===l){n=Bn(e,n,t);break e}ze(e,n,r,t)}n=n.child}return n;case 5:return cc(n),e===null&&xo(n),r=n.type,l=n.pendingProps,o=e!==null?e.memoizedProps:null,u=l.children,go(r,l)?u=null:o!==null&&go(r,o)&&(n.flags|=32),Mc(e,n),ze(e,n,u,t),n.child;case 6:return e===null&&xo(n),null;case 13:return zc(e,n,t);case 4:return pu(n,n.stateNode.containerInfo),r=n.pendingProps,e===null?n.child=nr(n,null,r,t):ze(e,n,r,t),n.child;case 11:return r=n.type,l=n.pendingProps,l=n.elementType===r?l:vn(r,l),As(e,n,r,l,t);case 7:return ze(e,n,n.pendingProps,t),n.child;case 8:return ze(e,n,n.pendingProps.children,t),n.child;case 12:return ze(e,n,n.pendingProps.children,t),n.child;case 10:e:{if(r=n.type._context,l=n.pendingProps,o=n.memoizedProps,u=l.value,ue(Ql,r._currentValue),r._currentValue=u,o!==null)if(kn(o.value,u)){if(o.children===l.children&&!Ye.current){n=Bn(e,n,t);break e}}else for(o=n.child,o!==null&&(o.return=n);o!==null;){var a=o.dependencies;if(a!==null){u=o.child;for(var s=a.firstContext;s!==null;){if(s.context===r){if(o.tag===1){s=Fn(-1,t&-t),s.tag=2;var f=o.updateQueue;if(f!==null){f=f.shared;var m=f.pending;m===null?s.next=s:(s.next=m.next,m.next=s),f.pending=s}}o.lanes|=t,s=o.alternate,s!==null&&(s.lanes|=t),ko(o.return,t,n),a.lanes|=t;break}s=s.next}}else if(o.tag===10)u=o.type===n.type?null:o.child;else if(o.tag===18){if(u=o.return,u===null)throw Error(D(341));u.lanes|=t,a=u.alternate,a!==null&&(a.lanes|=t),ko(u,t,n),u=o.sibling}else u=o.child;if(u!==null)u.return=o;else for(u=o;u!==null;){if(u===n){u=null;break}if(o=u.sibling,o!==null){o.return=u.return,u=o;break}u=u.return}o=u}ze(e,n,l.children,t),n=n.child}return n;case 9:return l=n.type,r=n.pendingProps.children,qt(n,t),l=cn(l),r=r(l),n.flags|=1,ze(e,n,r,t),n.child;case 14:return r=n.type,l=vn(r,n.pendingProps),l=vn(r.type,l),Ps(e,n,r,l,t);case 15:return Rc(e,n,n.type,n.pendingProps,t);case 17:return r=n.type,l=n.pendingProps,l=n.elementType===r?l:vn(r,l),Pl(e,n),n.tag=1,Qe(r)?(e=!0,Vl(n)):e=!1,qt(n,t),Lc(n,r,l),Eo(n,r,l,t),To(null,n,r,!0,e,t);case 19:return Dc(e,n,t);case 22:return Ic(e,n,t)}throw Error(D(156,n.tag))};function ef(e,n){return Ta(e,n)}function Yp(e,n,t,r){this.tag=e,this.key=t,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=n,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function sn(e,n,t,r){return new Yp(e,n,t,r)}function Au(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Qp(e){if(typeof e=="function")return Au(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Ko)return 11;if(e===Go)return 14}return 2}function rt(e,n){var t=e.alternate;return t===null?(t=sn(e.tag,n,e.key,e.mode),t.elementType=e.elementType,t.type=e.type,t.stateNode=e.stateNode,t.alternate=e,e.alternate=t):(t.pendingProps=n,t.type=e.type,t.flags=0,t.subtreeFlags=0,t.deletions=null),t.flags=e.flags&14680064,t.childLanes=e.childLanes,t.lanes=e.lanes,t.child=e.child,t.memoizedProps=e.memoizedProps,t.memoizedState=e.memoizedState,t.updateQueue=e.updateQueue,n=e.dependencies,t.dependencies=n===null?null:{lanes:n.lanes,firstContext:n.firstContext},t.sibling=e.sibling,t.index=e.index,t.ref=e.ref,t}function Ol(e,n,t,r,l,o){var u=2;if(r=e,typeof e=="function")Au(e)&&(u=1);else if(typeof e=="string")u=5;else e:switch(e){case Mt:return wt(t.children,l,o,n);case Qo:u=8,l|=8;break;case Ki:return e=sn(12,t,n,l|2),e.elementType=Ki,e.lanes=o,e;case Gi:return e=sn(13,t,n,l),e.elementType=Gi,e.lanes=o,e;case Xi:return e=sn(19,t,n,l),e.elementType=Xi,e.lanes=o,e;case ca:return hi(t,l,o,n);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case sa:u=10;break e;case aa:u=9;break e;case Ko:u=11;break e;case Go:u=14;break e;case Wn:u=16,r=null;break e}throw Error(D(130,e==null?e:typeof e,""))}return n=sn(u,t,n,l),n.elementType=e,n.type=r,n.lanes=o,n}function wt(e,n,t,r){return e=sn(7,e,r,n),e.lanes=t,e}function hi(e,n,t,r){return e=sn(22,e,r,n),e.elementType=ca,e.lanes=t,e.stateNode={isHidden:!1},e}function Vi(e,n,t){return e=sn(6,e,null,n),e.lanes=t,e}function Wi(e,n,t){return n=sn(4,e.children!==null?e.children:[],e.key,n),n.lanes=t,n.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},n}function Kp(e,n,t,r,l){this.tag=n,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Ei(0),this.expirationTimes=Ei(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Ei(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function Pu(e,n,t,r,l,o,u,a,s){return e=new Kp(e,n,t,a,s),n===1?(n=1,o===!0&&(n|=8)):n=0,o=sn(3,null,null,n),e.current=o,o.stateNode=e,o.memoizedState={element:r,isDehydrated:t,cache:null,transitions:null,pendingSuspenseBoundaries:null},du(o),e}function Gp(e,n,t){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(lf)}catch(e){console.error(e)}}lf(),la.exports=Je;var bp=la.exports,Vs=bp;Yi.createRoot=Vs.createRoot,Yi.hydrateRoot=Vs.hydrateRoot;/*! js-yaml 4.2.0 https://github.com/nodeca/js-yaml @license MIT */var eh=Object.create,of=Object.defineProperty,nh=Object.getOwnPropertyDescriptor,th=Object.getOwnPropertyNames,rh=Object.getPrototypeOf,lh=Object.prototype.hasOwnProperty,ce=(e,n)=>()=>(n||(e((n={exports:{}}).exports,n),e=null),n.exports),ih=(e,n,t,r)=>{if(n&&typeof n=="object"||typeof n=="function")for(var l=th(n),o=0,u=l.length,a;on[s]).bind(null,a),enumerable:!(r=nh(n,a))||r.enumerable});return e},oh=(e,n,t)=>(t=e!=null?eh(rh(e)):{},ih(of(t,"default",{value:e,enumerable:!0}),e)),br=ce((e,n)=>{function t(s){return typeof s>"u"||s===null}function r(s){return typeof s=="object"&&s!==null}function l(s){return Array.isArray(s)?s:t(s)?[]:[s]}function o(s,f){if(f){const m=Object.keys(f);for(let d=0,p=m.length;d{function t(l,o){let u="";const a=l.reason||"(unknown reason)";return l.mark?(l.mark.name&&(u+='in "'+l.mark.name+'" '),u+="("+(l.mark.line+1)+":"+(l.mark.column+1)+")",!o&&l.mark.snippet&&(u+=` + +`+l.mark.snippet),a+" "+u):a}function r(l,o){Error.call(this),this.name="YAMLException",this.reason=l,this.mark=o,this.message=t(this,!1),Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=new Error().stack||""}r.prototype=Object.create(Error.prototype),r.prototype.constructor=r,r.prototype.toString=function(o){return this.name+": "+t(this,o)},n.exports=r}),uh=ce((e,n)=>{var t=br();function r(u,a,s,f,m){let d="",p="";const w=Math.floor(m/2)-1;return f-a>w&&(d=" ... ",a=f-w+d.length),s-f>w&&(p=" ...",s=f+w-p.length),{str:d+u.slice(a,s).replace(/\t/g,"→")+p,pos:f-a+d.length}}function l(u,a){return t.repeat(" ",a-u.length)+u}function o(u,a){if(a=Object.create(a||null),!u.buffer)return null;a.maxLength||(a.maxLength=79),typeof a.indent!="number"&&(a.indent=1),typeof a.linesBefore!="number"&&(a.linesBefore=3),typeof a.linesAfter!="number"&&(a.linesAfter=2);const s=/\r?\n|\r|\0/g,f=[0],m=[];let d,p=-1;for(;d=s.exec(u.buffer);)m.push(d.index),f.push(d.index+d[0].length),u.position<=d.index&&p<0&&(p=f.length-2);p<0&&(p=f.length-1);let w="";const C=Math.min(u.line+a.linesAfter,m.length).toString().length,O=a.maxLength-(a.indent+C+3);for(let g=1;g<=a.linesBefore&&!(p-g<0);g++){const h=r(u.buffer,f[p-g],m[p-g],u.position-(f[p]-f[p-g]),O);w=t.repeat(" ",a.indent)+l((u.line-g+1).toString(),C)+" | "+h.str+` +`+w}const J=r(u.buffer,f[p],m[p],u.position,O);w+=t.repeat(" ",a.indent)+l((u.line+1).toString(),C)+" | "+J.str+` +`,w+=t.repeat("-",a.indent+C+3+J.pos)+`^ +`;for(let g=1;g<=a.linesAfter&&!(p+g>=m.length);g++){const h=r(u.buffer,f[p+g],m[p+g],u.position-(f[p]-f[p+g]),O);w+=t.repeat(" ",a.indent)+l((u.line+g+1).toString(),C)+" | "+h.str+` +`}return w.replace(/\n$/,"")}n.exports=o}),$e=ce((e,n)=>{var t=el(),r=["kind","multi","resolve","construct","instanceOf","predicate","represent","representName","defaultStyle","styleAliases"],l=["scalar","sequence","mapping"];function o(a){const s={};return a!==null&&Object.keys(a).forEach(function(f){a[f].forEach(function(m){s[String(m)]=f})}),s}function u(a,s){if(s=s||{},Object.keys(s).forEach(function(f){if(r.indexOf(f)===-1)throw new t('Unknown option "'+f+'" is met in definition of "'+a+'" YAML type.')}),this.options=s,this.tag=a,this.kind=s.kind||null,this.resolve=s.resolve||function(){return!0},this.construct=s.construct||function(f){return f},this.instanceOf=s.instanceOf||null,this.predicate=s.predicate||null,this.represent=s.represent||null,this.representName=s.representName||null,this.defaultStyle=s.defaultStyle||null,this.multi=s.multi||!1,this.styleAliases=o(s.styleAliases||null),l.indexOf(this.kind)===-1)throw new t('Unknown kind "'+this.kind+'" is specified for "'+a+'" YAML type.')}n.exports=u}),uf=ce((e,n)=>{var t=el(),r=$e();function l(a,s){const f=[];return a[s].forEach(function(m){let d=f.length;f.forEach(function(p,w){p.tag===m.tag&&p.kind===m.kind&&p.multi===m.multi&&(d=w)}),f[d]=m}),f}function o(){const a={scalar:{},sequence:{},mapping:{},fallback:{},multi:{scalar:[],sequence:[],mapping:[],fallback:[]}};function s(f){f.multi?(a.multi[f.kind].push(f),a.multi.fallback.push(f)):a[f.kind][f.tag]=a.fallback[f.tag]=f}for(let f=0,m=arguments.length;f{n.exports=new($e())("tag:yaml.org,2002:str",{kind:"scalar",construct:function(t){return t!==null?t:""}})}),af=ce((e,n)=>{n.exports=new($e())("tag:yaml.org,2002:seq",{kind:"sequence",construct:function(t){return t!==null?t:[]}})}),cf=ce((e,n)=>{n.exports=new($e())("tag:yaml.org,2002:map",{kind:"mapping",construct:function(t){return t!==null?t:{}}})}),ff=ce((e,n)=>{n.exports=new(uf())({explicit:[sf(),af(),cf()]})}),df=ce((e,n)=>{var t=$e();function r(u){if(u===null)return!0;const a=u.length;return a===1&&u==="~"||a===4&&(u==="null"||u==="Null"||u==="NULL")}function l(){return null}function o(u){return u===null}n.exports=new t("tag:yaml.org,2002:null",{kind:"scalar",resolve:r,construct:l,predicate:o,represent:{canonical:function(){return"~"},lowercase:function(){return"null"},uppercase:function(){return"NULL"},camelcase:function(){return"Null"},empty:function(){return""}},defaultStyle:"lowercase"})}),pf=ce((e,n)=>{var t=$e();function r(u){if(u===null)return!1;const a=u.length;return a===4&&(u==="true"||u==="True"||u==="TRUE")||a===5&&(u==="false"||u==="False"||u==="FALSE")}function l(u){return u==="true"||u==="True"||u==="TRUE"}function o(u){return Object.prototype.toString.call(u)==="[object Boolean]"}n.exports=new t("tag:yaml.org,2002:bool",{kind:"scalar",resolve:r,construct:l,predicate:o,represent:{lowercase:function(u){return u?"true":"false"},uppercase:function(u){return u?"TRUE":"FALSE"},camelcase:function(u){return u?"True":"False"}},defaultStyle:"lowercase"})}),hf=ce((e,n)=>{var t=br(),r=$e();function l(d){return d>=48&&d<=57||d>=65&&d<=70||d>=97&&d<=102}function o(d){return d>=48&&d<=55}function u(d){return d>=48&&d<=57}function a(d){if(d===null)return!1;const p=d.length;let w=0,C=!1;if(!p)return!1;let O=d[w];if((O==="-"||O==="+")&&(O=d[++w]),O==="0"){if(w+1===p)return!0;if(O=d[++w],O==="b"){for(w++;w=0?"0b"+d.toString(2):"-0b"+d.toString(2).slice(1)},octal:function(d){return d>=0?"0o"+d.toString(8):"-0o"+d.toString(8).slice(1)},decimal:function(d){return d.toString(10)},hexadecimal:function(d){return d>=0?"0x"+d.toString(16).toUpperCase():"-0x"+d.toString(16).toUpperCase().slice(1)}},defaultStyle:"decimal",styleAliases:{binary:[2,"bin"],octal:[8,"oct"],decimal:[10,"dec"],hexadecimal:[16,"hex"]}})}),mf=ce((e,n)=>{var t=br(),r=$e(),l=new RegExp("^(?:[-+]?(?:[0-9]+)(?:\\.[0-9]*)?(?:[eE][-+]?[0-9]+)?|\\.[0-9]+(?:[eE][-+]?[0-9]+)?|[-+]?\\.(?:inf|Inf|INF)|\\.(?:nan|NaN|NAN))$"),o=new RegExp("^(?:[-+]?\\.(?:inf|Inf|INF)|\\.(?:nan|NaN|NAN))$");function u(d){return d===null||!l.test(d)?!1:Number.isFinite(parseFloat(d,10))?!0:o.test(d)}function a(d){let p=d.toLowerCase();const w=p[0]==="-"?-1:1;return"+-".indexOf(p[0])>=0&&(p=p.slice(1)),p===".inf"?w===1?Number.POSITIVE_INFINITY:Number.NEGATIVE_INFINITY:p===".nan"?NaN:w*parseFloat(p,10)}var s=/^[-+]?[0-9]+e/;function f(d,p){if(isNaN(d))switch(p){case"lowercase":return".nan";case"uppercase":return".NAN";case"camelcase":return".NaN"}else if(Number.POSITIVE_INFINITY===d)switch(p){case"lowercase":return".inf";case"uppercase":return".INF";case"camelcase":return".Inf"}else if(Number.NEGATIVE_INFINITY===d)switch(p){case"lowercase":return"-.inf";case"uppercase":return"-.INF";case"camelcase":return"-.Inf"}else if(t.isNegativeZero(d))return"-0.0";const w=d.toString(10);return s.test(w)?w.replace("e",".e"):w}function m(d){return Object.prototype.toString.call(d)==="[object Number]"&&(d%1!==0||t.isNegativeZero(d))}n.exports=new r("tag:yaml.org,2002:float",{kind:"scalar",resolve:u,construct:a,predicate:m,represent:f,defaultStyle:"lowercase"})}),gf=ce((e,n)=>{n.exports=ff().extend({implicit:[df(),pf(),hf(),mf()]})}),vf=ce((e,n)=>{n.exports=gf()}),yf=ce((e,n)=>{var t=$e(),r=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$"),l=new RegExp("^([0-9][0-9][0-9][0-9])-([0-9][0-9]?)-([0-9][0-9]?)(?:[Tt]|[ \\t]+)([0-9][0-9]?):([0-9][0-9]):([0-9][0-9])(?:\\.([0-9]*))?(?:[ \\t]*(Z|([-+])([0-9][0-9]?)(?::([0-9][0-9]))?))?$");function o(s){return s===null?!1:r.exec(s)!==null||l.exec(s)!==null}function u(s){let f=0,m=null,d=r.exec(s);if(d===null&&(d=l.exec(s)),d===null)throw new Error("Date resolve error");const p=+d[1],w=+d[2]-1,C=+d[3];if(!d[4])return new Date(Date.UTC(p,w,C));const O=+d[4],J=+d[5],g=+d[6];if(d[7]){for(f=d[7].slice(0,3);f.length<3;)f+="0";f=+f}if(d[9]){const v=+d[10],k=+(d[11]||0);m=(v*60+k)*6e4,d[9]==="-"&&(m=-m)}const h=new Date(Date.UTC(p,w,C,O,J,g,f));return m&&h.setTime(h.getTime()-m),h}function a(s){return s.toISOString()}n.exports=new t("tag:yaml.org,2002:timestamp",{kind:"scalar",resolve:o,construct:u,instanceOf:Date,represent:a})}),wf=ce((e,n)=>{var t=$e();function r(l){return l==="<<"||l===null}n.exports=new t("tag:yaml.org,2002:merge",{kind:"scalar",resolve:r})}),Sf=ce((e,n)=>{var t=$e(),r=`ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/= +\r`;function l(s){if(s===null)return!1;let f=0;const m=s.length,d=r;for(let p=0;p64)){if(w<0)return!1;f+=6}}return f%8===0}function o(s){const f=s.replace(/[\r\n=]/g,""),m=f.length,d=r;let p=0;const w=[];for(let O=0;O>16&255),w.push(p>>8&255),w.push(p&255)),p=p<<6|d.indexOf(f.charAt(O));const C=m%4*6;return C===0?(w.push(p>>16&255),w.push(p>>8&255),w.push(p&255)):C===18?(w.push(p>>10&255),w.push(p>>2&255)):C===12&&w.push(p>>4&255),new Uint8Array(w)}function u(s){let f="",m=0;const d=s.length,p=r;for(let C=0;C>18&63],f+=p[m>>12&63],f+=p[m>>6&63],f+=p[m&63]),m=(m<<8)+s[C];const w=d%3;return w===0?(f+=p[m>>18&63],f+=p[m>>12&63],f+=p[m>>6&63],f+=p[m&63]):w===2?(f+=p[m>>10&63],f+=p[m>>4&63],f+=p[m<<2&63],f+=p[64]):w===1&&(f+=p[m>>2&63],f+=p[m<<4&63],f+=p[64],f+=p[64]),f}function a(s){return Object.prototype.toString.call(s)==="[object Uint8Array]"}n.exports=new t("tag:yaml.org,2002:binary",{kind:"scalar",resolve:l,construct:o,predicate:a,represent:u})}),xf=ce((e,n)=>{var t=$e(),r=Object.prototype.hasOwnProperty,l=Object.prototype.toString;function o(a){if(a===null)return!0;const s=[],f=a;for(let m=0,d=f.length;m{var t=$e(),r=Object.prototype.toString;function l(u){if(u===null)return!0;const a=u,s=new Array(a.length);for(let f=0,m=a.length;f{var t=$e(),r=Object.prototype.hasOwnProperty;function l(u){if(u===null)return!0;const a=u;for(const s in a)if(r.call(a,s)&&a[s]!==null)return!1;return!0}function o(u){return u!==null?u:{}}n.exports=new t("tag:yaml.org,2002:set",{kind:"mapping",resolve:l,construct:o})}),Ru=ce((e,n)=>{n.exports=vf().extend({implicit:[yf(),wf()],explicit:[Sf(),xf(),kf(),Cf()]})}),sh=ce((e,n)=>{var t=br(),r=el(),l=uh(),o=Ru(),u=Object.prototype.hasOwnProperty,a=1,s=2,f=3,m=4,d=1,p=2,w=3,C=/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x84\x86-\x9F\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/,O=/[\x85\u2028\u2029]/,J=/[,\[\]{}]/,g=/^(?:!|!!|![0-9A-Za-z-]+!)$/,h=/^(?:!|[^,\[\]{}])(?:%[0-9a-f]{2}|[0-9a-z\-#;/?:@&=+$,_.!~*'()\[\]])*$/i;function v(i){return Object.prototype.toString.call(i)}function k(i){return i===10||i===13}function j(i){return i===9||i===32}function T(i){return i===9||i===32||i===10||i===13}function U(i){return i===44||i===91||i===93||i===123||i===125}function $(i){if(i>=48&&i<=57)return i-48;const y=i|32;return y>=97&&y<=102?y-97+10:-1}function W(i){return i===120?2:i===117?4:i===85?8:0}function Q(i){return i>=48&&i<=57?i-48:-1}function le(i){switch(i){case 48:return"\0";case 97:return"\x07";case 98:return"\b";case 116:return" ";case 9:return" ";case 110:return` +`;case 118:return"\v";case 102:return"\f";case 114:return"\r";case 101:return"\x1B";case 32:return" ";case 34:return'"';case 47:return"/";case 92:return"\\";case 78:return"…";case 95:return" ";case 76:return"\u2028";case 80:return"\u2029";default:return""}}function en(i){return i<=65535?String.fromCharCode(i):String.fromCharCode((i-65536>>10)+55296,(i-65536&1023)+56320)}function Me(i,y,N){y==="__proto__"?Object.defineProperty(i,y,{configurable:!0,enumerable:!0,writable:!0,value:N}):i[y]=N}var dn=new Array(256),Cn=new Array(256);for(let i=0;i<256;i++)dn[i]=le(i)?1:0,Cn[i]=le(i);function oe(i,y){this.input=i,this.filename=y.filename||null,this.schema=y.schema||o,this.onWarning=y.onWarning||null,this.legacy=y.legacy||!1,this.json=y.json||!1,this.listener=y.listener||null,this.maxDepth=typeof y.maxDepth=="number"?y.maxDepth:100,this.maxMergeSeqLength=typeof y.maxMergeSeqLength=="number"?y.maxMergeSeqLength:20,this.implicitTypes=this.schema.compiledImplicit,this.typeMap=this.schema.compiledTypeMap,this.length=i.length,this.position=0,this.line=0,this.lineStart=0,this.lineIndent=0,this.depth=0,this.firstTabInLine=-1,this.documents=[],this.anchorMapTransactions=[]}function nn(i,y){const N={name:i.filename,buffer:i.input.slice(0,-1),position:i.position,line:i.line,column:i.position-i.lineStart};return N.snippet=l(N),new r(y,N)}function _(i,y){throw nn(i,y)}function K(i,y){i.onWarning&&i.onWarning.call(null,nn(i,y))}function X(i,y,N){const A=i.anchorMapTransactions;if(A.length!==0){const E=A[A.length-1];u.call(E,y)||(E[y]={existed:u.call(i.anchorMap,y),value:i.anchorMap[y]})}i.anchorMap[y]=N}function ie(i){i.anchorMapTransactions.push(Object.create(null))}function te(i){const y=i.anchorMapTransactions.pop(),N=i.anchorMapTransactions;if(N.length===0)return;const A=N[N.length-1],E=Object.keys(y);for(let z=0,c=E.length;z=0;A-=1){const E=y[N[A]];E.existed?i.anchorMap[N[A]]=E.value:delete i.anchorMap[N[A]]}}function Ee(i){return{position:i.position,line:i.line,lineStart:i.lineStart,lineIndent:i.lineIndent,firstTabInLine:i.firstTabInLine,tag:i.tag,anchor:i.anchor,kind:i.kind,result:i.result}}function tn(i,y){i.position=y.position,i.line=y.line,i.lineStart=y.lineStart,i.lineIndent=y.lineIndent,i.firstTabInLine=y.firstTabInLine,i.tag=y.tag,i.anchor=y.anchor,i.kind=y.kind,i.result=y.result}var He={YAML:function(y,N,A){y.version!==null&&_(y,"duplication of %YAML directive"),A.length!==1&&_(y,"YAML directive accepts exactly one argument");const E=/^([0-9]+)\.([0-9]+)$/.exec(A[0]);E===null&&_(y,"ill-formed argument of the YAML directive");const z=parseInt(E[1],10),c=parseInt(E[2],10);z!==1&&_(y,"unacceptable YAML version of the document"),y.version=A[0],y.checkLineBreaks=c<2,c!==1&&c!==2&&K(y,"unsupported YAML version of the document")},TAG:function(y,N,A){let E;A.length!==2&&_(y,"TAG directive accepts exactly two arguments");const z=A[0];E=A[1],g.test(z)||_(y,"ill-formed tag handle (first argument) of the TAG directive"),u.call(y.tagMap,z)&&_(y,'there is a previously declared suffix for "'+z+'" tag handle'),h.test(E)||_(y,"ill-formed tag prefix (second argument) of the TAG directive");try{E=decodeURIComponent(E)}catch{_(y,"tag prefix is malformed: "+E)}y.tagMap[z]=E}};function Se(i,y,N,A){if(y=32&&S<=1114111||_(i,"expected valid JSON character")}else C.test(E)&&_(i,"the stream contains non-printable characters");i.result+=E}}function En(i,y,N,A){t.isObject(N)||_(i,"cannot merge mappings; the provided source object is unacceptable");const E=Object.keys(N);for(let z=0,c=E.length;zi.maxMergeSeqLength&&_(i,"merge sequence length exceeded maxMergeSeqLength ("+i.maxMergeSeqLength+")");const P=new Set;for(let L=0,M=z.length;L1&&(i.result+=t.repeat(` +`,y-1))}function Tt(i,y,N){let A,E,z,c,S,F;const P=i.kind,L=i.result;let M=i.input.charCodeAt(i.position);if(T(M)||U(M)||M===35||M===38||M===42||M===33||M===124||M===62||M===39||M===34||M===37||M===64||M===96)return!1;if(M===63||M===45){const R=i.input.charCodeAt(i.position+1);if(T(R)||N&&U(R))return!1}for(i.kind="scalar",i.result="",A=E=i.position,z=!1;M!==0;){if(M===58){const R=i.input.charCodeAt(i.position+1);if(T(R)||N&&U(R))break}else if(M===35){if(T(i.input.charCodeAt(i.position-1)))break}else{if(i.position===i.lineStart&&jn(i)||N&&U(M))break;if(k(M))if(c=i.line,S=i.lineStart,F=i.lineIndent,fe(i,!1,-1),i.lineIndent>=y){z=!0,M=i.input.charCodeAt(i.position);continue}else{i.position=E,i.line=c,i.lineStart=S,i.lineIndent=F;break}}z&&(Se(i,A,E,!1),hn(i,i.line-c),A=E=i.position,z=!1),j(M)||(E=i.position+1),M=i.input.charCodeAt(++i.position)}return Se(i,A,E,!1),i.result?!0:(i.kind=P,i.result=L,!1)}function At(i,y){let N,A,E=i.input.charCodeAt(i.position);if(E!==39)return!1;for(i.kind="scalar",i.result="",i.position++,N=A=i.position;(E=i.input.charCodeAt(i.position))!==0;)if(E===39)if(Se(i,N,i.position,!0),E=i.input.charCodeAt(++i.position),E===39)N=i.position,i.position++,A=i.position;else return!0;else k(E)?(Se(i,N,A,!0),hn(i,fe(i,!1,y)),N=A=i.position):i.position===i.lineStart&&jn(i)?_(i,"unexpected end of the document within a single quoted scalar"):(i.position++,j(E)||(A=i.position));_(i,"unexpected end of the stream within a single quoted scalar")}function at(i,y){let N,A,E,z=i.input.charCodeAt(i.position);if(z!==34)return!1;for(i.kind="scalar",i.result="",i.position++,N=A=i.position;(z=i.input.charCodeAt(i.position))!==0;){if(z===34)return Se(i,N,i.position,!0),i.position++,!0;if(z===92){if(Se(i,N,i.position,!0),z=i.input.charCodeAt(++i.position),k(z))fe(i,!1,y);else if(z<256&&dn[z])i.result+=Cn[z],i.position++;else if((E=W(z))>0){let c=E,S=0;for(;c>0;c--)z=i.input.charCodeAt(++i.position),(E=$(z))>=0?S=(S<<4)+E:_(i,"expected hexadecimal character");i.result+=en(S),i.position++}else _(i,"unknown escape sequence");N=A=i.position}else k(z)?(Se(i,N,A,!0),hn(i,fe(i,!1,y)),N=A=i.position):i.position===i.lineStart&&jn(i)?_(i,"unexpected end of the document within a double quoted scalar"):(i.position++,j(z)||(A=i.position))}_(i,"unexpected end of the stream within a double quoted scalar")}function Pt(i,y){let N=!0,A,E,z;const c=i.tag;let S;const F=i.anchor;let P,L,M,R;const H=Object.create(null);let B,Y,G,ee=i.input.charCodeAt(i.position);if(ee===91)P=93,R=!1,S=[];else if(ee===123)P=125,R=!0,S={};else return!1;for(i.anchor!==null&&X(i,i.anchor,S),ee=i.input.charCodeAt(++i.position);ee!==0;){if(fe(i,!0,y),ee=i.input.charCodeAt(i.position),ee===P)return i.position++,i.tag=c,i.anchor=F,i.kind=R?"mapping":"sequence",i.result=S,!0;N?ee===44&&_(i,"expected the node content, but found ','"):_(i,"missed comma between flow collection entries"),Y=B=G=null,L=M=!1,ee===63&&T(i.input.charCodeAt(i.position+1))&&(L=M=!0,i.position++,fe(i,!0,y)),A=i.line,E=i.lineStart,z=i.position,ln(i,y,a,!1,!0),Y=i.tag,B=i.result,fe(i,!0,y),ee=i.input.charCodeAt(i.position),(M||i.line===A)&&ee===58&&(L=!0,ee=i.input.charCodeAt(++i.position),fe(i,!0,y),ln(i,y,a,!1,!0),G=i.result),R?Fe(i,S,H,Y,B,G,A,E,z):L?S.push(Fe(i,null,H,Y,B,G,A,E,z)):S.push(B),fe(i,!0,y),ee=i.input.charCodeAt(i.position),ee===44?(N=!0,ee=i.input.charCodeAt(++i.position)):N=!1}_(i,"unexpected end of the stream within a flow collection")}function Te(i,y){let N,A=d,E=!1,z=!1,c=y,S=0,F=!1,P,L=i.input.charCodeAt(i.position);if(L===124)N=!1;else if(L===62)N=!0;else return!1;for(i.kind="scalar",i.result="";L!==0;)if(L=i.input.charCodeAt(++i.position),L===43||L===45)d===A?A=L===43?w:p:_(i,"repeat of a chomping mode identifier");else if((P=Q(L))>=0)P===0?_(i,"bad explicit indentation width of a block scalar; it cannot be less than one"):z?_(i,"repeat of an indentation width identifier"):(c=y+P-1,z=!0);else break;if(j(L)){do L=i.input.charCodeAt(++i.position);while(j(L));if(L===35)do L=i.input.charCodeAt(++i.position);while(!k(L)&&L!==0)}for(;L!==0;){for(Hn(i),i.lineIndent=0,L=i.input.charCodeAt(i.position);(!z||i.lineIndentc&&(c=i.lineIndent),k(L)){S++;continue}if(!z&&c===0&&_(i,"missing indentation for block scalar"),i.lineIndenty)&&c!==0)_(i,"bad indentation of a sequence entry");else if(i.lineIndenty)&&(B&&(E=i.line,z=i.lineStart,c=i.position),ln(i,y,m,!0,A)&&(B?R=i.result:H=i.result),B||(Fe(i,P,L,M,R,H,E,z,c),M=R=H=null),fe(i,!0,-1),G=i.input.charCodeAt(i.position)),(i.line===pe||i.lineIndent>y)&&G!==0)_(i,"bad indentation of a mapping entry");else if(i.lineIndent=i.maxDepth&&_(i,"nesting exceeded maxDepth ("+i.maxDepth+")"),i.depth+=1,i.listener!==null&&i.listener("open",i),i.tag=null,i.anchor=null,i.kind=null,i.result=null;const B=z=c=m===N||f===N;if(A&&fe(i,!0,-1)&&(F=!0,i.lineIndent>y?S=1:i.lineIndent===y?S=0:i.lineIndenty?S=1:i.lineIndent===y?S=0:i.lineIndent tag; it should be "scalar", not "'+i.kind+'"');for(let Y=0,G=i.implicitTypes.length;Y"),i.result!==null&&M.kind!==i.kind&&_(i,"unacceptable node kind for !<"+i.tag+'> tag; it should be "'+M.kind+'", not "'+i.kind+'"'),M.resolve(i.result,i.tag)?(i.result=M.construct(i.result,i.tag),i.anchor!==null&&X(i,i.anchor,i.result)):_(i,"cannot resolve a node with !<"+i.tag+"> explicit tag")}return i.listener!==null&&i.listener("close",i),i.depth-=1,i.tag!==null||i.anchor!==null||P}function Ot(i){const y=i.position;let N=!1,A;for(i.version=null,i.checkLineBreaks=i.legacy,i.tagMap=Object.create(null),i.anchorMap=Object.create(null);(A=i.input.charCodeAt(i.position))!==0&&(fe(i,!0,-1),A=i.input.charCodeAt(i.position),!(i.lineIndent>0||A!==37));){N=!0,A=i.input.charCodeAt(++i.position);let E=i.position;for(;A!==0&&!T(A);)A=i.input.charCodeAt(++i.position);const z=i.input.slice(E,i.position),c=[];for(z.length<1&&_(i,"directive name must not be less than one character in length");A!==0;){for(;j(A);)A=i.input.charCodeAt(++i.position);if(A===35){do A=i.input.charCodeAt(++i.position);while(A!==0&&!k(A));break}if(k(A))break;for(E=i.position;A!==0&&!T(A);)A=i.input.charCodeAt(++i.position);c.push(i.input.slice(E,i.position))}A!==0&&Hn(i),u.call(He,z)?He[z](i,z,c):K(i,'unknown document directive "'+z+'"')}if(fe(i,!0,-1),i.lineIndent===0&&i.input.charCodeAt(i.position)===45&&i.input.charCodeAt(i.position+1)===45&&i.input.charCodeAt(i.position+2)===45?(i.position+=3,fe(i,!0,-1)):N&&_(i,"directives end mark is expected"),ln(i,i.lineIndent-1,m,!1,!0),fe(i,!0,-1),i.checkLineBreaks&&O.test(i.input.slice(y,i.position))&&K(i,"non-ASCII line breaks are interpreted as content"),i.documents.push(i.result),i.position===i.lineStart&&jn(i)){i.input.charCodeAt(i.position)===46&&(i.position+=3,fe(i,!0,-1));return}i.position"u"&&(N=y,y=null);const A=dt(i,N);if(typeof y!="function")return A;for(let E=0,z=A.length;E{var t=br(),r=el(),l=Ru(),o=Object.prototype.toString,u=Object.prototype.hasOwnProperty,a=65279,s=9,f=10,m=13,d=32,p=33,w=34,C=35,O=37,J=38,g=39,h=42,v=44,k=45,j=58,T=61,U=62,$=63,W=64,Q=91,le=93,en=96,Me=123,dn=124,Cn=125,oe={};oe[0]="\\0",oe[7]="\\a",oe[8]="\\b",oe[9]="\\t",oe[10]="\\n",oe[11]="\\v",oe[12]="\\f",oe[13]="\\r",oe[27]="\\e",oe[34]='\\"',oe[92]="\\\\",oe[133]="\\N",oe[160]="\\_",oe[8232]="\\L",oe[8233]="\\P";var nn=["y","Y","yes","Yes","YES","on","On","ON","n","N","no","No","NO","off","Off","OFF"],_=/^[-+]?[0-9_]+(?::[0-9_]+)+(?:\.[0-9_]*)?$/;function K(c,S){if(S===null)return{};const F={},P=Object.keys(S);for(let L=0,M=P.length;L=32&&c<=126||c>=161&&c<=55295&&c!==8232&&c!==8233||c>=57344&&c<=65533&&c!==a||c>=65536&&c<=1114111}function Fe(c){return En(c)&&c!==a&&c!==m&&c!==f}function Hn(c,S,F){const P=Fe(c),L=P&&!Se(c);return(F?P:P&&c!==v&&c!==Q&&c!==le&&c!==Me&&c!==Cn)&&c!==C&&!(S===j&&!L)||Fe(S)&&!Se(S)&&c===C||S===j&&L}function fe(c){return En(c)&&c!==a&&!Se(c)&&c!==k&&c!==$&&c!==j&&c!==v&&c!==Q&&c!==le&&c!==Me&&c!==Cn&&c!==C&&c!==J&&c!==h&&c!==p&&c!==dn&&c!==T&&c!==U&&c!==g&&c!==w&&c!==O&&c!==W&&c!==en}function jn(c){return!Se(c)&&c!==j}function hn(c,S){const F=c.charCodeAt(S);let P;return F>=55296&&F<=56319&&S+1=56320&&P<=57343)?(F-55296)*1024+P-56320+65536:F}function Tt(c){return/^\n* /.test(c)}var At=1,at=2,Pt=3,Te=4,rn=5;function _n(c,S,F,P,L,M,R,H){let B,Y=0,G=null,ee=!1,pe=!1;const Iu=P!==-1;let sr=-1,ar=fe(hn(c,0))&&jn(hn(c,c.length-1));if(S||R)for(B=0;B=65536?B+=2:B++){if(Y=hn(c,B),!En(Y))return rn;ar=ar&&Hn(Y,G,H),G=Y}else{for(B=0;B=65536?B+=2:B++){if(Y=hn(c,B),Y===f)ee=!0,Iu&&(pe=pe||B-sr-1>P&&c[sr+1]!==" ",sr=B);else if(!En(Y))return rn;ar=ar&&Hn(Y,G,H),G=Y}pe=pe||Iu&&B-sr-1>P&&c[sr+1]!==" "}return!ee&&!pe?ar&&!R&&!L(c)?At:M===te?rn:at:F>9&&Tt(c)?rn:R?M===te?rn:at:pe?Te:Pt}function Lt(c,S,F,P,L){c.dump=function(){if(S.length===0)return c.quotingType===te?'""':"''";if(!c.noCompatMode&&(nn.indexOf(S)!==-1||_.test(S)))return c.quotingType===te?'"'+S+'"':"'"+S+"'";const M=c.indent*Math.max(1,F),R=c.lineWidth===-1?-1:Math.max(Math.min(c.lineWidth,40),c.lineWidth-M),H=P||c.flowLevel>-1&&F>=c.flowLevel;function B(Y){return He(c,Y)}switch(_n(S,H,c.indent,R,B,c.quotingType,c.forceQuotes&&!P,L)){case At:return S;case at:return"'"+S.replace(/'/g,"''")+"'";case Pt:return"|"+ct(S,c.indent)+ft(Ee(S,M));case Te:return">"+ct(S,c.indent)+ft(Ee(jt(S,R),M));case rn:return'"'+Ot(S)+'"';default:throw new r("impossible error: invalid scalar style")}}()}function ct(c,S){const F=Tt(c)?String(S):"",P=c[c.length-1]===` +`;return F+(P&&(c[c.length-2]===` +`||c===` +`)?"+":P?"":"-")+` +`}function ft(c){return c[c.length-1]===` +`?c.slice(0,-1):c}function jt(c,S){const F=/(\n+)([^\n]*)/g;let P=function(){let H=c.indexOf(` +`);return H=H!==-1?H:c.length,F.lastIndex=H,ln(c.slice(0,H),S)}(),L=c[0]===` +`||c[0]===" ",M,R;for(;R=F.exec(c);){const H=R[1],B=R[2];M=B[0]===" ",P+=H+(!L&&!M&&B!==""?` +`:"")+ln(B,S),L=M}return P}function ln(c,S){if(c===""||c[0]===" ")return c;const F=/ [^ ]/g;let P,L=0,M,R=0,H=0,B="";for(;P=F.exec(c);)H=P.index,H-L>S&&(M=R>L?R:H,B+=` +`+c.slice(L,M),L=M+1),R=H;return B+=` +`,c.length-L>S&&R>L?B+=c.slice(L,R)+` +`+c.slice(R+1):B+=c.slice(L),B.slice(1)}function Ot(c){let S="",F=0;for(let P=0;P=65536?P+=2:P++){F=hn(c,P);const L=oe[F];!L&&En(F)?(S+=c[P],F>=65536&&(S+=c[P+1])):S+=L||X(F)}return S}function dt(c,S,F){let P="";const L=c.tag;for(let M=0,R=F.length;M"u"&&N(c,S,null,!1,!1))&&(P!==""&&(P+=","+(c.condenseFlow?"":" ")),P+=c.dump)}c.tag=L,c.dump="["+P+"]"}function I(c,S,F,P){let L="";const M=c.tag;for(let R=0,H=F.length;R"u"&&N(c,S+1,null,!0,!0,!1,!0))&&((!P||L!=="")&&(L+=tn(c,S)),c.dump&&f===c.dump.charCodeAt(0)?L+="-":L+="- ",L+=c.dump)}c.tag=M,c.dump=L||"[]"}function q(c,S,F){let P="";const L=c.tag,M=Object.keys(F);for(let R=0,H=M.length;R1024&&(B+="? "),B+=c.dump+(c.condenseFlow?'"':"")+":"+(c.condenseFlow?"":" "),N(c,S,G,!1,!1)&&(B+=c.dump,P+=B))}c.tag=L,c.dump="{"+P+"}"}function i(c,S,F,P){let L="";const M=c.tag,R=Object.keys(F);if(c.sortKeys===!0)R.sort();else if(typeof c.sortKeys=="function")R.sort(c.sortKeys);else if(c.sortKeys)throw new r("sortKeys must be a boolean or a function");for(let H=0,B=R.length;H1024;pe&&(c.dump&&f===c.dump.charCodeAt(0)?Y+="?":Y+="? "),Y+=c.dump,pe&&(Y+=tn(c,S)),N(c,S+1,ee,!0,pe)&&(c.dump&&f===c.dump.charCodeAt(0)?Y+=":":Y+=": ",Y+=c.dump,L+=Y)}c.tag=M,c.dump=L||"{}"}function y(c,S,F){const P=F?c.explicitTypes:c.implicitTypes;for(let L=0,M=P.length;L tag resolver accepts not "'+H+'" style');c.dump=B}return!0}}return!1}function N(c,S,F,P,L,M,R){c.tag=null,c.dump=F,y(c,F,!1)||y(c,F,!0);const H=o.call(c.dump),B=P;P&&(P=c.flowLevel<0||c.flowLevel>S);const Y=H==="[object Object]"||H==="[object Array]";let G,ee;if(Y&&(G=c.duplicates.indexOf(F),ee=G!==-1),(c.tag!==null&&c.tag!=="?"||ee||c.indent!==2&&S>0)&&(L=!1),ee&&c.usedDuplicates[G])c.dump="*ref_"+G;else{if(Y&&ee&&!c.usedDuplicates[G]&&(c.usedDuplicates[G]=!0),H==="[object Object]")P&&Object.keys(c.dump).length!==0?(i(c,S,c.dump,L),ee&&(c.dump="&ref_"+G+c.dump)):(q(c,S,c.dump),ee&&(c.dump="&ref_"+G+" "+c.dump));else if(H==="[object Array]")P&&c.dump.length!==0?(c.noArrayIndent&&!R&&S>0?I(c,S-1,c.dump,L):I(c,S,c.dump,L),ee&&(c.dump="&ref_"+G+c.dump)):(dt(c,S,c.dump),ee&&(c.dump="&ref_"+G+" "+c.dump));else if(H==="[object String]")c.tag!=="?"&&Lt(c,c.dump,S,M,B);else{if(H==="[object Undefined]")return!1;if(c.skipInvalid)return!1;throw new r("unacceptable kind of an object to dump "+H)}if(c.tag!==null&&c.tag!=="?"){let pe=encodeURI(c.tag[0]==="!"?c.tag.slice(1):c.tag).replace(/!/g,"%21");c.tag[0]==="!"?pe="!"+pe:pe.slice(0,18)==="tag:yaml.org,2002:"?pe="!!"+pe.slice(18):pe="!<"+pe+">",c.dump=pe+" "+c.dump}}return!0}function A(c,S){const F=[],P=[];E(c,F,P);const L=P.length;for(let M=0;M{var t=sh(),r=ah();function l(o,u){return function(){throw new Error("Function yaml."+o+" is removed in js-yaml 4. Use yaml."+u+" instead, which is now safe by default.")}}n.exports.Type=$e(),n.exports.Schema=uf(),n.exports.FAILSAFE_SCHEMA=ff(),n.exports.JSON_SCHEMA=gf(),n.exports.CORE_SCHEMA=vf(),n.exports.DEFAULT_SCHEMA=Ru(),n.exports.load=t.load,n.exports.loadAll=t.loadAll,n.exports.dump=r.dump,n.exports.YAMLException=el(),n.exports.types={binary:Sf(),float:mf(),map:cf(),null:df(),pairs:kf(),set:Cf(),timestamp:yf(),bool:pf(),int:hf(),merge:wf(),omap:xf(),seq:af(),str:sf()},n.exports.safeLoad=l("safeLoad","load"),n.exports.safeLoadAll=l("safeLoadAll","loadAll"),n.exports.safeDump=l("safeDump","dump")})()),{Type:kh,Schema:Ch,FAILSAFE_SCHEMA:Eh,JSON_SCHEMA:_h,CORE_SCHEMA:Nh,DEFAULT_SCHEMA:Th,load:Ah,loadAll:Ph,dump:Lh,YAMLException:jh,types:Oh,safeLoad:Rh,safeLoadAll:Ih,safeDump:Mh}=Ef.default,Ws=Ef.default;async function gn(e){if(!e.ok)throw new Error(await e.text()||e.statusText);return e.json()}const yl={"Content-Type":"application/json"},ye={recipes:()=>fetch("/api/recipes").then(gn),upload:e=>{const n=new FormData;return n.append("file",e),fetch("/api/upload",{method:"POST",body:n}).then(gn)},createProject:e=>fetch("/api/projects",{method:"POST",headers:yl,body:JSON.stringify(e)}).then(gn),getProject:e=>fetch(`/api/projects/${e}`).then(gn),updateProject:(e,n)=>fetch(`/api/projects/${e}`,{method:"PUT",headers:yl,body:JSON.stringify(n)}).then(gn),previewProject:(e,n=!1)=>fetch(`/api/projects/${e}/preview`,{method:"POST",headers:yl,body:JSON.stringify({draft:n})}).then(gn),previewSegment:(e,n,t=!0)=>fetch(`/api/projects/${e}/preview_segment`,{method:"POST",headers:yl,body:JSON.stringify({index:n,draft:t})}).then(gn),generateProject:e=>fetch(`/api/projects/${e}/generate`,{method:"POST"}).then(gn),job:e=>fetch(`/api/jobs/${e}`).then(gn),runs:()=>fetch("/api/runs").then(gn),run:e=>fetch(`/api/runs/${e}`).then(gn),finalUrl:e=>`/api/runs/${e}/final`,previewUrl:e=>`/api/runs/${e}/files/fluid_preview.mp4`,segPreviewUrl:e=>`/api/runs/${e}/files/segment_preview.mp4`,posterUrl:e=>`/api/runs/${e}/latest_frame`,fileUrl:(e,n)=>`/api/runs/${e}/files/${n}`,watchJob:(e,n)=>{const t=location.protocol==="https:"?"wss":"ws",r=new WebSocket(`${t}://${location.host}/ws/jobs/${e}`);return r.onmessage=l=>n(JSON.parse(l.data)),r}},wl=["analyze","simulate","control","diffuse","post"],ch={drop:"rgba(184,74,116,0.30)",build:"rgba(184,74,116,0.16)",intro:"rgba(52,128,138,0.16)",outro:"rgba(52,128,138,0.16)",verse:"rgba(140,149,161,0.14)"},fh=6;function dh(e){const{waveform:n,duration:t,segments:r,selected:l,beats:o,onsets:u,playhead:a,onSelect:s,onSeek:f,onMoveBoundary:m}=e,d=Z.useRef(null),[p,w]=Z.useState(null),[C,O]=Z.useState(null);Z.useEffect(()=>{const h=d.current;if(!h)return;const v=window.devicePixelRatio||1,k=h.clientWidth,j=h.clientHeight;h.width=k*v,h.height=j*v;const T=h.getContext("2d");T.scale(v,v),T.clearRect(0,0,k,j);const U=W=>W/t*k;r.forEach((W,Q)=>{T.fillStyle=ch[W.label]||"rgba(140,149,161,0.10)",T.fillRect(U(W.start),0,U(W.end)-U(W.start),j),Q===l&&(T.strokeStyle="#b84a74",T.lineWidth=2,T.strokeRect(U(W.start)+1,1,U(W.end)-U(W.start)-2,j-2))});for(let W=1;W{const Q=U(W);T.beginPath(),T.moveTo(Q,j-10),T.lineTo(Q,j),T.stroke()}),T.fillStyle="#e0a458",u.low.forEach(W=>{T.beginPath(),T.arc(U(W),j-16,2.2,0,7),T.fill()}),T.fillStyle="#8fc7bc",u.high.forEach(W=>{T.beginPath(),T.arc(U(W),8,1.6,0,7),T.fill()}),T.strokeStyle="#8fc7bc",T.lineWidth=1;const $=j/2;if(T.beginPath(),n.forEach((W,Q)=>{const le=Q/n.length*k;T.moveTo(le,$-W*$*.8),T.lineTo(le,$+W*$*.8)}),T.stroke(),a>0){const W=U(Math.min(a,t));T.strokeStyle="#ffffff",T.lineWidth=1.5,T.beginPath(),T.moveTo(W,0),T.lineTo(W,j),T.stroke()}},[n,t,r,l,o,u,a,p,C]);const J=h=>{const v=d.current.getBoundingClientRect();return Math.max(0,Math.min(t,(h-v.left)/v.width*t))},g=h=>{const v=d.current.getBoundingClientRect();for(let k=1;k{const v=g(h.clientX);v!=null&&w(v)},onMouseMove:h=>{p!=null?m(p,J(h.clientX)):O(g(h.clientX))},onMouseUp:h=>{if(p!=null){w(null);return}const v=J(h.clientX);f(v);const k=r.findIndex(j=>v>=j.start&&v=0&&s(k)},onMouseLeave:()=>{w(null),O(null)}}),x.jsx("div",{className:"sec-row",children:r.map((h,v)=>x.jsxs("span",{className:`sec-chip ${h.label} ${v===l?"sel":""}`,onClick:()=>s(v),style:{cursor:"pointer"},children:[h.label," · ",h.start.toFixed(1),"–",h.end.toFixed(1),"s"]},v))}),x.jsx("p",{className:"muted",style:{marginTop:8},children:"Click to select & seek · drag a boundary handle to move it · dots = detected kicks (amber) and hats (teal)."})]})}const Sl=.4;function Ys(e,n,t){var o;const r=structuredClone(e||{});let l=r;for(let u=0;u{ye.recipes().then(r).catch(()=>{})},[]);const X=I=>{a(I.run_id),f(I.project),I.analysis&&d(I.analysis),w(I.audio_url??null),O(0)};Z.useEffect(()=>{e&&ye.getProject(e).then(X).catch(I=>j(String(I.message||I)))},[e]),Z.useEffect(()=>{if(!oe)return;let I=0;const q=()=>{const i=_.current;i&&Cn(i.currentTime),I=requestAnimationFrame(q)};return I=requestAnimationFrame(q),()=>cancelAnimationFrame(I)},[oe]);const ie=Z.useCallback(I=>{u&&(window.clearTimeout(K.current),K.current=window.setTimeout(()=>{ye.updateProject(u,{segments:I}).catch(()=>{})},700))},[u]),te=I=>{f(q=>q&&{...q,segments:I}),ie(I)},pn=async I=>{j(""),g(!0);try{const{audio_id:q}=await ye.upload(I);X(await ye.createProject({audio_id:q,recipe_name:l}))}catch(q){j(String(q.message||q))}finally{g(!1)}},Ee=I=>{s&&te(s.segments.map((q,i)=>i===C?{...q,...I}:q))},tn=(I,q)=>Ee({fluid:Ys(s.segments[C].fluid,I,q)}),He=(I,q)=>{if(!s)return;const i=structuredClone(s.segments),y=i[I-1].start+Sl,N=i[I].end-Sl,A=Math.max(y,Math.min(N,q));i[I-1].end=A,i[I].start=A,te(i)},Se=()=>{if(!s)return;const I=dn,q=s.segments.findIndex(A=>I>A.start+Sl&&I{if(!s||C>=s.segments.length-1)return;const I=structuredClone(s.segments);I[C].end=I[C+1].end,I.splice(C+1,1),te(I)},Fe=(I,q)=>{if(!s||!u)return;const i=Ys(s.recipe,I,q);f({...s,recipe:i}),window.clearTimeout(K.current),K.current=window.setTimeout(()=>{ye.updateProject(u,{recipe:i}).catch(()=>{})},700)},Hn=()=>{le(Ws.dump((s==null?void 0:s.recipe)??{},{noRefs:!0})),Me(""),W("yaml")},fe=async()=>{if(!(!s||!u))try{const I=Ws.load(Q);f({...s,recipe:I}),await ye.updateProject(u,{recipe:I}),Me(""),W("recipe")}catch(I){Me(String(I.message||I))}},jn=async()=>{!u||!s||(window.clearTimeout(K.current),await ye.updateProject(u,{segments:s.segments,recipe:s.recipe}))},hn=async()=>{if(u){g(!0),j("");try{await jn();const{job_id:I}=await ye.previewSegment(u,C,T);n(u,I)}catch(I){j(String(I.message||I)),g(!1)}}},Tt=async()=>{if(u){g(!0),j("");try{await jn();const{job_id:I}=await ye.previewProject(u,T);n(u,I)}catch(I){j(String(I.message||I)),g(!1)}}},At=()=>{const I=_.current;I&&(I.paused?(I.play(),nn(!0)):(I.pause(),nn(!1)))},at=I=>{const q=_.current;q&&(q.currentTime=I),Cn(I)},Pt=()=>Ee({fluid:{}}),Te=s==null?void 0:s.segments[C],rn=(s==null?void 0:s.segments.length)??0,_n=((ft=(ct=s==null?void 0:s.recipe)==null?void 0:ct.fluid)==null?void 0:ft.palette)??[],Lt=([I,q,i,y,N,A])=>{const E=Te?ph(Te.fluid,q):!1;return x.jsxs("div",{className:"slider-row",children:[x.jsxs("label",{className:`field ${E?"ov":""}`,children:[E&&x.jsx("span",{className:"ov-dot",title:"overridden for this segment",children:"●"}),I," ",x.jsx("span",{className:"val",children:Qs(Te.fluid,q,i)})]}),x.jsx("input",{type:"range",min:y,max:N,step:A,value:Qs(Te.fluid,q,i),onChange:z=>tn(q,parseFloat(z.target.value))})]},I)};return x.jsxs("div",{className:"grid",children:[x.jsxs("div",{children:[!s&&x.jsxs("div",{className:`drop ${h?"hover":""}`,onDragOver:I=>{I.preventDefault(),v(!0)},onDragLeave:()=>v(!1),onDrop:I=>{I.preventDefault(),v(!1);const q=I.dataTransfer.files[0];q&&pn(q)},children:[x.jsx("p",{style:{fontSize:18,marginBottom:10},children:"Drop an audio file here"}),x.jsx("p",{className:"muted",children:"analysis splits it into editable segments"}),x.jsxs("label",{className:"btn ghost",style:{display:"inline-block",width:"auto",marginTop:12},children:["Choose file",x.jsx("input",{type:"file",accept:"audio/*",style:{display:"none"},onChange:I=>{var q;return((q=I.target.files)==null?void 0:q[0])&&pn(I.target.files[0])}})]}),x.jsxs("div",{style:{marginTop:18},children:[x.jsx("label",{className:"field",style:{textAlign:"center"},children:"Visual identity"}),x.jsx("select",{value:l,onChange:I=>o(I.target.value),style:{maxWidth:240,margin:"0 auto"},children:t.map(I=>x.jsx("option",{value:I.name,children:I.name},I.name))})]})]}),s&&m&&x.jsxs("div",{className:"card",children:[x.jsxs("div",{className:"transport",children:[x.jsx("button",{className:"play",onClick:At,children:oe?"❚❚":"▶"}),x.jsxs("span",{className:"mono muted",children:[dn.toFixed(1),"s / ",m.duration_s.toFixed(1),"s"]}),x.jsxs("span",{className:"mono muted",style:{marginLeft:"auto"},children:[m.tempo_bpm," BPM · ",s.segments.length," segments"]})]}),p&&x.jsx("audio",{ref:_,src:p,onEnded:()=>nn(!1)}),x.jsx(dh,{waveform:m.waveform,duration:m.duration_s,segments:s.segments,selected:C,beats:(m.beats||[]).map(I=>I.t),onsets:{low:((jt=m.onsets)==null?void 0:jt.low)??[],high:((ln=m.onsets)==null?void 0:ln.high)??[]},playhead:dn,onSelect:O,onSeek:at,onMoveBoundary:He}),x.jsxs("div",{className:"seg-ops",children:[x.jsx("button",{className:"btn ghost slim",onClick:Se,children:"Split at playhead"}),x.jsx("button",{className:"btn ghost slim",onClick:En,disabled:!s||C>=s.segments.length-1,children:"Merge with next"})]})]}),k&&x.jsx("p",{className:"err",children:k})]}),x.jsx("aside",{children:s&&x.jsxs("div",{className:"card inspector",children:[x.jsxs("div",{className:"insp-tabs",children:[x.jsx("button",{className:$==="segment"?"active":"",onClick:()=>W("segment"),children:"Segment"}),x.jsx("button",{className:$==="recipe"?"active":"",onClick:()=>W("recipe"),children:"Recipe"}),x.jsx("button",{className:$==="yaml"?"active":"",onClick:Hn,children:"YAML"})]}),x.jsxs("div",{className:"insp-body",children:[$==="segment"&&Te&&x.jsxs(x.Fragment,{children:[x.jsxs("div",{className:"seg-nav",children:[x.jsx("button",{disabled:C===0,onClick:()=>O(C-1),children:"‹"}),x.jsxs("span",{className:"mono",children:[C+1,"/",rn," · ",Te.start.toFixed(1),"–",Te.end.toFixed(1),"s"]}),x.jsx("button",{disabled:C>=rn-1,onClick:()=>O(C+1),children:"›"})]}),x.jsx("label",{className:"field",children:"Label"}),x.jsx("input",{type:"text",value:Te.label,onChange:I=>Ee({label:I.target.value})}),x.jsx("label",{className:"field",children:"Prompt (diffusion)"}),x.jsx("textarea",{value:Te.prompt,onChange:I=>Ee({prompt:I.target.value})}),hh.map(Lt),x.jsxs("details",{className:"advanced",children:[x.jsx("summary",{children:"More settings"}),mh.map(Lt)]}),x.jsx("button",{className:"btn ghost slim reset",onClick:Pt,children:"Reset segment to recipe defaults"})]}),$==="recipe"&&x.jsxs(x.Fragment,{children:[x.jsx("p",{className:"muted",style:{fontSize:12,marginBottom:8},children:"Global defaults — segments inherit these unless overridden."}),x.jsx("label",{className:"field",children:"Seed"}),x.jsx("input",{type:"number",value:s.recipe.seed??0,onChange:I=>Fe(["seed"],parseInt(I.target.value)||0)}),x.jsx("label",{className:"field",children:"Palette"}),x.jsxs("div",{className:"palette-row",children:[_n.map((I,q)=>x.jsx("input",{type:"color",value:I,onChange:i=>{const y=[..._n];y[q]=i.target.value,Fe(["fluid","palette"],y)}},q)),x.jsx("button",{className:"btn ghost slim",onClick:()=>Fe(["fluid","palette"],[..._n,"#888888"]),children:"+"}),_n.length>1&&x.jsx("button",{className:"btn ghost slim",onClick:()=>Fe(["fluid","palette"],_n.slice(0,-1)),children:"−"})]}),x.jsx("p",{className:"muted",style:{fontSize:12},children:"First colour = kicks; the rest cycle on hats."}),x.jsxs("label",{className:"field",children:["Denoise strength ",x.jsx("span",{className:"val",children:((Ot=s.recipe.diffusion)==null?void 0:Ot.strength)??.5})]}),x.jsx("input",{type:"range",min:.1,max:.9,step:.05,value:((dt=s.recipe.diffusion)==null?void 0:dt.strength)??.5,onChange:I=>Fe(["diffusion","strength"],parseFloat(I.target.value))})]}),$==="yaml"&&x.jsxs(x.Fragment,{children:[x.jsx("p",{className:"muted",style:{fontSize:12,marginBottom:8},children:"Full recipe — total control. Apply to use it."}),x.jsx("textarea",{className:"yaml",value:Q,onChange:I=>le(I.target.value),spellCheck:!1}),en&&x.jsx("p",{className:"err",children:en}),x.jsx("button",{className:"btn ghost",onClick:fe,children:"Apply YAML"})]})]}),x.jsxs("div",{className:"insp-footer",children:[x.jsxs("label",{className:"check",title:"Caps resolution for fast iteration; the final Generate always runs full quality.",children:[x.jsx("input",{type:"checkbox",checked:T,onChange:I=>U(I.target.checked)}),"Draft quality (fast)"]}),x.jsx("button",{className:"btn",disabled:J||!u,onClick:hn,children:J?"Working…":`Preview segment (${Te?(Te.end-Te.start).toFixed(1):"?"}s)`}),x.jsx("button",{className:"btn ghost",disabled:J||!u,onClick:Tt,children:"Preview full track"})]})]})})]})}const vh=1200;function yh({runId:e,jobId:n,onSeeGallery:t}){const[r,l]=Z.useState(null),[o,u]=Z.useState(null),[a,s]=Z.useState(null),f=Z.useRef(null),m=k=>{var j;(j=f.current)==null||j.close(),l(null),s(k),f.current=ye.watchJob(k,l)};Z.useEffect(()=>(n&&m(n),()=>{var k;return(k=f.current)==null?void 0:k.close()}),[n]);const d=(r==null?void 0:r.status)==="running";Z.useEffect(()=>{if(!d||!e){u(null);return}const k=window.setInterval(()=>{u(`/api/runs/${e}/latest_frame?ts=${Date.now()}`)},vh);return()=>window.clearInterval(k)},[d,e]);const p=async()=>{a&&await fetch(`/api/jobs/${a}/cancel`,{method:"POST"}).catch(()=>{})},w=async()=>{if(!e)return;const{job_id:k}=await ye.generateProject(e);m(k)};if(!n||!e)return x.jsx("div",{className:"card",style:{marginTop:26},children:x.jsx("p",{className:"muted",children:"No active render. Start a fluid preview from the Studio."})});const C=(r==null?void 0:r.kind)||"fluid",O=r!=null&&r.stage?wl.indexOf(r.stage):-1,J=(r==null?void 0:r.status)==="done",g=C==="fluid_segment",h=C==="fluid"||g,v=g?["simulate","post"]:h?wl.filter(k=>k!=="diffuse"&&k!=="control"):wl;return x.jsxs("div",{className:"grid",children:[x.jsxs("div",{className:"card",children:[x.jsx("h3",{children:g?"Segment preview":h?"Fluid preview":"Diffusion"}),x.jsx("div",{className:"pipeline",children:v.map(k=>{const j=(r==null?void 0:r.stage)===k&&r.status==="running",T=J||O>wl.indexOf(k)||(r==null?void 0:r.stage)===k&&(r==null?void 0:r.total)>0&&(r==null?void 0:r.done)===(r==null?void 0:r.total),U=T?100:j&&(r!=null&&r.total)?Math.round(r.done/r.total*100):0;return x.jsxs("div",{className:`stage-row ${T?"done":""}`,children:[x.jsx("span",{className:"stage-name",children:k}),x.jsx("div",{className:"bar",children:x.jsx("i",{style:{width:`${U}%`}})}),x.jsx("span",{className:"pct",children:T?"done":j?`${U}%`:"—"})]},k)})}),(r==null?void 0:r.status)==="error"&&x.jsxs("p",{className:"err",children:["Error: ",r.error]}),(r==null?void 0:r.status)==="cancelled"&&x.jsx("p",{className:"muted",children:"Cancelled."}),d&&x.jsx("button",{className:"btn ghost slim",style:{marginTop:14},onClick:p,children:"Cancel render"}),d&&o&&x.jsxs("div",{style:{marginTop:14},children:[x.jsx("p",{className:"muted",style:{fontSize:12,marginBottom:6},children:"Live frame"}),x.jsx("img",{className:"live-frame",src:o,onError:k=>k.target.style.display="none",onLoad:k=>k.target.style.display="block"})]})]}),x.jsxs("aside",{className:"card",children:[x.jsx("h3",{children:J?h?"Fluid result":"Final clip":"Rendering…"}),J?x.jsxs(x.Fragment,{children:[x.jsx("video",{src:g?ye.segPreviewUrl(e):h?ye.previewUrl(e):ye.finalUrl(e),poster:ye.posterUrl(e),controls:!0,autoPlay:!0,loop:!0}),h?x.jsxs(x.Fragment,{children:[x.jsx("p",{className:"muted",style:{margin:"8px 0"},children:"Happy with the motion? Run the diffusion to get the final clip."}),x.jsx("button",{className:"btn",onClick:w,children:"Generate final (diffusion)"})]}):x.jsx("button",{className:"btn ghost",onClick:t,children:"See in Gallery"})]}):x.jsx("p",{className:"muted",children:(r==null?void 0:r.status)==="error"?"Render failed — see the pipeline.":"Working… the result appears here when ready."})]})]})}function _f(e){return e.stage==="done"||e.status==="done"?ye.finalUrl(e.id):e.fluid_preview?ye.previewUrl(e.id):null}function wh({a:e,b:n,onClose:t}){var m;const r=Z.useRef(null),l=Z.useRef(null),[o,u]=Z.useState(!1),a=d=>{r.current&&d(r.current),l.current&&d(l.current)},s=()=>{a(o?d=>d.pause():d=>{var p;d.currentTime=((p=r.current)==null?void 0:p.currentTime)??0,d.play()}),u(!o)},f=d=>a(p=>{p.currentTime=d});return x.jsxs("div",{className:"card compare",children:[x.jsxs("div",{className:"compare-head",children:[x.jsx("h3",{children:"Compare"}),x.jsx("button",{className:"btn ghost slim",onClick:t,children:"Close"})]}),x.jsx("div",{className:"compare-grid",children:[{r:e,ref:r},{r:n,ref:l}].map(({r:d,ref:p})=>x.jsxs("div",{children:[x.jsx("video",{ref:p,src:_f(d)??void 0,poster:ye.posterUrl(d.id),preload:"auto",muted:p===l}),x.jsxs("p",{className:"muted mono",style:{fontSize:11},children:[d.recipe," · ",d.id]})]},d.id))}),x.jsxs("div",{className:"transport",style:{marginTop:10},children:[x.jsx("button",{className:"play",onClick:s,children:o?"❚❚":"▶"}),x.jsx("input",{type:"range",min:0,max:((m=r.current)==null?void 0:m.duration)||60,step:.05,style:{flex:1},defaultValue:0,onChange:d=>f(parseFloat(d.target.value))})]}),x.jsx("p",{className:"muted",style:{fontSize:12},children:"Same audio timeline, both videos locked together — judge one parameter change."})]})}function Sh({onOpenInStudio:e}){const[n,t]=Z.useState([]),[r,l]=Z.useState([]),[o,u]=Z.useState(!1);Z.useEffect(()=>{ye.runs().then(t).catch(()=>t([]))},[]);const a=f=>l(m=>m.includes(f)?m.filter(d=>d!==f):[...m,f].slice(-2));if(n.length===0)return x.jsx("div",{className:"card",style:{marginTop:26},children:x.jsx("p",{className:"muted",children:"No runs yet. Create one in the Studio."})});const s=r.map(f=>n.find(m=>m.id===f)).filter(Boolean);return x.jsxs(x.Fragment,{children:[o&&s.length===2?x.jsx(wh,{a:s[0],b:s[1],onClose:()=>u(!1)}):r.length===2&&x.jsxs("div",{className:"card compare-bar",children:[x.jsx("span",{className:"muted",children:"2 runs selected"}),x.jsx("button",{className:"btn slim",style:{width:"auto"},onClick:()=>u(!0),children:"Compare side by side"})]}),x.jsx("div",{className:"runs",children:n.map(f=>{const m=_f(f),d=f.stage==="done"||f.status==="done";return x.jsxs("div",{className:`card run-card ${r.includes(f.id)?"picked":""}`,children:[m?x.jsx("video",{src:m,poster:ye.posterUrl(f.id),controls:!0,loop:!0,preload:"metadata"}):x.jsxs("p",{className:"muted",children:["(",f.stage||f.status,")"]}),x.jsxs("div",{className:"run-meta",children:[x.jsx("span",{className:"mono",children:f.recipe}),x.jsx("span",{className:`badge ${f.status}`,children:d?"final":f.stage||f.status})]}),x.jsxs("div",{className:"run-meta",children:[x.jsxs("span",{children:[f.n_frames??"—"," frames"]}),f.sync&&x.jsxs("span",{className:"mono",children:["corr ",f.sync.correlation]})]}),x.jsxs("div",{className:"run-actions",children:[x.jsx("button",{className:"btn ghost slim",onClick:()=>e(f.id),children:"Open in Studio"}),x.jsxs("label",{className:"check",style:{marginTop:0},children:[x.jsx("input",{type:"checkbox",checked:r.includes(f.id),onChange:()=>a(f.id)}),"compare"]})]}),x.jsx("p",{className:"muted mono",style:{fontSize:11,marginTop:4},children:new Date(f.created*1e3).toLocaleString()})]},f.id)})})]})}function xh(){const[e,n]=Z.useState("studio"),[t,r]=Z.useState(null),[l,o]=Z.useState(null),u=(a,s)=>x.jsx("button",{className:e===a?"active":"",onClick:()=>n(a),children:s});return x.jsxs("div",{className:"app",children:[x.jsxs("header",{className:"top",children:[x.jsxs("span",{className:"brand",children:["Kaika ",x.jsx("span",{className:"kanji",children:"開花"})]}),x.jsxs("nav",{className:"tabs",children:[u("studio","Studio"),u("render","Render"),u("gallery","Gallery")]})]}),e==="studio"&&x.jsx(gh,{initialRunId:t,onPreview:(a,s)=>{r(a),o(s),n("render")}}),e==="render"&&x.jsx(yh,{runId:t,jobId:l,onSeeGallery:()=>n("gallery")}),e==="gallery"&&x.jsx(Sh,{onOpenInStudio:a=>{r(a),n("studio")}})]})}Yi.createRoot(document.getElementById("root")).render(x.jsx(Hf.StrictMode,{children:x.jsx(xh,{})})); diff --git a/kaika/src/kaika/webapp_dist/index.html b/kaika/src/kaika/webapp_dist/index.html new file mode 100644 index 0000000..a1f0f7c --- /dev/null +++ b/kaika/src/kaika/webapp_dist/index.html @@ -0,0 +1,13 @@ + + + + + + Kaika 開花 + + + + +
+ + diff --git a/kaika/tests/conftest.py b/kaika/tests/conftest.py new file mode 100644 index 0000000..a02ca80 --- /dev/null +++ b/kaika/tests/conftest.py @@ -0,0 +1,99 @@ +"""Shared fixtures: deterministic synthetic audio so tests need no asset files.""" +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import soundfile as sf +import pytest + + +def synth_track(path: Path, sr: int = 22050, duration: float = 4.0, + bpm: float = 120.0) -> Path: + """Write a click track: low kicks on the beat, high hats on the off-beats. + + Beat period for 120 BPM is 0.5 s; hats land every 0.25 s. Energy ramps up + in the second half so structural segmentation has something to find. + """ + rng = np.random.default_rng(0) + n = int(sr * duration) + y = np.zeros(n, dtype=np.float32) + beat_period = 60.0 / bpm + + def burst(center_s, freq, dur=0.05, amp=0.8, noise=False): + start = int(center_s * sr) + length = int(dur * sr) + if start >= n: + return + end = min(n, start + length) + t = np.arange(end - start) / sr + env = np.exp(-t * 40.0) + if noise: + from scipy.signal import butter, sosfilt + band = rng.standard_normal(end - start).astype(np.float32) + # high-pass the burst like a real hi-hat, so it is genuinely a + # high-band event and not broadband noise leaking into the low band + sos = butter(4, 5000.0, btype="high", fs=sr, output="sos") + band = sosfilt(sos, band).astype(np.float32) + sig = band * env * amp + else: + sig = np.sin(2 * np.pi * freq * t).astype(np.float32) * env * amp + y[start:end] += sig + + t = 0.0 + while t < duration: + ramp = 0.4 + 0.6 * (t / duration) # energy grows over the track + burst(t, 60.0, dur=0.08, amp=0.9 * ramp) # kick (low) + burst(t + beat_period / 2, 6000.0, dur=0.03, amp=0.5 * ramp, noise=True) # hat (high) + t += beat_period + + y = np.clip(y, -1.0, 1.0) + sf.write(str(path), y, sr) + return path + + +@pytest.fixture +def track_wav(tmp_path) -> Path: + return synth_track(tmp_path / "track.wav") + + +# --- shared server-test scaffolding ---------------------------------------- +import time as _time + +# A fast, GPU-free recipe for end-to-end server/pipeline tests. +SMALL_RECIPE = { + "name": "t", "seed": 1, + "fluid": {"resolution": 40, "render_resolution": 40}, + "diffusion": {"backend": "local", "control": ["depth"]}, + "post": {"fps": 24}, +} + + +@pytest.fixture +def api_client(tmp_path): + """A TestClient over a fresh app with isolated runs/ and data/ dirs.""" + from fastapi.testclient import TestClient + from kaika.server.app import create_app + app = create_app(runs_root=tmp_path / "runs", data_dir=tmp_path / "data") + with TestClient(app) as c: + yield c + + +def upload_audio(client, tmp_path, duration=1.0) -> str: + """Synthesize, upload, and return the audio_id.""" + wav = synth_track(tmp_path / "upload.wav", duration=duration) + with wav.open("rb") as f: + r = client.post("/api/upload", files={"file": ("upload.wav", f, "audio/wav")}) + assert r.status_code == 200 + return r.json()["audio_id"] + + +def wait_for_job(client, job_id, timeout=90) -> dict: + """Poll a job until it finishes (done/error) or the timeout elapses.""" + deadline = _time.time() + timeout + while _time.time() < deadline: + j = client.get(f"/api/jobs/{job_id}").json() + if j["status"] in ("done", "error"): + return j + _time.sleep(0.2) + raise AssertionError("job did not finish in time") diff --git a/kaika/tests/test_analyze.py b/kaika/tests/test_analyze.py new file mode 100644 index 0000000..7d9468d --- /dev/null +++ b/kaika/tests/test_analyze.py @@ -0,0 +1,67 @@ +"""Phase 1: E1 audio analysis.""" +from __future__ import annotations + +from kaika.core.analyze import analyze +from kaika.core.score import Score + + +def test_frame_count_matches_fps(track_wav): + fps = 24 + score = analyze(track_wav, fps=fps) + expected = score.audio.duration_s * fps + # one row per video frame, within a couple of frames of duration*fps + assert abs(score.n_frames - expected) <= 3 + assert score.audio.hop_length == round(score.audio.sr / fps) + + +def test_tempo_detected(track_wav): + score = analyze(track_wav, fps=24) + # beat tracking can land on an octave; accept 120 or its common multiples + assert any(abs(score.tempo_bpm - t) < 6 for t in (60, 120, 240)) + assert len(score.beats) >= 4 + + +def test_low_onsets_present(track_wav): + score = analyze(track_wav, fps=24) + # kicks every 0.5s over 4s -> several low-band onsets + assert len(score.onsets["low"]) >= 4 + assert all(0.0 <= e.mag <= 1.0 for e in score.onsets["low"]) + + +def test_onsets_are_precise_not_noisy(track_wav): + """HPSS + strict peak picking: counts must stay near the real event counts + (8 kicks, 8 hats), not balloon — every onset spawns a visual source.""" + score = analyze(track_wav, fps=24) + assert len(score.onsets["low"]) <= 14 # was 23 before HPSS + assert 5 <= len(score.onsets["high"]) <= 12 + # mid-track kicks land on the beat grid (±1 frame) + times = [e.t for e in score.onsets["low"]] + for expected in (0.5, 1.0, 1.5, 2.0, 2.5): + assert any(abs(t - expected) < 0.06 for t in times), expected + + +def test_bands_normalised(track_wav): + score = analyze(track_wav, fps=24) + for f in score.frames: + assert abs(sum(f.bands) - 1.0) < 1e-3 or sum(f.bands) == 0.0 + assert 0.0 <= f.rms <= 1.0 + + +def test_sections_cover_track(track_wav): + score = analyze(track_wav, fps=24) + assert len(score.sections) >= 2 + assert score.sections[0].start == 0.0 + assert abs(score.sections[-1].end - score.audio.duration_s) < 0.1 + # sections are contiguous + for a, b in zip(score.sections, score.sections[1:]): + assert abs(a.end - b.start) < 1e-6 + + +def test_json_roundtrip(track_wav, tmp_path): + score = analyze(track_wav, fps=24) + p = tmp_path / "score.json" + score.to_json(p) + again = Score.from_json(p) + assert again.tempo_bpm == score.tempo_bpm + assert again.n_frames == score.n_frames + assert again.sections[0].label == score.sections[0].label diff --git a/kaika/tests/test_cli.py b/kaika/tests/test_cli.py new file mode 100644 index 0000000..3702559 --- /dev/null +++ b/kaika/tests/test_cli.py @@ -0,0 +1,18 @@ +"""Phase 9: CLI surface + recipe discovery.""" +from __future__ import annotations + +from kaika.cli import build_parser +from kaika.core import recipe as R + + +def test_parser_has_commands(): + p = build_parser() + ns = p.parse_args(["run", "x.wav", "--recipe", "eclosion", "--seconds", "5"]) + assert ns.cmd == "run" and ns.seconds == 5.0 + ns2 = p.parse_args(["serve", "--port", "9000"]) + assert ns2.cmd == "serve" and ns2.port == 9000 + + +def test_recipes_dir_resolves(): + assert R.RECIPES_DIR.is_dir() + assert (R.RECIPES_DIR / "eclosion.yaml").exists() diff --git a/kaika/tests/test_control.py b/kaika/tests/test_control.py new file mode 100644 index 0000000..c224759 --- /dev/null +++ b/kaika/tests/test_control.py @@ -0,0 +1,59 @@ +"""Phase 3: E3 control signals.""" +from __future__ import annotations + +import numpy as np +import imageio.v2 as imageio + +from kaika.core.analyze import analyze +from kaika.core import recipe as R +from kaika.core.simulate import simulate +from kaika.core.control import generate_control, ALL_SIGNALS + + +def _prep(track_wav, tmp_path, frames=8): + score = analyze(track_wav, fps=24) + rec = R.from_dict({"seed": 3, "fluid": {"resolution": 48, "render_resolution": 64}}) + sim = simulate(score, rec, tmp_path, max_frames=frames) + return sim + + +def test_all_signals_written_and_aligned(track_wav, tmp_path): + sim = _prep(track_wav, tmp_path) + res = generate_control(sim.fluid_dir, sim.velocity_dir, tmp_path) + assert set(res.dirs) == set(ALL_SIGNALS) + for d in res.dirs.values(): + assert len(list(d.glob("*.png"))) == sim.n_frames == res.n_frames + + +def test_depth_is_grayscale_with_range(track_wav, tmp_path): + sim = _prep(track_wav, tmp_path) + res = generate_control(sim.fluid_dir, sim.velocity_dir, tmp_path, signals=["depth"]) + img = imageio.imread(sorted(res.dirs["depth"].glob("*.png"))[-1]) + assert img.ndim == 2 # single channel + assert int(img.max()) > int(img.min()) # real depth gradient, not flat + + +def test_canny_is_binary(track_wav, tmp_path): + sim = _prep(track_wav, tmp_path) + res = generate_control(sim.fluid_dir, sim.velocity_dir, tmp_path, signals=["canny"]) + img = imageio.imread(sorted(res.dirs["canny"].glob("*.png"))[-1]) + assert set(np.unique(img)).issubset({0, 255}) + + +def test_depth_normalization_is_global_not_per_frame(track_wav, tmp_path): + """With clip-global scaling, frames keep their relative brightness instead + of each saturating to 255 — that's what removes inter-frame flicker.""" + sim = _prep(track_wav, tmp_path, frames=12) + res = generate_control(sim.fluid_dir, sim.velocity_dir, tmp_path, signals=["depth"]) + maxes = [int(imageio.imread(p).max()) + for p in sorted(res.dirs["depth"].glob("*.png"))] + assert len(set(maxes)) > 1 # not every frame normalised to its own max + + +def test_flow_matches_render_size(track_wav, tmp_path): + sim = _prep(track_wav, tmp_path) + res = generate_control(sim.fluid_dir, sim.velocity_dir, tmp_path, + signals=["flow"], render_resolution=64) + img = imageio.imread(sorted(res.dirs["flow"].glob("*.png"))[-1]) + assert img.shape == (64, 64, 3) + assert img.max() > 0 # there is motion to colour diff --git a/kaika/tests/test_diffuse.py b/kaika/tests/test_diffuse.py new file mode 100644 index 0000000..0cd0b6e --- /dev/null +++ b/kaika/tests/test_diffuse.py @@ -0,0 +1,116 @@ +"""Phase 5: E4 diffusion — interface, scheduling, local fallback, comfy scaffold.""" +from __future__ import annotations + +import numpy as np +import imageio.v2 as imageio + +from kaika.core.analyze import analyze +from kaika.core import recipe as R +from kaika.core.simulate import simulate +from kaika.core.control import generate_control +from kaika.core import diffuse as D +from kaika.core.diffuse.base import (build_prompt_schedule, compress_schedule, + plan_chunks) +from kaika.core.diffuse.comfy import (build_workflow, load_workflow_template, + dominant_prompt, ComfyDiffuser, ComfyUnavailable) + + +# ---- scheduling (model-agnostic, the durable part) ------------------------- +def test_prompt_schedule_covers_every_frame(track_wav): + score = analyze(track_wav, fps=24) + rec = R.load_recipe("eclosion") + sched = build_prompt_schedule(score, rec, score.n_frames) + assert len(sched) == score.n_frames + assert all(p.startswith(rec.prompts["base"]) for p in sched) + + +def test_compress_schedule_change_points(): + per_frame = ["a", "a", "b", "b", "b", "a"] + assert compress_schedule(per_frame) == [(0, "a"), (2, "b"), (5, "a")] + + +def test_plan_chunks_cover_and_overlap(): + chunks = plan_chunks(100, chunk_frames=30, overlap=6) + assert chunks[0][0] == 0 + assert chunks[-1][1] == 100 + # contiguous coverage with the requested overlap + for (s0, e0), (s1, e1) in zip(chunks, chunks[1:]): + assert s1 == e0 - 6 + # union covers everything + covered = set() + for s, e in chunks: + covered.update(range(s, e)) + assert covered == set(range(100)) + + +def test_plan_chunks_snaps_to_boundary(): + # a boundary near the natural cut (30) should pull the seam onto it + chunks = plan_chunks(100, chunk_frames=30, overlap=6, boundaries=[28]) + assert chunks[0][1] == 28 + + +# ---- comfy workflow patching ---------------------------------------------- +def test_build_workflow_patches_tokens(): + tpl = load_workflow_template("wan-2.2-vace") + wf = build_workflow(tpl, prompt="peonies", seed=99, denoise=0.5, + control_video="/tmp/c.mp4", output_prefix="out") + assert wf["3"]["inputs"]["text"] == "peonies" + assert wf["6"]["inputs"]["seed"] == 99 + assert wf["6"]["inputs"]["denoise"] == 0.5 + assert wf["1"]["inputs"]["video"] == "/tmp/c.mp4" + # template itself is untouched (deepcopy) + assert tpl["3"]["inputs"]["text"] == "PROMPT" + + +def test_dominant_prompt(): + per_frame = ["intro", "drop", "drop", "drop", "outro"] + assert dominant_prompt(per_frame, 1, 4) == "drop" + + +def test_comfy_unavailable_is_clear(): + rec = R.from_dict({"diffusion": {"backend": "comfyui"}}) + d = ComfyDiffuser(endpoint="http://127.0.0.1:9", timeout=0.5) + try: + d._post_prompt({"x": 1}) + assert False, "expected ComfyUnavailable" + except ComfyUnavailable as e: + assert "not reachable" in str(e) + + +# ---- local fallback (runs end-to-end, no GPU) ------------------------------ +def _pipeline_to_control(track_wav, tmp_path, frames=10): + score = analyze(track_wav, fps=24) + rec = R.from_dict({"seed": 4, "fluid": {"resolution": 48, "render_resolution": 64}, + "diffusion": {"backend": "local", "strength": 0.6}}) + sim = simulate(score, rec, tmp_path, max_frames=frames) + ctrl = generate_control(sim.fluid_dir, sim.velocity_dir, tmp_path) + req = D.DiffuseRequest(fluid_dir=sim.fluid_dir, control_dirs=ctrl.dirs, + out_dir=tmp_path, score=score, recipe=rec, n_frames=frames) + return rec, req + + +def test_get_diffuser_local(): + rec = R.from_dict({"diffusion": {"backend": "local"}}) + assert isinstance(D.get_diffuser(rec), D.LocalStylizer) + + +def test_local_stylizer_outputs(track_wav, tmp_path): + rec, req = _pipeline_to_control(track_wav, tmp_path, frames=10) + res = D.get_diffuser(rec).run(req) + styled = sorted(res.styled_dir.glob("*.png")) + assert len(styled) == 10 == res.n_frames + img = imageio.imread(styled[-1]) + assert img.shape == (64, 64, 3) + + +def test_local_stylizer_deterministic(track_wav, tmp_path): + rec, req = _pipeline_to_control(track_wav, tmp_path, frames=6) + a = D.LocalStylizer().run(req).styled_dir + # re-run into a fresh out dir + req2 = D.DiffuseRequest(fluid_dir=req.fluid_dir, control_dirs=req.control_dirs, + out_dir=tmp_path / "b", score=req.score, recipe=rec, + n_frames=6) + b = D.LocalStylizer().run(req2).styled_dir + ia = imageio.imread(sorted(a.glob("*.png"))[-1]) + ib = imageio.imread(sorted(b.glob("*.png"))[-1]) + assert np.array_equal(ia, ib) diff --git a/kaika/tests/test_frontend.py b/kaika/tests/test_frontend.py new file mode 100644 index 0000000..303ec7f --- /dev/null +++ b/kaika/tests/test_frontend.py @@ -0,0 +1,27 @@ +"""Phase 8: the built frontend is embedded and served by the API server.""" +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from kaika.server.app import create_app, WEBAPP_DIST + + +built = (WEBAPP_DIST / "index.html").exists() + + +@pytest.mark.skipif(not built, reason="frontend not built (run `npm run build` in webapp/)") +def test_spa_is_served(tmp_path): + app = create_app(runs_root=tmp_path / "runs", data_dir=tmp_path / "data") + with TestClient(app) as c: + r = c.get("/") + assert r.status_code == 200 + assert '
' in r.text + # API still reachable alongside the static mount + assert c.get("/api/recipes").status_code == 200 + + +@pytest.mark.skipif(not built, reason="frontend not built") +def test_assets_present(): + assert any((WEBAPP_DIST / "assets").glob("*.js")) + assert any((WEBAPP_DIST / "assets").glob("*.css")) diff --git a/kaika/tests/test_gallery_api.py b/kaika/tests/test_gallery_api.py new file mode 100644 index 0000000..66b63fd --- /dev/null +++ b/kaika/tests/test_gallery_api.py @@ -0,0 +1,51 @@ +"""Package D: cancel, live frame peek.""" +from __future__ import annotations + +import time + +import pytest + +from conftest import SMALL_RECIPE as SMALL, upload_audio as _upload, wait_for_job as _wait + + +@pytest.fixture +def client(api_client): + return api_client + + +def _project(client, tmp_path, seconds=1.0): + aid = _upload(client, tmp_path, duration=1.5) + return client.post("/api/projects", json={"audio_id": aid, "recipe": SMALL, + "seconds": seconds}).json()["run_id"] + + +def test_cancel_running_job(client, tmp_path): + run_id = _project(client, tmp_path, seconds=1.0) + job = client.post(f"/api/projects/{run_id}/preview", json={}).json()["job_id"] + # cancel as soon as it is seen running (or still queued) + deadline = time.time() + 30 + while time.time() < deadline: + j = client.get(f"/api/jobs/{job}").json() + if j["status"] in ("queued", "running"): + assert client.post(f"/api/jobs/{job}/cancel").json()["ok"] + break + time.sleep(0.05) + deadline = time.time() + 60 + while time.time() < deadline: + j = client.get(f"/api/jobs/{job}").json() + if j["status"] in ("cancelled", "done", "error"): + break + time.sleep(0.2) + assert j["status"] == "cancelled" + # cancelling again is a 409 + assert client.post(f"/api/jobs/{job}/cancel").status_code == 409 + + +def test_latest_frame_during_and_after(client, tmp_path): + run_id = _project(client, tmp_path, seconds=0.5) + assert client.get(f"/api/runs/{run_id}/latest_frame").status_code == 404 + job = client.post(f"/api/projects/{run_id}/preview", json={}).json()["job_id"] + assert _wait(client, job)["status"] == "done" + r = client.get(f"/api/runs/{run_id}/latest_frame") + assert r.status_code == 200 + assert r.headers["content-type"] == "image/png" diff --git a/kaika/tests/test_iteration.py b/kaika/tests/test_iteration.py new file mode 100644 index 0000000..80b3257 --- /dev/null +++ b/kaika/tests/test_iteration.py @@ -0,0 +1,48 @@ +"""Package A: fast iteration — segment preview + draft mode over the API.""" +from __future__ import annotations + +import pytest + +from conftest import SMALL_RECIPE as SMALL, upload_audio as _upload, wait_for_job as _wait + + +@pytest.fixture +def client(api_client): + return api_client + + +def _make_project(client, tmp_path, seconds=1.0): + aid = _upload(client, tmp_path, duration=1.5) + return client.post("/api/projects", json={"audio_id": aid, "recipe": SMALL, + "seconds": seconds}).json() + + +def test_segment_preview_endpoint(client, tmp_path): + data = _make_project(client, tmp_path) + run_id = data["run_id"] + job = client.post(f"/api/projects/{run_id}/preview_segment", + json={"index": 0, "draft": True}).json()["job_id"] + j = _wait(client, job) + assert j["status"] == "done", j.get("error") + assert j["kind"] == "fluid_segment" + r = client.get(f"/api/runs/{run_id}/files/segment_preview.mp4") + assert r.status_code == 200 + # full fluid was never built — only the segment window + assert not (tmp_path / "runs" / run_id / "fluid").exists() + + +def test_segment_preview_bad_index(client, tmp_path): + data = _make_project(client, tmp_path) + r = client.post(f"/api/projects/{data['run_id']}/preview_segment", + json={"index": 99}) + assert r.status_code == 400 + + +def test_full_preview_draft_flag(client, tmp_path): + data = _make_project(client, tmp_path) + run_id = data["run_id"] + job = client.post(f"/api/projects/{run_id}/preview", + json={"draft": True}).json()["job_id"] + assert _wait(client, job)["status"] == "done" + m = client.get(f"/api/runs/{run_id}").json() + assert m["stages"]["simulate"]["draft"] is True diff --git a/kaika/tests/test_pipeline.py b/kaika/tests/test_pipeline.py new file mode 100644 index 0000000..445d05d --- /dev/null +++ b/kaika/tests/test_pipeline.py @@ -0,0 +1,73 @@ +"""Phase 6: end-to-end pipeline orchestration + CLI run.""" +from __future__ import annotations + +from pathlib import Path + +from kaika.core.analyze import analyze # noqa +from kaika.core import recipe as R +from kaika.core.pipeline import run_pipeline, load_run, list_runs + + +def _fast_recipe(): + return R.from_dict({ + "name": "fast", "seed": 11, + "fluid": {"resolution": 48, "render_resolution": 64}, + "diffusion": {"backend": "local", "control": ["depth", "flow"]}, + "post": {"fps": 24, "aspect": "square"}, + }) + + +def test_end_to_end_local(track_wav, tmp_path): + res = run_pipeline(track_wav, _fast_recipe(), runs_root=tmp_path, seconds=0.4) + assert res.final.exists() and res.final.stat().st_size > 1000 + rd = res.run_dir + # every intermediate and the frozen inputs are on disk + assert (rd / "score.json").exists() + assert (rd / "recipe.yaml").exists() + assert any((rd / "fluid").glob("*.png")) + assert any((rd / "velocity").glob("*.npy")) + assert any((rd / "control" / "depth").glob("*.png")) + assert any((rd / "styled").glob("*.png")) + assert res.backend == "local" + + +def test_manifest_records_stages(track_wav, tmp_path): + res = run_pipeline(track_wav, _fast_recipe(), runs_root=tmp_path, seconds=0.4) + m = load_run(res.run_dir) + assert m["status"] == "done" + for stage in ("analyze", "simulate", "control", "diffuse", "post"): + assert m["stages"][stage]["done"] is True + assert m["sync"] is not None + assert m["final"] == "kaika_final.mp4" + + +def test_progress_callback_fires(track_wav, tmp_path): + seen = set() + run_pipeline(track_wav, _fast_recipe(), runs_root=tmp_path, seconds=0.3, + progress=lambda s, d, t: seen.add(s)) + assert {"analyze", "simulate", "control", "diffuse", "post"} <= seen + + +def test_list_runs(track_wav, tmp_path): + run_pipeline(track_wav, _fast_recipe(), runs_root=tmp_path, seconds=0.3) + run_pipeline(track_wav, _fast_recipe(), runs_root=tmp_path, seconds=0.3) + runs = list_runs(tmp_path) + assert len(runs) == 2 + assert all("id" in r for r in runs) + + +def test_seconds_limits_frames(track_wav, tmp_path): + res = run_pipeline(track_wav, _fast_recipe(), runs_root=tmp_path, seconds=0.25) + # 0.25s * 24fps = 6 frames + assert res.n_frames == 6 + + +def test_cli_run(track_wav, tmp_path): + from kaika.cli import main + # write a tiny recipe file so --recipe path works without the package dir + rp = tmp_path / "fast.yaml" + _fast_recipe().to_yaml(rp) + rc = main(["run", str(track_wav), "--recipe", str(rp), + "--seconds", "0.3", "--out", str(tmp_path / "runs")]) + assert rc == 0 + assert list_runs(tmp_path / "runs") diff --git a/kaika/tests/test_post.py b/kaika/tests/test_post.py new file mode 100644 index 0000000..9ef4329 --- /dev/null +++ b/kaika/tests/test_post.py @@ -0,0 +1,71 @@ +"""Phase 4: E5 post-production + media helpers.""" +from __future__ import annotations + +import json + +import numpy as np + +from kaika.core.analyze import analyze +from kaika.core import recipe as R +from kaika.core.simulate import simulate +from kaika.core.post import assemble, sync_check +from kaika.core.media import frames_to_video, video_to_frames + + +def _sim(track_wav, tmp_path, frames=16): + score = analyze(track_wav, fps=24) + rec = R.from_dict({"seed": 5, "fluid": {"resolution": 48, "render_resolution": 64}}) + return score, simulate(score, rec, tmp_path, max_frames=frames) + + +def test_assemble_produces_playable_mp4(track_wav, tmp_path): + score, sim = _sim(track_wav, tmp_path) + out = tmp_path / "final.mp4" + res = assemble(sim.fluid_dir, track_wav, out, fps=24, + score=score, fluid_stats_path=sim.stats_path) + assert out.exists() and out.stat().st_size > 1000 + assert res.sync is not None + assert -6 <= res.sync.lag_frames <= 6 + + +def test_assemble_without_audio(track_wav, tmp_path): + _, sim = _sim(track_wav, tmp_path, frames=8) + out = tmp_path / "noaudio.mp4" + assemble(sim.fluid_dir, tmp_path / "missing.wav", out, fps=24) + assert out.exists() + + +def test_wide_aspect(track_wav, tmp_path): + _, sim = _sim(track_wav, tmp_path, frames=8) + out = tmp_path / "wide.mp4" + assemble(sim.fluid_dir, tmp_path / "no.wav", out, fps=24, aspect="wide") + # probe first frame via the bundled-ffmpeg reader to confirm 16:9-ish output + import imageio.v2 as imageio + reader = imageio.get_reader(str(out)) + frame = reader.get_data(0) + reader.close() + h, w = frame.shape[:2] + assert abs((w / h) - 16 / 9) < 0.05 + + +def test_sync_check_aligned_signal(): + """A fluid energy curve equal to RMS should report ~zero lag, high corr.""" + from kaika.core.score import Score, AudioInfo, FrameData + frames = [FrameData(rms=float(r), centroid_hz=1000, bands=[0.5, 0.3, 0.2]) + for r in np.abs(np.sin(np.linspace(0, 6, 40)))] + score = Score(audio=AudioInfo(sr=22050, duration_s=40 / 24, fps=24, hop_length=918), + tempo_bpm=120.0, frames=frames) + stats = {"kinetic_energy": [f.rms for f in frames]} + res = sync_check(score, stats) + assert res.lag_frames == 0 + assert res.correlation > 0.95 + + +def test_media_video_roundtrip(track_wav, tmp_path): + """Transfer-compression path: frames -> video -> frames.""" + _, sim = _sim(track_wav, tmp_path, frames=10) + vid = frames_to_video(sim.fluid_dir, tmp_path / "transfer.mp4", fps=24) + assert vid.exists() + out = video_to_frames(vid, tmp_path / "restored") + restored = sorted(out.glob("*.png")) + assert len(restored) == 10 diff --git a/kaika/tests/test_project.py b/kaika/tests/test_project.py new file mode 100644 index 0000000..cfbe236 --- /dev/null +++ b/kaika/tests/test_project.py @@ -0,0 +1,109 @@ +"""Phase S1: segment-aware Project model + per-segment simulation.""" +from __future__ import annotations + +import json + +import numpy as np + +from kaika.core.analyze import analyze +from kaika.core import recipe as R +from kaika.core.project import Project, Segment, _deep_merge +from kaika.core.simulate import simulate + + +def test_from_score_builds_segments(track_wav): + score = analyze(track_wav, fps=24) + rec = R.load_recipe("eclosion") + proj = Project.from_score(score, rec, audio="track.wav") + assert len(proj.segments) == len(score.sections) + # prompts seeded from the recipe per label + assert proj.segments[0].prompt == rec.prompt_for(score.sections[0].label) + assert proj.fps == 24 + + +def test_deep_merge_partial_override(): + base = {"vorticity": {"min": 8, "max": 38}, "dissipation": 0.9} + out = _deep_merge(base, {"vorticity": {"max": 60}}) + assert out["vorticity"] == {"min": 8, "max": 60} # min preserved + assert out["dissipation"] == 0.9 + + +def test_frame_configs_apply_segment_overrides(track_wav): + score = analyze(track_wav, fps=24) + rec = R.from_dict({"fluid": {"vorticity": {"min": 8, "max": 38}}}) + proj = Project.from_score(score, rec, audio="t.wav") + # override the last segment only + proj.segments[-1].fluid = {"vorticity": {"max": 99}} + cfgs = proj.frame_configs(score.n_frames) + assert cfgs[0].vorticity.max == 38 # early frame: base + assert cfgs[-1].vorticity.max == 99 # last segment: overridden + assert cfgs[0].vorticity.min == 8 # partial override kept min + + +def test_frame_configs_smooth_across_boundary(track_wav): + """Numeric params glide over SMOOTH_S at a boundary instead of jumping.""" + from kaika.core.project import Segment + score = analyze(track_wav, fps=24) + dur = score.audio.duration_s + rec = R.from_dict({"fluid": {"vorticity": {"min": 8, "max": 20}}}) + proj = Project(audio="t.wav", recipe=rec, fps=24, segments=[ + Segment(start=0.0, end=dur / 2, label="a", fluid={}), + Segment(start=dur / 2, end=dur, label="b", + fluid={"vorticity": {"max": 80}}), + ]) + cfgs = proj.frame_configs(score.n_frames) + boundary = int(round(dur / 2 * 24)) + vals = [c.vorticity.max for c in cfgs] + assert vals[0] == 20 and vals[-1] == 80 + # at least one frame holds an intermediate (smoothed) value near the cut + near = vals[max(0, boundary - 8): boundary + 8] + assert any(20 < v < 80 for v in near), near + + +def test_prompt_schedule_per_segment(track_wav): + score = analyze(track_wav, fps=24) + rec = R.load_recipe("eclosion") + proj = Project.from_score(score, rec, audio="t.wav") + proj.segments[-1].prompt = "UNIQUE LAST PROMPT" + sched = proj.prompt_schedule(score.n_frames) + assert len(sched) == score.n_frames + assert sched[-1] == "UNIQUE LAST PROMPT" + + +def test_roundtrip(tmp_path, track_wav): + score = analyze(track_wav, fps=24) + rec = R.load_recipe("eclosion") + proj = Project.from_score(score, rec, audio="t.wav") + proj.segments[0].fluid = {"dissipation": 0.85} + p = tmp_path / "project.json" + proj.to_json(p) + again = Project.from_json(p) + assert len(again.segments) == len(proj.segments) + assert again.segments[0].fluid == {"dissipation": 0.85} + assert again.recipe.seed == proj.recipe.seed + + +def test_simulation_respects_per_segment_emit(track_wav, tmp_path): + """A segment that emits no dye must stay darker than one that does — proof + that per-segment parameters actually drive the continuous simulation.""" + score = analyze(track_wav, fps=24) + half = score.audio.duration_s / 2 + rec = R.from_dict({"seed": 1, "fluid": {"resolution": 48, "render_resolution": 48}}) + proj = Project(audio="t.wav", recipe=rec, fps=24, segments=[ + Segment(start=0.0, end=half, label="silent", + fluid={"splats": {"low": {"radius": 0.12, "force": 9000, + "placement": "anchored", "emit": 0.0}, + "high": {"radius": 0.03, "force": 3500, + "placement": "scatter", "emit": 0.0}}, + "ambient_strength": 0.0}), + Segment(start=half, end=score.audio.duration_s, label="loud", fluid={}), + ]) + n = score.n_frames + cfgs = proj.frame_configs(n) + sim = simulate(score, rec, tmp_path, frame_configs=cfgs) + import imageio.v2 as imageio + frames = sorted(sim.fluid_dir.glob("*.png")) + h = len(frames) // 2 + first = np.mean([imageio.imread(f).mean() for f in frames[:h]]) + second = np.mean([imageio.imread(f).mean() for f in frames[h:]]) + assert first < second # silent segment darker than the emitting one diff --git a/kaika/tests/test_projects_api.py b/kaika/tests/test_projects_api.py new file mode 100644 index 0000000..a63d9f6 --- /dev/null +++ b/kaika/tests/test_projects_api.py @@ -0,0 +1,68 @@ +"""Phase S3: project (segment editor) API + staged jobs.""" +from __future__ import annotations + +import pytest + +from conftest import SMALL_RECIPE as SMALL, upload_audio as _upload, wait_for_job as _wait + + +@pytest.fixture +def client(api_client): + return api_client + + +def test_create_project_returns_segments_and_analysis(client, tmp_path): + aid = _upload(client, tmp_path) + r = client.post("/api/projects", json={"audio_id": aid, "recipe": SMALL, "seconds": 0.4}) + assert r.status_code == 200 + data = r.json() + assert data["run_id"] + assert len(data["project"]["segments"]) >= 1 + assert "waveform" in data["analysis"] and len(data["analysis"]["waveform"]) > 0 + # each segment carries an editable prompt + assert "prompt" in data["project"]["segments"][0] + + +def test_edit_segment_prompt_persists(client, tmp_path): + aid = _upload(client, tmp_path) + data = client.post("/api/projects", json={"audio_id": aid, "recipe": SMALL, + "seconds": 0.4}).json() + run_id = data["run_id"] + segs = data["project"]["segments"] + segs[0]["prompt"] = "EDITED PROMPT" + segs[0]["fluid"] = {"vorticity": {"min": 5, "max": 70}} + r = client.put(f"/api/projects/{run_id}", json={"segments": segs}) + assert r.status_code == 200 + again = client.get(f"/api/projects/{run_id}").json() + assert again["project"]["segments"][0]["prompt"] == "EDITED PROMPT" + assert again["project"]["segments"][0]["fluid"]["vorticity"]["max"] == 70 + + +def test_preview_then_generate_flow(client, tmp_path): + aid = _upload(client, tmp_path) + run_id = client.post("/api/projects", json={"audio_id": aid, "recipe": SMALL, + "seconds": 0.4}).json()["run_id"] + + # fluid preview (no diffusion) + job = client.post(f"/api/projects/{run_id}/preview").json()["job_id"] + assert _wait(client, job)["status"] == "done" + assert client.get(f"/api/runs/{run_id}/files/fluid_preview.mp4").status_code == 200 + assert client.get(f"/api/runs/{run_id}").json()["stage"] == "fluid" + assert not (tmp_path / "runs" / run_id / "styled").exists() + + # generate (diffuse) resumes the same run + job2 = client.post(f"/api/projects/{run_id}/generate").json()["job_id"] + assert _wait(client, job2)["status"] == "done" + assert client.get(f"/api/runs/{run_id}/final").status_code == 200 + m = client.get(f"/api/runs/{run_id}").json() + assert m["stage"] == "done" + + +def test_generate_without_preview_builds_fluid(client, tmp_path): + """Generate is self-sufficient: it builds the missing full fluid first.""" + aid = _upload(client, tmp_path) + run_id = client.post("/api/projects", json={"audio_id": aid, "recipe": SMALL, + "seconds": 0.4}).json()["run_id"] + job = client.post(f"/api/projects/{run_id}/generate").json()["job_id"] + assert _wait(client, job)["status"] == "done" + assert client.get(f"/api/runs/{run_id}/final").status_code == 200 diff --git a/kaika/tests/test_recipe.py b/kaika/tests/test_recipe.py new file mode 100644 index 0000000..5613ddf --- /dev/null +++ b/kaika/tests/test_recipe.py @@ -0,0 +1,42 @@ +"""Phase 2: recipe loading, defaults, and prompt resolution.""" +from __future__ import annotations + +from kaika.core import recipe as R + + +def test_load_named_recipe(): + r = R.load_recipe("eclosion") + assert r.name == "eclosion" + assert r.seed == 4217 + assert r.fluid.splats["low"].placement == "anchored" + assert r.diffusion.strength == 0.5 + + +def test_prompt_base_is_prefixed(): + r = R.load_recipe("eclosion") + p = r.prompt_for("drop") + assert p.startswith(r.prompts["base"]) + assert "peonies" in p + + +def test_unknown_label_falls_back_to_default(): + r = R.load_recipe("eclosion") + p = r.prompt_for("chorus") # not defined + assert r.prompts["default"] in p + + +def test_partial_dict_uses_defaults(): + r = R.from_dict({"name": "tiny", "fluid": {"resolution": 32}}) + assert r.name == "tiny" + assert r.fluid.resolution == 32 + assert r.fluid.dissipation == R.FluidConfig().dissipation # default kept + assert r.diffusion.backend == "local" + + +def test_yaml_roundtrip(tmp_path): + r = R.load_recipe("eclosion") + p = tmp_path / "r.yaml" + r.to_yaml(p) + again = R.load_recipe(p) + assert again.seed == r.seed + assert again.prompt_for("drop") == r.prompt_for("drop") diff --git a/kaika/tests/test_review_fixes.py b/kaika/tests/test_review_fixes.py new file mode 100644 index 0000000..57f75f1 --- /dev/null +++ b/kaika/tests/test_review_fixes.py @@ -0,0 +1,49 @@ +"""Regression tests for PR #2 review fixes.""" +from __future__ import annotations + +import json + +from fastapi.testclient import TestClient + +from kaika.server.app import create_app +from kaika.core import recipe as R +from kaika.core.simulate import _lookahead_boost +from kaika.core.score import Score, AudioInfo, Section + + +def test_path_traversal_prefix_run_ids(tmp_path): + """run_id prefix (run1 vs run12) must not allow cross-run file access.""" + runs = tmp_path / "runs" + (runs / "run1").mkdir(parents=True) + (runs / "run12").mkdir(parents=True) + (runs / "run12" / "secret.txt").write_text("nope") + (runs / "run1" / "run.json").write_text(json.dumps({"id": "run1"})) + app = create_app(runs_root=runs, data_dir=tmp_path / "data") + with TestClient(app) as c: + r = c.get("/api/runs/run1/files/../run12/secret.txt") + assert r.status_code == 404 + + +def test_lookahead_zero_no_crash(): + score = Score(audio=AudioInfo(sr=22050, duration_s=2.0, fps=24, hop_length=918), + tempo_bpm=120.0, + sections=[Section(start=0.0, end=2.0, label="drop", energy=1.0)]) + # frame exactly at the drop start with lookahead 0 used to ZeroDivision + assert _lookahead_boost(score, frame_i=0, fps=24, lookahead_s=0.0) == 0.0 + + +def test_recipe_null_keeps_defaults(): + r = R.from_dict({"fluid": {"vorticity": None, "dissipation": None}}) + # null in YAML must not wipe nested defaults + assert r.fluid.vorticity.min == R.Vorticity().min + assert r.fluid.dissipation == R.FluidConfig().dissipation + + +def test_lookahead_zero_in_full_sim(track_wav, tmp_path): + from kaika.core.analyze import analyze + from kaika.core.simulate import simulate + score = analyze(track_wav, fps=24) + rec = R.from_dict({"fluid": {"resolution": 40, "render_resolution": 40, + "lookahead_s": 0.0}}) + res = simulate(score, rec, tmp_path, max_frames=5) + assert res.n_frames == 5 diff --git a/kaika/tests/test_server.py b/kaika/tests/test_server.py new file mode 100644 index 0000000..081a83f --- /dev/null +++ b/kaika/tests/test_server.py @@ -0,0 +1,79 @@ +"""Phase 7: FastAPI server, job queue, WebSocket progress.""" +from __future__ import annotations + +from conftest import SMALL_RECIPE, upload_audio as _upload, wait_for_job as _wait_done + +# `api_client` fixture is provided by conftest; alias it as `client` here. +import pytest + + +@pytest.fixture +def client(api_client): + return api_client + + +def test_recipes_endpoint(client): + names = [r["name"] for r in client.get("/api/recipes").json()] + assert "eclosion" in names + + +def test_analyze_preview(client, tmp_path): + aid = _upload(client, tmp_path) + r = client.post("/api/analyze", params={"audio_id": aid, "fps": 24}) + assert r.status_code == 200 + data = r.json() + assert data["n_frames"] > 0 + assert len(data["sections"]) >= 1 + assert len(data["waveform"]) > 0 + assert "low" in data["onset_counts"] + + +def test_run_job_to_completion(client, tmp_path): + aid = _upload(client, tmp_path) + r = client.post("/api/runs", json={"audio_id": aid, "recipe": SMALL_RECIPE, + "seconds": 0.3}) + job_id = r.json()["job_id"] + j = _wait_done(client, job_id) + assert j["status"] == "done", j.get("error") + run_id = j["run_id"] + detail = client.get(f"/api/runs/{run_id}").json() + assert detail["status"] == "done" + # final video is served + assert client.get(f"/api/runs/{run_id}/final").status_code == 200 + # an intermediate frame is served, with path-traversal blocked + runs = client.get("/api/runs").json() + assert any(x["id"] == run_id for x in runs) + + +def test_file_traversal_blocked(client, tmp_path): + aid = _upload(client, tmp_path) + job_id = client.post("/api/runs", json={"audio_id": aid, "recipe": SMALL_RECIPE, + "seconds": 0.3}).json()["job_id"] + j = _wait_done(client, job_id) + run_id = j["run_id"] + bad = client.get(f"/api/runs/{run_id}/files/../../../etc/passwd") + assert bad.status_code == 404 + + +def test_websocket_progress(client, tmp_path): + aid = _upload(client, tmp_path) + job_id = client.post("/api/runs", json={"audio_id": aid, "recipe": SMALL_RECIPE, + "seconds": 0.3}).json()["job_id"] + stages = set() + final_status = None + with client.websocket_connect(f"/ws/jobs/{job_id}") as ws: + for _ in range(400): + msg = ws.receive_json() + if msg.get("stage"): + stages.add(msg["stage"]) + if msg.get("status") in ("done", "error"): + final_status = msg["status"] + break + assert final_status == "done" + assert stages # at least one progress stage streamed + + +def test_placeholder_index(client): + r = client.get("/") + assert r.status_code == 200 + assert "Kaika" in r.text diff --git a/kaika/tests/test_simulate.py b/kaika/tests/test_simulate.py new file mode 100644 index 0000000..9c53f15 --- /dev/null +++ b/kaika/tests/test_simulate.py @@ -0,0 +1,78 @@ +"""Phase 2: E2 fluid simulation.""" +from __future__ import annotations + +import json + +import numpy as np + +from kaika.core.analyze import analyze +from kaika.core import recipe as R +from kaika.core.simulate import simulate, FluidSim + + +def _small_recipe(): + return R.from_dict({ + "name": "test", "seed": 7, + "fluid": {"resolution": 48, "render_resolution": 64}, + }) + + +def test_outputs_written(track_wav, tmp_path): + score = analyze(track_wav, fps=24) + res = simulate(score, _small_recipe(), tmp_path, max_frames=10) + pngs = sorted(res.fluid_dir.glob("*.png")) + npys = sorted(res.velocity_dir.glob("*.npy")) + assert len(pngs) == 10 == len(npys) == res.n_frames + v = np.load(npys[0]) + assert v.shape == (48, 48, 2) # velocity at sim resolution + assert v.dtype == np.float32 + + +def test_render_resolution(track_wav, tmp_path): + import imageio.v2 as imageio + score = analyze(track_wav, fps=24) + res = simulate(score, _small_recipe(), tmp_path, max_frames=4) + img = imageio.imread(res.fluid_dir / "000003.png") + assert img.shape[:2] == (64, 64) # upsampled render + + +def test_density_appears(track_wav, tmp_path): + score = analyze(track_wav, fps=24) + res = simulate(score, _small_recipe(), tmp_path, max_frames=12) + import imageio.v2 as imageio + last = imageio.imread(sorted(res.fluid_dir.glob("*.png"))[-1]) + assert last.max() > 0 # splats injected colour, frame is not black + + +def test_stats_for_sync_check(track_wav, tmp_path): + score = analyze(track_wav, fps=24) + res = simulate(score, _small_recipe(), tmp_path, max_frames=8) + stats = json.loads(res.stats_path.read_text()) + assert len(stats["kinetic_energy"]) == 8 + assert len(stats["total_density"]) == 8 + + +def test_deterministic(track_wav, tmp_path): + score = analyze(track_wav, fps=24) + a = simulate(score, _small_recipe(), tmp_path / "a", max_frames=6) + b = simulate(score, _small_recipe(), tmp_path / "b", max_frames=6) + fa = np.load(sorted(a.velocity_dir.glob("*.npy"))[-1]) + fb = np.load(sorted(b.velocity_dir.glob("*.npy"))[-1]) + assert np.array_equal(fa, fb) # same seed -> identical fields + + +def _divergence(u, v): + # forward-difference divergence — the operator the projection drives to zero + return (np.roll(u, -1, 1) - u) + (np.roll(v, -1, 0) - v) + + +def test_projection_makes_incompressible(): + """The spectral (FFT) projection solves the Poisson system exactly, so a + single call must drive the velocity field's divergence to ~zero.""" + sim = FluidSim(n=32, dissipation=0.99, viscosity=0.0, seed=1) + sim.add_splat(0.5, 0.5, 0.1, 8000.0, np.array([1.0, 0.2, 0.5]), 0.7) + before = np.abs(_divergence(sim.u, sim.v)).mean() + sim._project() + after = np.abs(_divergence(sim.u, sim.v)).mean() + assert before > 1e-2 # the splat created real divergence + assert after < before * 1e-3 # one FFT solve ~ machine-exact diff --git a/kaika/tests/test_smoke.py b/kaika/tests/test_smoke.py new file mode 100644 index 0000000..a2de409 --- /dev/null +++ b/kaika/tests/test_smoke.py @@ -0,0 +1,18 @@ +"""Phase 0: package imports and core third-party deps are available.""" +import importlib + + +def test_version(): + import kaika + assert kaika.__version__ + + +def test_core_deps_importable(): + for mod in ("numpy", "scipy", "librosa", "cv2", "yaml", "imageio_ffmpeg"): + importlib.import_module(mod) + + +def test_ffmpeg_binary_available(): + import imageio_ffmpeg + exe = imageio_ffmpeg.get_ffmpeg_exe() + assert exe and __import__("os").path.exists(exe) diff --git a/kaika/tests/test_staged.py b/kaika/tests/test_staged.py new file mode 100644 index 0000000..e7e93e2 --- /dev/null +++ b/kaika/tests/test_staged.py @@ -0,0 +1,94 @@ +"""Phase S2: staged pipeline (fluid preview first, diffuse resumes).""" +from __future__ import annotations + +from kaika.core.analyze import analyze +from kaika.core import recipe as R +from kaika.core.project import Project +from kaika.core.pipeline import (run_fluid, run_diffuse, run_segment_preview, + load_run) + + +def _project(track_wav, seconds=0.4): + score = analyze(track_wav, fps=24) + rec = R.from_dict({"seed": 2, "fluid": {"resolution": 48, "render_resolution": 48}, + "diffusion": {"backend": "local", "control": ["depth"]}}) + proj = Project.from_score(score, rec, audio=track_wav.name) + proj.seconds = seconds + return proj, score + + +def test_fluid_stage_produces_preview_no_diffusion(track_wav, tmp_path): + proj, score = _project(track_wav) + res = run_fluid(proj, track_wav, runs_root=tmp_path, score=score) + rd = res.run_dir + assert (rd / "fluid_preview.mp4").exists() + assert (rd / "project.json").exists() + assert (rd / "score.json").exists() + assert any((rd / "fluid").glob("*.png")) + assert not (rd / "control").exists() # E3 deferred to diffuse stage + assert not (rd / "styled").exists() # diffusion not run yet + assert load_run(rd)["stage"] == "fluid" + assert res.backend == "fluid" + + +def test_diffuse_resumes_fluid_run(track_wav, tmp_path): + proj, score = _project(track_wav) + fluid = run_fluid(proj, track_wav, runs_root=tmp_path, score=score) + res = run_diffuse(fluid.run_dir) + rd = res.run_dir + assert any((rd / "control" / "depth").glob("*.png")) # E3 built lazily here + assert any((rd / "styled").glob("*.png")) + assert (rd / "kaika_final.mp4").exists() + m = load_run(rd) + assert m["stage"] == "done" and m["status"] == "done" + assert m["stages"]["diffuse"]["done"] is True + assert res.backend == "local" + + +def test_segment_preview_renders_only_window(track_wav, tmp_path): + """Previewing one segment writes only that window's frames + a sliced-audio + clip, leaves the full fluid/ untouched, and is the fast-iteration path.""" + proj, score = _project(track_wav, seconds=None) + fluid = run_fluid(proj, track_wav, runs_root=tmp_path, score=score) + rd = fluid.run_dir + full_frames = len(list((rd / "fluid").glob("*.png"))) + + seg = proj.segments[-1] + res = run_segment_preview(rd, len(proj.segments) - 1, draft=True) + assert res.final.name == "segment_preview.mp4" and res.final.exists() + seg_frames = len(list((rd / "seg_preview" / "fluid").glob("*.png"))) + expected = int(round((seg.end - seg.start) * proj.fps)) + assert abs(seg_frames - expected) <= 2 # only the window rendered + assert seg_frames < full_frames + # the full-track fluid is untouched, and no velocity dumped for the preview + assert len(list((rd / "fluid").glob("*.png"))) == full_frames + assert not (rd / "seg_preview" / "velocity").exists() + assert load_run(rd)["segment_preview"]["index"] == len(proj.segments) - 1 + + +def test_draft_fluid_is_resimulated_full_for_diffuse(track_wav, tmp_path): + """Generating from a draft preview transparently re-runs the fluid full-res.""" + import imageio.v2 as imageio + proj, score = _project(track_wav) + proj.recipe.fluid.resolution = 200 # full-res above the draft cap + proj.recipe.fluid.render_resolution = 320 + fluid = run_fluid(proj, track_wav, runs_root=tmp_path, score=score, draft=True) + rd = fluid.run_dir + first = imageio.imread(sorted((rd / "fluid").glob("*.png"))[0]) + assert first.shape[0] <= 224 # draft cap applied + run_diffuse(rd) + first = imageio.imread(sorted((rd / "fluid").glob("*.png"))[0]) + assert first.shape[0] == 320 # re-simulated at full quality + assert (rd / "kaika_final.mp4").exists() + + +def test_repreview_overwrites_same_run(track_wav, tmp_path): + """Editing params and re-previewing into the same run id refreshes the fluid.""" + proj, score = _project(track_wav) + r1 = run_fluid(proj, track_wav, runs_root=tmp_path, run_id="myrun", score=score) + # change a segment param and re-run into the same id + proj.segments[-1].fluid = {"vorticity": {"min": 4, "max": 70}} + r2 = run_fluid(proj, track_wav, runs_root=tmp_path, run_id="myrun", score=score) + assert r1.run_dir == r2.run_dir + saved = Project.from_json(r2.run_dir / "project.json") + assert saved.segments[-1].fluid["vorticity"]["max"] == 70 diff --git a/kaika/webapp/index.html b/kaika/webapp/index.html new file mode 100644 index 0000000..7d9e8a9 --- /dev/null +++ b/kaika/webapp/index.html @@ -0,0 +1,12 @@ + + + + + + Kaika 開花 + + +
+ + + diff --git a/kaika/webapp/package-lock.json b/kaika/webapp/package-lock.json new file mode 100644 index 0000000..f154b5e --- /dev/null +++ b/kaika/webapp/package-lock.json @@ -0,0 +1,1769 @@ +{ + "name": "kaika-webapp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kaika-webapp", + "version": "0.1.0", + "dependencies": { + "js-yaml": "^4.2.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.4", + "vite": "^5.4.2" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.35", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz", + "integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001797", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", + "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.371", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz", + "integrity": "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/kaika/webapp/package.json b/kaika/webapp/package.json new file mode 100644 index 0000000..77f2288 --- /dev/null +++ b/kaika/webapp/package.json @@ -0,0 +1,24 @@ +{ + "name": "kaika-webapp", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "js-yaml": "^4.2.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.4", + "vite": "^5.4.2" + } +} diff --git a/kaika/webapp/src/App.tsx b/kaika/webapp/src/App.tsx new file mode 100644 index 0000000..20173c5 --- /dev/null +++ b/kaika/webapp/src/App.tsx @@ -0,0 +1,44 @@ +import { useState } from "react"; +import Studio from "./components/Studio"; +import RenderView from "./components/RenderView"; +import Gallery from "./components/Gallery"; + +type View = "studio" | "render" | "gallery"; + +export default function App() { + const [view, setView] = useState("studio"); + const [runId, setRunId] = useState(null); + const [jobId, setJobId] = useState(null); + + const tab = (v: View, label: string) => ( + + ); + + return ( +
+
+ Kaika 開花 + +
+ + {view === "studio" && ( + { setRunId(rid); setJobId(jid); setView("render"); }} + /> + )} + {view === "render" && ( + setView("gallery")} /> + )} + {view === "gallery" && ( + { setRunId(rid); setView("studio"); }} /> + )} +
+ ); +} diff --git a/kaika/webapp/src/api.ts b/kaika/webapp/src/api.ts new file mode 100644 index 0000000..3aaef1e --- /dev/null +++ b/kaika/webapp/src/api.ts @@ -0,0 +1,83 @@ +// Typed client for the Kaika API. Same origin in production; Vite proxies in dev. + +export interface Section { start: number; end: number; label: string; energy?: number; } +export interface Beat { t: number; mag: number; } +export interface Segment { + start: number; end: number; label: string; + prompt: string; + fluid: any; // partial fluid overrides +} +export interface Analysis { + tempo_bpm: number; duration_s: number; fps: number; n_frames: number; + beats: Beat[]; onsets?: Record; + onset_counts: Record; waveform: number[]; +} +export interface ProjectDoc { + audio: string; fps: number; seconds: number | null; recipe: any; segments: Segment[]; +} +export interface ProjectPayload { + run_id: string; project: ProjectDoc; manifest: RunManifest; + analysis?: Analysis; audio_url?: string | null; +} +export interface RecipeEntry { name: string; yaml: string; recipe: any; } +export interface JobState { + id: string; status: string; stage: string | null; + done: number; total: number; run_id: string | null; error: string | null; kind?: string; +} +export interface RunManifest { + id: string; created: number; recipe: string; fps: number; n_frames: number; + stage?: string; status: string; sync: { lag_frames: number; correlation: number } | null; + final?: string; fluid_preview?: string; stages: Record; +} + +async function j(r: Response): Promise { + if (!r.ok) throw new Error((await r.text()) || r.statusText); + return r.json() as Promise; +} +const JSON_H = { "Content-Type": "application/json" }; + +export const api = { + recipes: () => fetch("/api/recipes").then(j), + + upload: (file: File) => { + const fd = new FormData(); + fd.append("file", file); + return fetch("/api/upload", { method: "POST", body: fd }) + .then(j<{ audio_id: string; name: string }>); + }, + + // ---- projects (segment editor) ---- + createProject: (body: { audio_id: string; recipe?: any; recipe_name?: string; seconds?: number }) => + fetch("/api/projects", { method: "POST", headers: JSON_H, body: JSON.stringify(body) }) + .then(j), + getProject: (runId: string) => fetch(`/api/projects/${runId}`).then(j), + updateProject: (runId: string, body: { segments?: Segment[]; recipe?: any; seconds?: number }) => + fetch(`/api/projects/${runId}`, { method: "PUT", headers: JSON_H, body: JSON.stringify(body) }) + .then(j), + previewProject: (runId: string, draft = false) => + fetch(`/api/projects/${runId}/preview`, { method: "POST", headers: JSON_H, body: JSON.stringify({ draft }) }) + .then(j<{ job_id: string }>), + previewSegment: (runId: string, index: number, draft = true) => + fetch(`/api/projects/${runId}/preview_segment`, { method: "POST", headers: JSON_H, body: JSON.stringify({ index, draft }) }) + .then(j<{ job_id: string }>), + generateProject: (runId: string) => + fetch(`/api/projects/${runId}/generate`, { method: "POST" }).then(j<{ job_id: string }>), + + job: (id: string) => fetch(`/api/jobs/${id}`).then(j), + runs: () => fetch("/api/runs").then(j), + run: (id: string) => fetch(`/api/runs/${id}`).then(j), + finalUrl: (id: string) => `/api/runs/${id}/final`, + previewUrl: (id: string) => `/api/runs/${id}/files/fluid_preview.mp4`, + segPreviewUrl: (id: string) => `/api/runs/${id}/files/segment_preview.mp4`, + posterUrl: (id: string) => `/api/runs/${id}/latest_frame`, + fileUrl: (id: string, sub: string) => `/api/runs/${id}/files/${sub}`, + + watchJob: (id: string, onMsg: (s: JobState) => void): WebSocket => { + const proto = location.protocol === "https:" ? "wss" : "ws"; + const ws = new WebSocket(`${proto}://${location.host}/ws/jobs/${id}`); + ws.onmessage = (e) => onMsg(JSON.parse(e.data)); + return ws; + }, +}; + +export const STAGES = ["analyze", "simulate", "control", "diffuse", "post"]; diff --git a/kaika/webapp/src/components/Gallery.tsx b/kaika/webapp/src/components/Gallery.tsx new file mode 100644 index 0000000..97888da --- /dev/null +++ b/kaika/webapp/src/components/Gallery.tsx @@ -0,0 +1,128 @@ +import { useEffect, useRef, useState } from "react"; +import { api, RunManifest } from "../api"; + +interface Props { + onOpenInStudio: (runId: string) => void; +} + +function runVideoUrl(r: RunManifest): string | null { + if (r.stage === "done" || r.status === "done") return api.finalUrl(r.id); + if (r.fluid_preview) return api.previewUrl(r.id); + return null; +} + +/** Two videos, one transport: play/pause/seek both on the same timeline. */ +function Compare({ a, b, onClose }: { a: RunManifest; b: RunManifest; onClose: () => void }) { + const va = useRef(null); + const vb = useRef(null); + const [playing, setPlaying] = useState(false); + + const both = (fn: (v: HTMLVideoElement) => void) => { + if (va.current) fn(va.current); + if (vb.current) fn(vb.current); + }; + const toggle = () => { + if (playing) both((v) => v.pause()); + else { both((v) => { v.currentTime = va.current?.currentTime ?? 0; v.play(); }); } + setPlaying(!playing); + }; + const seek = (t: number) => both((v) => { v.currentTime = t; }); + + return ( +
+
+

Compare

+ +
+
+ {[{ r: a, ref: va }, { r: b, ref: vb }].map(({ r, ref }) => ( +
+
+ ))} +
+
+ + seek(parseFloat(e.target.value))} /> +
+

+ Same audio timeline, both videos locked together — judge one parameter change. +

+
+ ); +} + +export default function Gallery({ onOpenInStudio }: Props) { + const [runs, setRuns] = useState([]); + const [picked, setPicked] = useState([]); + const [comparing, setComparing] = useState(false); + + useEffect(() => { api.runs().then(setRuns).catch(() => setRuns([])); }, []); + + const togglePick = (id: string) => + setPicked((p) => p.includes(id) ? p.filter((x) => x !== id) : [...p, id].slice(-2)); + + if (runs.length === 0) { + return ( +
+

No runs yet. Create one in the Studio.

+
+ ); + } + + const pair = picked.map((id) => runs.find((r) => r.id === id)!).filter(Boolean); + + return ( + <> + {comparing && pair.length === 2 ? ( + setComparing(false)} /> + ) : ( + picked.length === 2 && ( +
+ 2 runs selected + +
+ ) + )} + +
+ {runs.map((r) => { + const url = runVideoUrl(r); + const hasFinal = r.stage === "done" || r.status === "done"; + return ( +
+ {url ?
+ ); + })} +
+ + ); +} diff --git a/kaika/webapp/src/components/RenderView.tsx b/kaika/webapp/src/components/RenderView.tsx new file mode 100644 index 0000000..aa48b63 --- /dev/null +++ b/kaika/webapp/src/components/RenderView.tsx @@ -0,0 +1,134 @@ +import { useEffect, useRef, useState } from "react"; +import { api, JobState, STAGES } from "../api"; + +const LIVE_POLL_MS = 1200; + +interface Props { + runId: string | null; + jobId: string | null; + onSeeGallery: () => void; +} + +export default function RenderView({ runId, jobId, onSeeGallery }: Props) { + const [job, setJob] = useState(null); + const [liveFrame, setLiveFrame] = useState(null); + const [watchedJob, setWatchedJob] = useState(null); + const wsRef = useRef(null); + + const watch = (id: string) => { + wsRef.current?.close(); + setJob(null); + setWatchedJob(id); + wsRef.current = api.watchJob(id, setJob); + }; + + useEffect(() => { + if (jobId) watch(jobId); + return () => wsRef.current?.close(); + }, [jobId]); + + // live peek: poll the newest frame on disk while the job runs + const running = job?.status === "running"; + useEffect(() => { + if (!running || !runId) { setLiveFrame(null); return; } + const t = window.setInterval(() => { + setLiveFrame(`/api/runs/${runId}/latest_frame?ts=${Date.now()}`); + }, LIVE_POLL_MS); + return () => window.clearInterval(t); + }, [running, runId]); + + const cancel = async () => { + if (!watchedJob) return; + await fetch(`/api/jobs/${watchedJob}/cancel`, { method: "POST" }).catch(() => {}); + }; + + const generate = async () => { + if (!runId) return; + const { job_id } = await api.generateProject(runId); + watch(job_id); + }; + + if (!jobId || !runId) { + return ( +
+

No active render. Start a fluid preview from the Studio.

+
+ ); + } + + const kind = job?.kind || "fluid"; + const stageIndex = job?.stage ? STAGES.indexOf(job.stage) : -1; + const done = job?.status === "done"; + const isSegment = kind === "fluid_segment"; + const isFluid = kind === "fluid" || isSegment; + // show only the stages this kind of job actually runs + const stages = isSegment ? ["simulate", "post"] + : isFluid ? STAGES.filter((s) => s !== "diffuse" && s !== "control") : STAGES; + + return ( +
+
+

{isSegment ? "Segment preview" : isFluid ? "Fluid preview" : "Diffusion"}

+
+ {stages.map((name) => { + const isCurrent = job?.stage === name && job.status === "running"; + const isDone = done || stageIndex > STAGES.indexOf(name) || + (job?.stage === name && job?.total! > 0 && job?.done === job?.total); + const pct = isDone ? 100 : isCurrent && job?.total ? Math.round((job.done / job.total) * 100) : 0; + return ( +
+ {name} +
+ {isDone ? "done" : isCurrent ? `${pct}%` : "—"} +
+ ); + })} +
+ {job?.status === "error" &&

Error: {job.error}

} + {job?.status === "cancelled" &&

Cancelled.

} + {running && ( + + )} + {running && liveFrame && ( +
+

Live frame

+ ((e.target as HTMLImageElement).style.display = "none")} + onLoad={(e) => ((e.target as HTMLImageElement).style.display = "block")} /> +
+ )} +
+ + +
+ ); +} diff --git a/kaika/webapp/src/components/Studio.tsx b/kaika/webapp/src/components/Studio.tsx new file mode 100644 index 0000000..380e8b1 --- /dev/null +++ b/kaika/webapp/src/components/Studio.tsx @@ -0,0 +1,416 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import yaml from "js-yaml"; +import { api, Analysis, ProjectDoc, RecipeEntry, Segment } from "../api"; +import Waveform from "./Waveform"; + +interface Props { + initialRunId?: string | null; // reopen an existing project (Gallery) + onPreview: (runId: string, jobId: string) => void; +} + +const MIN_SEG_S = 0.4; + +// read/write a nested value in a segment's partial fluid-override object +function setNested(obj: any, path: string[], value: any) { + const next = structuredClone(obj || {}); + let o = next; + for (let i = 0; i < path.length - 1; i++) o = o[path[i]] ??= {}; + o[path[path.length - 1]] = value; + return next; +} +function getNested(obj: any, path: string[], fallback: number): number { + let o = obj; + for (const k of path) { if (o == null) return fallback; o = o[k]; } + return o == null ? fallback : o; +} +function hasNested(obj: any, path: string[]): boolean { + let o = obj; + for (const k of path) { if (o == null || typeof o !== "object" || !(k in o)) return false; o = o[k]; } + return o !== undefined; +} + +// [label, path, default, min, max, step] +type Row = [string, string[], number, number, number, number]; +const PRIMARY: Row[] = [ + ["Vorticity max", ["vorticity", "max"], 38, 5, 90, 1], + ["Kick emit", ["splats", "low", "emit"], 0.22, 0, 0.6, 0.02], + ["Hat emit", ["splats", "high", "emit"], 0.11, 0, 0.4, 0.01], + ["Ambient stir", ["ambient_strength"], 1.6, 0, 6, 0.2], +]; +const ADVANCED: Row[] = [ + ["Kick lifetime (s)", ["splats", "low", "lifetime_s"], 0.8, 0.2, 3, 0.1], + ["Hat lifetime (s)", ["splats", "high", "lifetime_s"], 0.3, 0.1, 1.5, 0.05], + ["Kick speed", ["splats", "low", "speed"], 1.3, 0, 4, 0.1], + ["Hat speed", ["splats", "high", "speed"], 2.6, 0, 5, 0.1], + ["Exposure", ["exposure"], 1.9, 0.5, 4, 0.1], + ["Bloom", ["bloom"], 0.65, 0, 2, 0.05], + ["Dissipation", ["dissipation"], 0.9, 0.8, 0.99, 0.01], +]; + +export default function Studio({ initialRunId, onPreview }: Props) { + const [recipes, setRecipes] = useState([]); + const [recipeName, setRecipeName] = useState("eclosion"); + const [runId, setRunId] = useState(null); + const [project, setProject] = useState(null); + const [analysis, setAnalysis] = useState(null); + const [audioUrl, setAudioUrl] = useState(null); + const [sel, setSel] = useState(0); + const [busy, setBusy] = useState(false); + const [hover, setHover] = useState(false); + const [err, setErr] = useState(""); + const [draft, setDraft] = useState(true); + const [tab, setTab] = useState<"segment" | "recipe" | "yaml">("segment"); + const [yamlText, setYamlText] = useState(""); + const [yamlErr, setYamlErr] = useState(""); + const [playhead, setPlayhead] = useState(0); + const [playing, setPlaying] = useState(false); + const audioRef = useRef(null); + const saveTimer = useRef(undefined); + + useEffect(() => { api.recipes().then(setRecipes).catch(() => {}); }, []); + + const adopt = (p: Awaited>) => { + setRunId(p.run_id); + setProject(p.project); + if (p.analysis) setAnalysis(p.analysis); + setAudioUrl(p.audio_url ?? null); + setSel(0); + }; + + useEffect(() => { + if (initialRunId) { + api.getProject(initialRunId).then(adopt).catch((e) => setErr(String(e.message || e))); + } + }, [initialRunId]); + + // playhead follows the audio element while playing + useEffect(() => { + if (!playing) return; + let raf = 0; + const tick = () => { + const a = audioRef.current; + if (a) setPlayhead(a.currentTime); + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [playing]); + + // debounced autosave of segment edits + const scheduleSave = useCallback((segs: Segment[]) => { + if (!runId) return; + window.clearTimeout(saveTimer.current); + saveTimer.current = window.setTimeout(() => { + api.updateProject(runId, { segments: segs }).catch(() => {}); + }, 700); + }, [runId]); + + const setSegments = (segs: Segment[]) => { + setProject((p) => (p ? { ...p, segments: segs } : p)); + scheduleSave(segs); + }; + + const upload = async (file: File) => { + setErr(""); setBusy(true); + try { + const { audio_id } = await api.upload(file); + adopt(await api.createProject({ audio_id, recipe_name: recipeName })); + } catch (e: any) { setErr(String(e.message || e)); } + finally { setBusy(false); } + }; + + // ---- segment operations ------------------------------------------------- + const updateSegment = (patch: Partial) => { + if (!project) return; + setSegments(project.segments.map((s, i) => (i === sel ? { ...s, ...patch } : s))); + }; + const setFluid = (path: string[], value: number) => + updateSegment({ fluid: setNested(project!.segments[sel].fluid, path, value) }); + + const moveBoundary = (b: number, t: number) => { + if (!project) return; + const segs = structuredClone(project.segments); + const lo = segs[b - 1].start + MIN_SEG_S; + const hi = segs[b].end - MIN_SEG_S; + const tt = Math.max(lo, Math.min(hi, t)); + segs[b - 1].end = tt; + segs[b].start = tt; + setSegments(segs); + }; + + const splitAtPlayhead = () => { + if (!project) return; + const t = playhead; + const i = project.segments.findIndex((s) => t > s.start + MIN_SEG_S && t < s.end - MIN_SEG_S); + if (i < 0) return; + const segs = structuredClone(project.segments); + const s = segs[i]; + const right: Segment = { ...structuredClone(s), start: t }; + s.end = t; + segs.splice(i + 1, 0, right); + setSegments(segs); + setSel(i + 1); + }; + + const mergeWithNext = () => { + if (!project || sel >= project.segments.length - 1) return; + const segs = structuredClone(project.segments); + segs[sel].end = segs[sel + 1].end; + segs.splice(sel + 1, 1); + setSegments(segs); + }; + + // ---- recipe (global) ---------------------------------------------------- + const setRecipeField = (path: string[], value: any) => { + if (!project || !runId) return; + const rec = setNested(project.recipe, path, value); + setProject({ ...project, recipe: rec }); + window.clearTimeout(saveTimer.current); + saveTimer.current = window.setTimeout(() => { + api.updateProject(runId, { recipe: rec }).catch(() => {}); + }, 700); + }; + + const openYaml = () => { + setYamlText(yaml.dump(project?.recipe ?? {}, { noRefs: true })); + setYamlErr(""); + setTab("yaml"); + }; + const applyYaml = async () => { + if (!project || !runId) return; + try { + const rec = yaml.load(yamlText) as any; + setProject({ ...project, recipe: rec }); + await api.updateProject(runId, { recipe: rec }); + setYamlErr(""); + setTab("recipe"); + } catch (e: any) { setYamlErr(String(e.message || e)); } + }; + + // ---- actions ------------------------------------------------------------ + const flushSave = async () => { + if (!runId || !project) return; + window.clearTimeout(saveTimer.current); + await api.updateProject(runId, { segments: project.segments, recipe: project.recipe }); + }; + const previewSegment = async () => { + if (!runId) return; + setBusy(true); setErr(""); + try { + await flushSave(); + const { job_id } = await api.previewSegment(runId, sel, draft); + onPreview(runId, job_id); + } catch (e: any) { setErr(String(e.message || e)); setBusy(false); } + }; + const previewFull = async () => { + if (!runId) return; + setBusy(true); setErr(""); + try { + await flushSave(); + const { job_id } = await api.previewProject(runId, draft); + onPreview(runId, job_id); + } catch (e: any) { setErr(String(e.message || e)); setBusy(false); } + }; + + const togglePlay = () => { + const a = audioRef.current; + if (!a) return; + if (a.paused) { a.play(); setPlaying(true); } + else { a.pause(); setPlaying(false); } + }; + const seek = (t: number) => { + const a = audioRef.current; + if (a) a.currentTime = t; + setPlayhead(t); + }; + + const resetSegment = () => updateSegment({ fluid: {} }); + + const seg = project?.segments[sel]; + const nSeg = project?.segments.length ?? 0; + const palette: string[] = project?.recipe?.fluid?.palette ?? []; + + const sliderRow = ([name, path, dflt, min, max, step]: Row) => { + const ov = seg ? hasNested(seg.fluid, path) : false; + return ( +
+ + setFluid(path, parseFloat(e.target.value))} /> +
+ ); + }; + + return ( +
+
+ {!project && ( +
{ e.preventDefault(); setHover(true); }} + onDragLeave={() => setHover(false)} + onDrop={(e) => { e.preventDefault(); setHover(false); const f = e.dataTransfer.files[0]; if (f) upload(f); }}> +

Drop an audio file here

+

analysis splits it into editable segments

+ +
+ + +
+
+ )} + + {project && analysis && ( +
+
+ + {playhead.toFixed(1)}s / {analysis.duration_s.toFixed(1)}s + + {analysis.tempo_bpm} BPM · {project.segments.length} segments + +
+ {audioUrl && ( +
+ )} + {err &&

{err}

} +
+ +