From c0c410aeb3fc5a23088ada36138f0f7cb2b1a4b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:04:58 +0000 Subject: [PATCH 01/31] Kaika phase 0: project skeleton, packaging, smoke tests --- kaika/.gitignore | 9 ++++++ kaika/README.md | 33 ++++++++++++++++++++ kaika/pyproject.toml | 39 ++++++++++++++++++++++++ kaika/src/kaika/__init__.py | 1 + kaika/src/kaika/core/__init__.py | 0 kaika/src/kaika/core/diffuse/__init__.py | 0 kaika/src/kaika/server/__init__.py | 0 kaika/tests/test_smoke.py | 18 +++++++++++ 8 files changed, 100 insertions(+) create mode 100644 kaika/.gitignore create mode 100644 kaika/README.md create mode 100644 kaika/pyproject.toml create mode 100644 kaika/src/kaika/__init__.py create mode 100644 kaika/src/kaika/core/__init__.py create mode 100644 kaika/src/kaika/core/diffuse/__init__.py create mode 100644 kaika/src/kaika/server/__init__.py create mode 100644 kaika/tests/test_smoke.py diff --git a/kaika/.gitignore b/kaika/.gitignore new file mode 100644 index 0000000..00cfee1 --- /dev/null +++ b/kaika/.gitignore @@ -0,0 +1,9 @@ +.venv/ +__pycache__/ +*.pyc +*.egg-info/ +runs/ +.pytest_cache/ +node_modules/ +webapp/dist/ +.DS_Store diff --git a/kaika/README.md b/kaika/README.md new file mode 100644 index 0000000..c8710b0 --- /dev/null +++ b/kaika/README.md @@ -0,0 +1,33 @@ +# 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. + +See `../project_ideas/kaika.md` for the full specification. + +## Quickstart + +```bash +uv venv && . .venv/bin/activate +uv pip install -e ".[dev]" +pytest # run the test suite +kaika --help # CLI +kaika run path/to/track.wav --recipe eclosion --seconds 4 # render a short clip +kaika serve # launch the local app (http://localhost:8400) +``` + +## Pipeline + +| Stage | Module | In → Out | +| --- | --- | --- | +| E1 analyze | `kaika.core.analyze` | audio → `score.json` | +| E2 simulate | `kaika.core.simulate` | score + recipe → fluid frames + velocity | +| E3 control | `kaika.core.control` | fluid → depth / canny / flow | +| E4 diffuse | `kaika.core.diffuse` | fluid + control → styled frames | +| E5 post | `kaika.core.post` | styled frames + audio → `final.mp4` | + +Each stage reads and writes a run directory (`runs//`) and is independently +testable. E4 ships with a deterministic local stylizer fallback so the whole +pipeline runs end-to-end with no GPU; the ComfyUI / rented-GPU backend is a +drop-in replacement behind the same interface. diff --git a/kaika/pyproject.toml b/kaika/pyproject.toml new file mode 100644 index 0000000..78eeb22 --- /dev/null +++ b/kaika/pyproject.toml @@ -0,0 +1,39 @@ +[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", +] + +[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.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" 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/core/__init__.py b/kaika/src/kaika/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kaika/src/kaika/core/diffuse/__init__.py b/kaika/src/kaika/core/diffuse/__init__.py new file mode 100644 index 0000000..e69de29 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/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) From 233ef2f6105db9b4c7dbade9316e2930600deb3a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:07:29 +0000 Subject: [PATCH 02/31] Kaika phase 1: E1 audio analysis (score model + analyzer) with tests --- kaika/src/kaika/core/analyze.py | 162 ++++++++++++++++++++++++++++++++ kaika/src/kaika/core/score.py | 79 ++++++++++++++++ kaika/tests/conftest.py | 52 ++++++++++ kaika/tests/test_analyze.py | 55 +++++++++++ 4 files changed, 348 insertions(+) create mode 100644 kaika/src/kaika/core/analyze.py create mode 100644 kaika/src/kaika/core/score.py create mode 100644 kaika/tests/conftest.py create mode 100644 kaika/tests/test_analyze.py diff --git a/kaika/src/kaika/core/analyze.py b/kaika/src/kaika/core/analyze.py new file mode 100644 index 0000000..a9363e3 --- /dev/null +++ b/kaika/src/kaika/core/analyze.py @@ -0,0 +1,162 @@ +"""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.""" + 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) + 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)] + + onsets = { + "low": _band_onsets(S[low_mask], sr, hop), + "mid": _band_onsets(S[mid_mask], sr, hop), + "high": _band_onsets(S[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/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/tests/conftest.py b/kaika/tests/conftest.py new file mode 100644 index 0000000..1d1d2db --- /dev/null +++ b/kaika/tests/conftest.py @@ -0,0 +1,52 @@ +"""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: + band = rng.standard_normal(end - start).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") diff --git a/kaika/tests/test_analyze.py b/kaika/tests/test_analyze.py new file mode 100644 index 0000000..45c2e49 --- /dev/null +++ b/kaika/tests/test_analyze.py @@ -0,0 +1,55 @@ +"""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_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 From 5caf56a328b57cd53013ab408c81b43e0b92e25c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:11:35 +0000 Subject: [PATCH 03/31] Kaika phase 2: E2 fluid simulation + recipe model with tests --- kaika/recipes/eclosion.yaml | 33 +++++ kaika/src/kaika/core/recipe.py | 135 +++++++++++++++++ kaika/src/kaika/core/simulate.py | 247 +++++++++++++++++++++++++++++++ kaika/tests/test_recipe.py | 42 ++++++ kaika/tests/test_simulate.py | 86 +++++++++++ 5 files changed, 543 insertions(+) create mode 100644 kaika/recipes/eclosion.yaml create mode 100644 kaika/src/kaika/core/recipe.py create mode 100644 kaika/src/kaika/core/simulate.py create mode 100644 kaika/tests/test_recipe.py create mode 100644 kaika/tests/test_simulate.py diff --git a/kaika/recipes/eclosion.yaml b/kaika/recipes/eclosion.yaml new file mode 100644 index 0000000..2a8308e --- /dev/null +++ b/kaika/recipes/eclosion.yaml @@ -0,0 +1,33 @@ +name: eclosion +seed: 4217 + +fluid: + resolution: 256 + render_resolution: 512 + dissipation: 0.985 + lookahead_s: 8.0 + splats: + low: { radius: 0.12, force: 9000, placement: anchored } + high: { radius: 0.03, force: 3500, placement: scatter, max_per_beat: 5 } + 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/src/kaika/core/recipe.py b/kaika/src/kaika/core/recipe.py new file mode 100644 index 0000000..476e9a4 --- /dev/null +++ b/kaika/src/kaika/core/recipe.py @@ -0,0 +1,135 @@ +"""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 +from pathlib import Path +from typing import Dict, List + +import yaml + +RECIPES_DIR = Path(__file__).resolve().parents[3] / "recipes" + + +@dataclass +class Splat: + radius: float = 0.08 + force: float = 6000.0 + placement: str = "scatter" # "anchored" | "scatter" + max_per_beat: int = 4 + + +@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.99 # density decay per step + viscosity: float = 0.0 + lookahead_s: float = 8.0 + splats: Dict[str, Splat] = field(default_factory=lambda: { + "low": Splat(radius=0.12, force=9000.0, placement="anchored"), + "high": Splat(radius=0.03, force=3500.0, placement="scatter", max_per_beat=5), + }) + vorticity: Vorticity = field(default_factory=Vorticity) + + +@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" + + +@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 _merge(default, data): + """Build a dataclass instance from defaults overlaid with a dict.""" + if data is None: + return default + kwargs = {} + for f in default.__dataclass_fields__.values(): + cur = getattr(default, f.name) + if f.name in data: + val = data[f.name] + if f.name == "splats" and isinstance(val, dict): + kwargs[f.name] = {k: Splat(**v) for k, v in val.items()} + elif f.name == "vorticity" and isinstance(val, dict): + kwargs[f.name] = Vorticity(**val) + else: + kwargs[f.name] = val + else: + kwargs[f.name] = cur + return type(default)(**kwargs) + + +def from_dict(d: dict) -> Recipe: + d = dict(d or {}) + r = Recipe() + return Recipe( + name=d.get("name", r.name), + seed=int(d.get("seed", r.seed)), + fluid=_merge(FluidConfig(), d.get("fluid")), + diffusion=_merge(DiffusionConfig(), d.get("diffusion")), + post=_merge(PostConfig(), d.get("post")), + prompts={**r.prompts, **(d.get("prompts") 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/simulate.py b/kaika/src/kaika/core/simulate.py new file mode 100644 index 0000000..f608d77 --- /dev/null +++ b/kaika/src/kaika/core/simulate.py @@ -0,0 +1,247 @@ +"""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, so it is fully deterministic (seed -> identical +video) and runs anywhere with no GPU. The simulation is the *movement skeleton*: +audio onsets inject splats, RMS drives vorticity, and an upcoming drop is +anticipated by sub-visible vortices (lookahead). + +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 typing import Callable, List, Optional + +import numpy as np +from scipy.ndimage import map_coordinates + +from .score import Score +from .recipe import Recipe + +ProgressFn = Callable[[int, int], None] + + +def _hue_to_rgb(hue: np.ndarray) -> np.ndarray: + """Vectorised HSV(h, 1, 1) -> RGB for a hue array in [0, 1].""" + h6 = (hue % 1.0) * 6.0 + i = np.floor(h6).astype(int) + f = h6 - i + p = np.zeros_like(hue) + q = 1.0 - f + t = f + r = np.choose(i % 6, [1, q, p, p, t, 1.0 * np.ones_like(hue)]) + g = np.choose(i % 6, [t, 1.0 * np.ones_like(hue), 1, q, p, p]) + b = np.choose(i % 6, [p, p, t, 1.0 * np.ones_like(hue), 1, q]) + return np.stack([r, g, b], axis=-1) + + +def _centroid_to_hue(centroid_hz: float) -> float: + """Map spectral centroid to a hue: low->magenta/pink, high->teal/cyan.""" + lo, hi = np.log10(150.0), np.log10(8000.0) + x = (np.log10(max(centroid_hz, 150.0)) - lo) / (hi - lo) + x = float(np.clip(x, 0.0, 1.0)) + return 0.92 - 0.42 * x # 0.92 (magenta) -> 0.50 (teal/cyan) + + +@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): + self.n = n + self.dissipation = 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) + + # ---- operators --------------------------------------------------------- + def _advect(self, field: np.ndarray, dt: float) -> np.ndarray: + bx = (self.xs - dt * self.u) % self.n + by = (self.ys - dt * self.v) % self.n + if field.ndim == 2: + return map_coordinates(field, [by, bx], order=1, mode="grid-wrap") + out = np.empty_like(field) + for c in range(field.shape[2]): + out[..., c] = map_coordinates(field[..., c], [by, bx], order=1, + mode="grid-wrap") + return out + + def _project(self, iters: int = 20) -> None: + n = self.n + div = -0.5 * ((np.roll(self.u, -1, 1) - np.roll(self.u, 1, 1)) + + (np.roll(self.v, -1, 0) - np.roll(self.v, 1, 0))) / n + p = np.zeros_like(div) + for _ in range(iters): + p = (div + np.roll(p, 1, 1) + np.roll(p, -1, 1) + + np.roll(p, 1, 0) + np.roll(p, -1, 0)) / 4.0 + self.u -= 0.5 * n * (np.roll(p, -1, 1) - np.roll(p, 1, 1)) + self.v -= 0.5 * n * (np.roll(p, -1, 0) - np.roll(p, 1, 0)) + + 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_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) + self.u += (g * force * np.cos(dir_angle) / n).astype(np.float32) + self.v += (g * force * np.sin(dir_angle) / n).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.u = self._advect(self.u, dt) + self.v = self._advect(self.v, dt) + self._project() + self.density = self._advect(self.density, dt) + self.density *= self.dissipation + np.clip(self.density, 0.0, 4.0, out=self.density) + + 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.""" + 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) -> SimResult: + 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) + 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) + fc = recipe.fluid + sim = FluidSim(fc.resolution, fc.dissipation, fc.viscosity, recipe.seed) + + low_cfg = fc.splats.get("low") + high_cfg = fc.splats.get("high") + low_by_frame = _build_event_index(score.onsets.get("low", []), fps, n_frames) + high_by_frame = _build_event_index(score.onsets.get("high", []), fps, n_frames) + + rng = np.random.default_rng(recipe.seed + 1) + anchor = np.array([0.5, 0.5]) # centre of gravity for kicks + dt = 1.0 + stats = {"kinetic_energy": [], "total_density": []} + + render_res = fc.render_resolution + for i in range(n_frames): + fdata = score.frames[i] + hue = _centroid_to_hue(fdata.centroid_hz) + color = _hue_to_rgb(np.array([hue]))[0].astype(np.float32) + + # Kicks: large, slow, anchored splats. + if low_cfg: + for e in low_by_frame[i]: + jitter = rng.normal(0, 0.04, 2) + px, py = np.clip(anchor + jitter, 0.05, 0.95) + ang = rng.uniform(0, 2 * np.pi) + sim.add_splat(px, py, low_cfg.radius, low_cfg.force * (0.4 + e.mag), + color * 1.2, ang) + # Hats: small, vivid, scattered "pop everywhere". + if high_cfg: + hats = high_by_frame[i][: high_cfg.max_per_beat] + for e in hats: + px, py = rng.uniform(0.08, 0.92, 2) + ang = rng.uniform(0, 2 * np.pi) + sim.add_splat(px, py, high_cfg.radius, high_cfg.force * (0.4 + e.mag), + color, ang) + + # Lookahead: sub-visible swirl building tension before a drop. + boost = _lookahead_boost(score, i, fps, fc.lookahead_s) + if boost > 0: + for _ in range(2): + px, py = rng.uniform(0.2, 0.8, 2) + ang = rng.uniform(0, 2 * np.pi) + sim.add_splat(px, py, 0.10, 1200.0 * boost, color * 0.15 * boost, ang) + + # RMS drives vorticity between recipe min/max. + vmin, vmax = fc.vorticity.min, fc.vorticity.max + vort = vmin + (vmax - vmin) * fdata.rms + sim.step(dt, vort) + + # Render frame. + img = np.clip(sim.density, 0.0, 1.0) + frame = (img * 255).astype(np.uint8) + if render_res != fc.resolution: + import cv2 + frame = cv2.resize(frame, (render_res, render_res), + interpolation=cv2.INTER_LINEAR) + imageio.imwrite(fluid_dir / f"{i:06d}.png", frame) + np.save(vel_dir / f"{i: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(i + 1, n_frames) + + 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=n_frames, resolution=render_res) 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_simulate.py b/kaika/tests/test_simulate.py new file mode 100644 index 0000000..0d1793f --- /dev/null +++ b/kaika/tests/test_simulate.py @@ -0,0 +1,86 @@ +"""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): + return ((np.roll(u, -1, 1) - np.roll(u, 1, 1)) + + (np.roll(v, -1, 0) - np.roll(v, 1, 0))) * 0.5 + + +def _project_div(seed_force_args, iters): + sim = FluidSim(n=32, dissipation=0.99, viscosity=0.0, seed=1) + sim.add_splat(*seed_force_args) + sim._project(iters=iters) + return np.abs(_divergence(sim.u, sim.v)).mean() + + +def test_projection_converges(): + """The pressure solve must monotonically reduce divergence as the Poisson + solver iterates — proof it is actually solving for incompressibility.""" + args = (0.5, 0.5, 0.1, 8000.0, np.array([1.0, 0.2, 0.5]), 0.7) + sim = FluidSim(n=32, dissipation=0.99, viscosity=0.0, seed=1) + sim.add_splat(*args) + before = np.abs(_divergence(sim.u, sim.v)).mean() + d_few = _project_div(args, iters=5) + d_many = _project_div(args, iters=120) + assert d_few < before # any projection reduces divergence + assert d_many < d_few # more iterations -> closer to divergence-free From f82147a4a8ee27f757336f041a0ab34780c4f49b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:12:31 +0000 Subject: [PATCH 04/31] Kaika phase 3: E3 control signals (depth/canny/flow) with tests --- kaika/src/kaika/core/control.py | 97 +++++++++++++++++++++++++++++++++ kaika/tests/test_control.py | 49 +++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 kaika/src/kaika/core/control.py create mode 100644 kaika/tests/test_control.py diff --git a/kaika/src/kaika/core/control.py b/kaika/src/kaika/core/control.py new file mode 100644 index 0000000..07011f0 --- /dev/null +++ b/kaika/src/kaika/core/control.py @@ -0,0 +1,97 @@ +"""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) -> np.ndarray: + g = _luma(rgb).astype(np.float32) + lo, hi = float(g.min()), float(g.max()) + if hi - lo < 1e-6: + return np.zeros_like(g, np.uint8) + return ((g - lo) / (hi - lo) * 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) -> np.ndarray: + """Colour-code a velocity field (H,W,2) as HSV flow at the render size.""" + 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 + peak = float(mag.max()) + val = (mag / peak * 255).astype(np.uint8) if peak > 1e-6 else np.zeros_like(u, np.uint8) + hsv[..., 2] = val + 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) + 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)) + 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)) + if progress: + progress(i + 1, n) + + return ControlResult(dirs=dirs, n_frames=n) diff --git a/kaika/tests/test_control.py b/kaika/tests/test_control.py new file mode 100644 index 0000000..f36d92b --- /dev/null +++ b/kaika/tests/test_control.py @@ -0,0 +1,49 @@ +"""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_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 From c524bc2dfc98a8839e9eba885f4e527003546fcd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:14:09 +0000 Subject: [PATCH 05/31] Kaika phase 4: E5 post-production (mux, sync check, transfer codec) with tests --- kaika/src/kaika/core/media.py | 50 ++++++++++++++++ kaika/src/kaika/core/post.py | 104 ++++++++++++++++++++++++++++++++++ kaika/tests/test_post.py | 71 +++++++++++++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 kaika/src/kaika/core/media.py create mode 100644 kaika/src/kaika/core/post.py create mode 100644 kaika/tests/test_post.py 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/post.py b/kaika/src/kaika/core/post.py new file mode 100644 index 0000000..8d44fd4 --- /dev/null +++ b/kaika/src/kaika/core/post.py @@ -0,0 +1,104 @@ +"""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=ih*16/9: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) -> 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) + + args = ["-framerate", str(fps), "-i", str(frames_dir / pattern)] + if Path(audio_path).exists(): + args += ["-i", str(audio_path)] + args += ["-vf", ",".join(vf), "-r", str(out_fps), + "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18"] + if Path(audio_path).exists(): + 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/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 From 2b890cf66632f788a724d24d07acf452a62dbc3c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:17:09 +0000 Subject: [PATCH 06/31] Kaika phase 5: E4 diffusion (interface, scheduling, local fallback, ComfyUI+GPU scaffold) with tests --- kaika/src/kaika/core/diffuse/__init__.py | 23 +++ kaika/src/kaika/core/diffuse/base.py | 100 +++++++++++++ kaika/src/kaika/core/diffuse/comfy.py | 139 ++++++++++++++++++ kaika/src/kaika/core/diffuse/local.py | 65 ++++++++ kaika/src/kaika/core/diffuse/provision.py | 38 +++++ .../diffuse/workflows/wan_vace_vid2vid.json | 49 ++++++ kaika/tests/test_diffuse.py | 116 +++++++++++++++ 7 files changed, 530 insertions(+) create mode 100644 kaika/src/kaika/core/diffuse/base.py create mode 100644 kaika/src/kaika/core/diffuse/comfy.py create mode 100644 kaika/src/kaika/core/diffuse/local.py create mode 100644 kaika/src/kaika/core/diffuse/provision.py create mode 100644 kaika/src/kaika/core/diffuse/workflows/wan_vace_vid2vid.json create mode 100644 kaika/tests/test_diffuse.py diff --git a/kaika/src/kaika/core/diffuse/__init__.py b/kaika/src/kaika/core/diffuse/__init__.py index e69de29..5d50e99 100644 --- a/kaika/src/kaika/core/diffuse/__init__.py +++ 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..60c26eb --- /dev/null +++ b/kaika/src/kaika/core/diffuse/base.py @@ -0,0 +1,100 @@ +"""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 + + +@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..28eee14 --- /dev/null +++ b/kaika/src/kaika/core/diffuse/comfy.py @@ -0,0 +1,139 @@ +"""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" + + +class ComfyUnavailable(RuntimeError): + pass + + +def load_workflow_template(model: str) -> dict: + """Load the versioned workflow JSON for a model family.""" + name = "wan_vace_vid2vid" # single template for the Wan/VACE family + path = WORKFLOW_DIR / f"{name}.json" + data = json.loads(path.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_frame = 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..2c28b3b --- /dev/null +++ b/kaika/src/kaika/core/diffuse/local.py @@ -0,0 +1,65 @@ +"""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 + + +class LocalStylizer(Diffuser): + name = "local" + + def _stylize(self, fluid: np.ndarray, depth: Optional[np.ndarray], + strength: float) -> 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]) + # 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") + strength = float(req.recipe.diffusion.strength) + + frames = sorted(req.fluid_dir.glob("*.png"))[: req.n_frames] + 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) + out = self._stylize(fluid, depth, strength) + 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/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) From 052f69e5bf2e4bb431fffa0852f5befa1268b748 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:18:49 +0000 Subject: [PATCH 07/31] Kaika phase 6: pipeline orchestration + run directory + CLI run, end-to-end tested --- kaika/src/kaika/cli.py | 71 ++++++++++++++ kaika/src/kaika/core/pipeline.py | 156 +++++++++++++++++++++++++++++++ kaika/tests/test_pipeline.py | 73 +++++++++++++++ 3 files changed, 300 insertions(+) create mode 100644 kaika/src/kaika/cli.py create mode 100644 kaika/src/kaika/core/pipeline.py create mode 100644 kaika/tests/test_pipeline.py diff --git a/kaika/src/kaika/cli.py b/kaika/src/kaika/cli.py new file mode 100644 index 0000000..e07d3b0 --- /dev/null +++ b/kaika/src/kaika/cli.py @@ -0,0 +1,71 @@ +"""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: + 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/pipeline.py b/kaika/src/kaika/core/pipeline.py new file mode 100644 index 0000000..a10e201 --- /dev/null +++ b/kaika/src/kaika/core/pipeline.py @@ -0,0 +1,156 @@ +"""Pipeline orchestration: audio + recipe -> a reproducible run directory. + +Everything is a *run*. Each invocation writes ``runs//`` with the frozen +recipe, the score, every intermediate (fluid/, velocity/, control/, styled/) +and the final clip, plus a ``run.json`` manifest. Nothing the UI shows is not +re-generable from that directory. +""" +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 .analyze import analyze +from .simulate import simulate +from .control import generate_control +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 + 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 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: + if isinstance(recipe, str): + recipe = load_recipe(recipe) + audio_path = Path(audio_path) + run_id = run_id or f"{time.strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}" + run_dir = Path(runs_root) / run_id + run_dir.mkdir(parents=True, exist_ok=True) + + # Freeze inputs so the run is replayable. + recipe.to_yaml(run_dir / "recipe.yaml") + try: + shutil.copy2(audio_path, run_dir / ("audio" + audio_path.suffix)) + frozen_audio = run_dir / ("audio" + audio_path.suffix) + except OSError: + frozen_audio = audio_path + + manifest = { + "id": run_id, "created": time.time(), "audio": audio_path.name, + "recipe": recipe.name, "fps": recipe.post.fps, "seconds": seconds, + "status": "running", "stages": {}, "error": None, + } + (run_dir / "run.json").write_text(json.dumps(manifest, indent=2)) + + def save_manifest(): + (run_dir / "run.json").write_text(json.dumps(manifest, indent=2)) + + try: + fps = recipe.post.fps + # E1 + _emit(progress, "analyze", 0, 1) + score = analyze(frozen_audio, fps=fps) + score.to_json(run_dir / "score.json") + manifest["stages"]["analyze"] = {"done": True, "n_frames": score.n_frames} + max_frames = int(round(seconds * fps)) if seconds else None + n = min(score.n_frames, max_frames) if max_frames else score.n_frames + manifest["n_frames"] = n + _emit(progress, "analyze", 1, 1) + + # E2 + sim = simulate(score, recipe, run_dir, max_frames=max_frames, + progress=lambda d, t: _emit(progress, "simulate", d, t)) + manifest["stages"]["simulate"] = {"done": True, "n_frames": sim.n_frames} + save_manifest() + + # E3 + signals = recipe.diffusion.control or ["depth", "flow"] + ctrl = generate_control( + sim.fluid_dir, sim.velocity_dir, 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)} + save_manifest() + + # E4 + diffuser = D.get_diffuser(recipe) + req = D.DiffuseRequest(fluid_dir=sim.fluid_dir, control_dirs=ctrl.dirs, + out_dir=run_dir, score=score, recipe=recipe, n_frames=n) + dres = diffuser.run(req, progress=lambda d, t: _emit(progress, "diffuse", d, t)) + manifest["stages"]["diffuse"] = {"done": True, "backend": dres.backend, + "n_frames": dres.n_frames} + save_manifest() + + # E5 — prefer styled frames; fall back to raw fluid if backend produced none. + styled = dres.styled_dir + frames_for_post = styled if any(styled.glob("*.png")) else sim.fluid_dir + _emit(progress, "post", 0, 1) + final = run_dir / "kaika_final.mp4" + post = assemble(frames_for_post, frozen_audio, final, fps=fps, + aspect=recipe.post.aspect, interpolate=recipe.post.interpolate, + upscale=recipe.post.upscale, score=score, + fluid_stats_path=sim.stats_path) + manifest["stages"]["post"] = {"done": True} + manifest["sync"] = asdict(post.sync) if post.sync else None + manifest["final"] = final.name + manifest["status"] = "done" + _emit(progress, "post", 1, 1) + save_manifest() + + return RunResult( + run_id=run_id, 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["status"] = "error" + manifest["error"] = f"{type(e).__name__}: {e}" + save_manifest() + raise + + +def load_run(run_dir: str | Path) -> dict: + return json.loads((Path(run_dir) / "run.json").read_text()) + + +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/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") From 197b9c93597079a682de37d232dae19f56267f45 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:22:08 +0000 Subject: [PATCH 08/31] Kaika phase 7: FastAPI server, SQLite job queue, WebSocket progress, run gallery API --- kaika/pyproject.toml | 1 + kaika/src/kaika/server/app.py | 203 +++++++++++++++++++++++++++++++++ kaika/src/kaika/server/db.py | 64 +++++++++++ kaika/src/kaika/server/jobs.py | 92 +++++++++++++++ kaika/tests/test_server.py | 108 ++++++++++++++++++ 5 files changed, 468 insertions(+) create mode 100644 kaika/src/kaika/server/app.py create mode 100644 kaika/src/kaika/server/db.py create mode 100644 kaika/src/kaika/server/jobs.py create mode 100644 kaika/tests/test_server.py diff --git a/kaika/pyproject.toml b/kaika/pyproject.toml index 78eeb22..763b210 100644 --- a/kaika/pyproject.toml +++ b/kaika/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "uvicorn[standard]>=0.27", "pydantic>=2.6", "websockets>=12.0", + "python-multipart>=0.0.9", ] [project.optional-dependencies] diff --git a/kaika/src/kaika/server/app.py b/kaika/src/kaika/server/app.py new file mode 100644 index 0000000..7d092da --- /dev/null +++ b/kaika/src/kaika/server/app.py @@ -0,0 +1,203 @@ +"""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 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.pipeline import load_run, list_runs +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 + + +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) ------------------------------------------------------ + @app.post("/api/runs") + def start_run(req: RunRequest): + path = _resolve_audio(req.audio_id) + if req.recipe is not None: + rec = R.from_dict(req.recipe) + name = rec.name + else: + rec = R.load_recipe(req.recipe_name or "eclosion") + name = rec.name + job_id = jm.submit(path, rec, seconds=req.seconds, recipe_name=name) + return {"job_id": job_id} + + @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.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() + if not str(target).startswith(str(rd)) or 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..53d73d9 --- /dev/null +++ b/kaika/src/kaika/server/jobs.py @@ -0,0 +1,92 @@ +"""Background job queue: one render at a time, with live progress state. + +A single worker thread drains a FIFO queue (the local sim already saturates the +machine). Progress is kept in a thread-safe in-memory dict that the WebSocket +endpoint polls and streams — no cross-thread asyncio juggling. +""" +from __future__ import annotations + +import queue +import threading +import time +import uuid +from pathlib import Path +from typing import Dict, Optional + +from ..core import recipe as R +from ..core.pipeline import run_pipeline +from .db import JobDB + + +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 + # rehydrate history from db (mark interrupted jobs as such) + 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"], "recipe": 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, audio_path: str | Path, recipe, seconds=None, + recipe_name: str = "") -> str: + 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": None, "error": None, + "recipe": recipe_name or getattr(recipe, "name", "recipe"), + "_audio": str(audio_path), "_recipe": recipe, "_seconds": seconds, + } + self.db.create(job_id, self._jobs[job_id]["recipe"], Path(audio_path).name) + 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 _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 # idle -> let the thread die; resurrected on next submit + self._run_one(job_id) + + def _run_one(self, job_id: str): + with self._lock: + job = self._jobs[job_id] + audio, recipe, seconds = job["_audio"], job["_recipe"], job["_seconds"] + self._set(job_id, status="running") + self.db.update(job_id, status="running") + + def progress(stage, done, total): + self._set(job_id, stage=stage, done=done, total=total) + + try: + res = run_pipeline(audio, recipe, runs_root=self.runs_root, + seconds=seconds, progress=progress) + self._set(job_id, status="done", run_id=res.run_id, stage="post", + done=1, total=1) + self.db.update(job_id, status="done", run_id=res.run_id) + except Exception as e: # noqa + 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/tests/test_server.py b/kaika/tests/test_server.py new file mode 100644 index 0000000..56ae36e --- /dev/null +++ b/kaika/tests/test_server.py @@ -0,0 +1,108 @@ +"""Phase 7: FastAPI server, job queue, WebSocket progress.""" +from __future__ import annotations + +import time + +import pytest +from fastapi.testclient import TestClient + +from kaika.server.app import create_app +from conftest import synth_track + + +@pytest.fixture +def client(tmp_path): + app = create_app(runs_root=tmp_path / "runs", data_dir=tmp_path / "data") + with TestClient(app) as c: + yield c + + +def _upload(client, tmp_path): + wav = synth_track(tmp_path / "up.wav", duration=1.0) + with wav.open("rb") as f: + r = client.post("/api/upload", files={"file": ("up.wav", f, "audio/wav")}) + assert r.status_code == 200 + return r.json()["audio_id"] + + +SMALL_RECIPE = {"name": "t", "seed": 1, + "fluid": {"resolution": 40, "render_resolution": 48}, + "diffusion": {"backend": "local", "control": ["depth"]}, + "post": {"fps": 24}} + + +def _wait_done(client, job_id, timeout=90): + 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") + + +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 From bcef6443c5521f41cbca9c425f5dd4bb567e8643 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:26:10 +0000 Subject: [PATCH 09/31] Kaika phase 8: React+Vite+TS frontend (Studio/Render/Gallery), built & embedded; served by server --- kaika/.gitignore | 1 + .../webapp_dist/assets/index-BSnvwGl-.js | 40 + .../webapp_dist/assets/index-UeejODpn.css | 1 + kaika/src/kaika/webapp_dist/index.html | 13 + kaika/tests/test_frontend.py | 27 + kaika/webapp/index.html | 12 + kaika/webapp/package-lock.json | 1732 +++++++++++++++++ kaika/webapp/package.json | 22 + kaika/webapp/src/App.tsx | 45 + kaika/webapp/src/api.ts | 67 + kaika/webapp/src/components/Gallery.tsx | 43 + kaika/webapp/src/components/RenderView.tsx | 90 + kaika/webapp/src/components/Studio.tsx | 213 ++ kaika/webapp/src/components/Waveform.tsx | 96 + kaika/webapp/src/main.tsx | 10 + kaika/webapp/src/styles.css | 105 + kaika/webapp/tsconfig.json | 20 + kaika/webapp/vite.config.ts | 19 + 18 files changed, 2556 insertions(+) create mode 100644 kaika/src/kaika/webapp_dist/assets/index-BSnvwGl-.js create mode 100644 kaika/src/kaika/webapp_dist/assets/index-UeejODpn.css create mode 100644 kaika/src/kaika/webapp_dist/index.html create mode 100644 kaika/tests/test_frontend.py create mode 100644 kaika/webapp/index.html create mode 100644 kaika/webapp/package-lock.json create mode 100644 kaika/webapp/package.json create mode 100644 kaika/webapp/src/App.tsx create mode 100644 kaika/webapp/src/api.ts create mode 100644 kaika/webapp/src/components/Gallery.tsx create mode 100644 kaika/webapp/src/components/RenderView.tsx create mode 100644 kaika/webapp/src/components/Studio.tsx create mode 100644 kaika/webapp/src/components/Waveform.tsx create mode 100644 kaika/webapp/src/main.tsx create mode 100644 kaika/webapp/src/styles.css create mode 100644 kaika/webapp/tsconfig.json create mode 100644 kaika/webapp/vite.config.ts diff --git a/kaika/.gitignore b/kaika/.gitignore index 00cfee1..c9c74fe 100644 --- a/kaika/.gitignore +++ b/kaika/.gitignore @@ -7,3 +7,4 @@ runs/ node_modules/ webapp/dist/ .DS_Store +*.tsbuildinfo diff --git a/kaika/src/kaika/webapp_dist/assets/index-BSnvwGl-.js b/kaika/src/kaika/webapp_dist/assets/index-BSnvwGl-.js new file mode 100644 index 0000000..7c3e6ee --- /dev/null +++ b/kaika/src/kaika/webapp_dist/assets/index-BSnvwGl-.js @@ -0,0 +1,40 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const u of l)if(u.type==="childList")for(const i of u.addedNodes)i.tagName==="LINK"&&i.rel==="modulepreload"&&r(i)}).observe(document,{childList:!0,subtree:!0});function n(l){const u={};return l.integrity&&(u.integrity=l.integrity),l.referrerPolicy&&(u.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?u.credentials="include":l.crossOrigin==="anonymous"?u.credentials="omit":u.credentials="same-origin",u}function r(l){if(l.ep)return;l.ep=!0;const u=n(l);fetch(l.href,u)}})();function uc(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Ko={exports:{}},rl={},Yo={exports:{}},R={};/** + * @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 Jn=Symbol.for("react.element"),ic=Symbol.for("react.portal"),oc=Symbol.for("react.fragment"),sc=Symbol.for("react.strict_mode"),ac=Symbol.for("react.profiler"),cc=Symbol.for("react.provider"),fc=Symbol.for("react.context"),dc=Symbol.for("react.forward_ref"),pc=Symbol.for("react.suspense"),mc=Symbol.for("react.memo"),hc=Symbol.for("react.lazy"),Di=Symbol.iterator;function vc(e){return e===null||typeof e!="object"?null:(e=Di&&e[Di]||e["@@iterator"],typeof e=="function"?e:null)}var Xo={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Go=Object.assign,Zo={};function cn(e,t,n){this.props=e,this.context=t,this.refs=Zo,this.updater=n||Xo}cn.prototype.isReactComponent={};cn.prototype.setState=function(e,t){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,t,"setState")};cn.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function Jo(){}Jo.prototype=cn.prototype;function Vu(e,t,n){this.props=e,this.context=t,this.refs=Zo,this.updater=n||Xo}var Bu=Vu.prototype=new Jo;Bu.constructor=Vu;Go(Bu,cn.prototype);Bu.isPureReactComponent=!0;var Ii=Array.isArray,qo=Object.prototype.hasOwnProperty,Hu={current:null},bo={key:!0,ref:!0,__self:!0,__source:!0};function es(e,t,n){var r,l={},u=null,i=null;if(t!=null)for(r in t.ref!==void 0&&(i=t.ref),t.key!==void 0&&(u=""+t.key),t)qo.call(t,r)&&!bo.hasOwnProperty(r)&&(l[r]=t[r]);var o=arguments.length-2;if(o===1)l.children=n;else if(1>>1,J=_[K];if(0>>1;Kl(kl,j))Stl(rr,kl)?(_[K]=rr,_[St]=j,K=St):(_[K]=kl,_[wt]=j,K=wt);else if(Stl(rr,j))_[K]=rr,_[St]=j,K=St;else break e}}return L}function l(_,L){var j=_.sortIndex-L.sortIndex;return j!==0?j:_.id-L.id}if(typeof performance=="object"&&typeof performance.now=="function"){var u=performance;e.unstable_now=function(){return u.now()}}else{var i=Date,o=i.now();e.unstable_now=function(){return i.now()-o}}var s=[],c=[],h=1,p=null,m=3,v=!1,k=!1,S=!1,O=typeof setTimeout=="function"?setTimeout:null,f=typeof clearTimeout=="function"?clearTimeout:null,a=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function d(_){for(var L=n(c);L!==null;){if(L.callback===null)r(c);else if(L.startTime<=_)r(c),L.sortIndex=L.expirationTime,t(s,L);else break;L=n(c)}}function y(_){if(S=!1,d(_),!k)if(n(s)!==null)k=!0,wl(x);else{var L=n(c);L!==null&&Sl(y,L.startTime-_)}}function x(_,L){k=!1,S&&(S=!1,f(z),z=-1),v=!0;var j=m;try{for(d(L),p=n(s);p!==null&&(!(p.expirationTime>L)||_&&!F());){var K=p.callback;if(typeof K=="function"){p.callback=null,m=p.priorityLevel;var J=K(p.expirationTime<=L);L=e.unstable_now(),typeof J=="function"?p.callback=J:p===n(s)&&r(s),d(L)}else r(s);p=n(s)}if(p!==null)var nr=!0;else{var wt=n(c);wt!==null&&Sl(y,wt.startTime-L),nr=!1}return nr}finally{p=null,m=j,v=!1}}var N=!1,P=null,z=-1,E=5,T=-1;function F(){return!(e.unstable_now()-T_||125<_?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):E=0<_?Math.floor(1e3/_):5},e.unstable_getCurrentPriorityLevel=function(){return m},e.unstable_getFirstCallbackNode=function(){return n(s)},e.unstable_next=function(_){switch(m){case 1:case 2:case 3:var L=3;break;default:L=m}var j=m;m=L;try{return _()}finally{m=j}},e.unstable_pauseExecution=function(){},e.unstable_requestPaint=function(){},e.unstable_runWithPriority=function(_,L){switch(_){case 1:case 2:case 3:case 4:case 5:break;default:_=3}var j=m;m=_;try{return L()}finally{m=j}},e.unstable_scheduleCallback=function(_,L,j){var K=e.unstable_now();switch(typeof j=="object"&&j!==null?(j=j.delay,j=typeof j=="number"&&0K?(_.sortIndex=j,t(c,_),n(s)===null&&_===n(c)&&(S?(f(z),z=-1):S=!0,Sl(y,j-K))):(_.sortIndex=J,t(s,_),k||v||(k=!0,wl(x))),_},e.unstable_shouldYield=F,e.unstable_wrapCallback=function(_){var L=m;return function(){var j=m;m=L;try{return _.apply(this,arguments)}finally{m=j}}}})(us);ls.exports=us;var zc=ls.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 Tc=$,we=zc;function g(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Gl=Object.prototype.hasOwnProperty,Lc=/^[: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]*$/,Ui={},$i={};function jc(e){return Gl.call($i,e)?!0:Gl.call(Ui,e)?!1:Lc.test(e)?$i[e]=!0:(Ui[e]=!0,!1)}function Rc(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Oc(e,t,n,r){if(t===null||typeof t>"u"||Rc(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function ce(e,t,n,r,l,u,i){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=u,this.removeEmptyString=i}var ne={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){ne[e]=new ce(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];ne[t]=new ce(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){ne[e]=new ce(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){ne[e]=new ce(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){ne[e]=new ce(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){ne[e]=new ce(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){ne[e]=new ce(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){ne[e]=new ce(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){ne[e]=new ce(e,5,!1,e.toLowerCase(),null,!1,!1)});var Qu=/[\-:]([a-z])/g;function Ku(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 t=e.replace(Qu,Ku);ne[t]=new ce(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Qu,Ku);ne[t]=new ce(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Qu,Ku);ne[t]=new ce(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){ne[e]=new ce(e,1,!1,e.toLowerCase(),null,!1,!1)});ne.xlinkHref=new ce("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){ne[e]=new ce(e,1,!1,e.toLowerCase(),null,!0,!0)});function Yu(e,t,n,r){var l=ne.hasOwnProperty(t)?ne[t]:null;(l!==null?l.type!==0:r||!(2o||l[i]!==u[o]){var s=` +`+l[i].replace(" at new "," at ");return e.displayName&&s.includes("")&&(s=s.replace("",e.displayName)),s}while(1<=i&&0<=o);break}}}finally{Cl=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?kn(e):""}function Mc(e){switch(e.tag){case 5:return kn(e.type);case 16:return kn("Lazy");case 13:return kn("Suspense");case 19:return kn("SuspenseList");case 0:case 2:case 15:return e=_l(e.type,!1),e;case 11:return e=_l(e.type.render,!1),e;case 1:return e=_l(e.type,!0),e;default:return""}}function bl(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 $t:return"Fragment";case Ut:return"Portal";case Zl:return"Profiler";case Xu:return"StrictMode";case Jl:return"Suspense";case ql:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case ss:return(e.displayName||"Context")+".Consumer";case os:return(e._context.displayName||"Context")+".Provider";case Gu:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Zu:return t=e.displayName||null,t!==null?t:bl(e.type)||"Memo";case et:t=e._payload,e=e._init;try{return bl(e(t))}catch{}}return null}function Dc(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return bl(t);case 8:return t===Xu?"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 t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function mt(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function cs(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Ic(e){var t=cs(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,u=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(i){r=""+i,u.call(this,i)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(i){r=""+i},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function ir(e){e._valueTracker||(e._valueTracker=Ic(e))}function fs(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=cs(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function Or(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 eu(e,t){var n=t.checked;return W({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Vi(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=mt(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function ds(e,t){t=t.checked,t!=null&&Yu(e,"checked",t,!1)}function tu(e,t){ds(e,t);var n=mt(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?nu(e,t.type,n):t.hasOwnProperty("defaultValue")&&nu(e,t.type,mt(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function Bi(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function nu(e,t,n){(t!=="number"||Or(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var xn=Array.isArray;function Zt(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=or.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Dn(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var _n={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},Fc=["Webkit","ms","Moz","O"];Object.keys(_n).forEach(function(e){Fc.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),_n[t]=_n[e]})});function vs(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||_n.hasOwnProperty(e)&&_n[e]?(""+t).trim():t+"px"}function ys(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=vs(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var Uc=W({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 uu(e,t){if(t){if(Uc[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(g(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(g(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(g(61))}if(t.style!=null&&typeof t.style!="object")throw Error(g(62))}}function iu(e,t){if(e.indexOf("-")===-1)return typeof t.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 ou=null;function Ju(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var su=null,Jt=null,qt=null;function Qi(e){if(e=er(e)){if(typeof su!="function")throw Error(g(280));var t=e.stateNode;t&&(t=sl(t),su(e.stateNode,e.type,t))}}function gs(e){Jt?qt?qt.push(e):qt=[e]:Jt=e}function ws(){if(Jt){var e=Jt,t=qt;if(qt=Jt=null,Qi(e),t)for(e=0;e>>=0,e===0?32:31-(Gc(e)/Zc|0)|0}var sr=64,ar=4194304;function En(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 Fr(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,u=e.pingedLanes,i=n&268435455;if(i!==0){var o=i&~l;o!==0?r=En(o):(u&=i,u!==0&&(r=En(u)))}else i=n&~l,i!==0?r=En(i):u!==0&&(r=En(u));if(r===0)return 0;if(t!==0&&t!==r&&!(t&l)&&(l=r&-r,u=t&-t,l>=u||l===16&&(u&4194240)!==0))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function qn(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Re(t),e[t]=n}function ef(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Pn),eo=" ",to=!1;function $s(e,t){switch(e){case"keyup":return Tf.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function As(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var At=!1;function jf(e,t){switch(e){case"compositionend":return As(t);case"keypress":return t.which!==32?null:(to=!0,eo);case"textInput":return e=t.data,e===eo&&to?null:e;default:return null}}function Rf(e,t){if(At)return e==="compositionend"||!ui&&$s(e,t)?(e=Fs(),Cr=ni=lt=null,At=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=uo(n)}}function Ws(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Ws(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Qs(){for(var e=window,t=Or();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Or(e.document)}return t}function ii(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Vf(e){var t=Qs(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Ws(n.ownerDocument.documentElement,n)){if(r!==null&&ii(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,u=Math.min(r.start,l);r=r.end===void 0?u:Math.min(r.end,l),!e.extend&&u>r&&(l=r,r=u,u=l),l=io(n,u);var i=io(n,r);l&&i&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==i.node||e.focusOffset!==i.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),u>r?(e.addRange(t),e.extend(i.node,i.offset)):(t.setEnd(i.node,i.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,Vt=null,mu=null,Tn=null,hu=!1;function oo(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;hu||Vt==null||Vt!==Or(r)||(r=Vt,"selectionStart"in r&&ii(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}),Tn&&Vn(Tn,r)||(Tn=r,r=Ar(mu,"onSelect"),0Wt||(e.current=ku[Wt],ku[Wt]=null,Wt--)}function I(e,t){Wt++,ku[Wt]=e.current,e.current=t}var ht={},ie=yt(ht),pe=yt(!1),Tt=ht;function rn(e,t){var n=e.type.contextTypes;if(!n)return ht;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},u;for(u in n)l[u]=t[u];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function me(e){return e=e.childContextTypes,e!=null}function Br(){A(pe),A(ie)}function ho(e,t,n){if(ie.current!==ht)throw Error(g(168));I(ie,t),I(pe,n)}function ea(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(g(108,Dc(e)||"Unknown",l));return W({},n,r)}function Hr(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||ht,Tt=ie.current,I(ie,e),I(pe,pe.current),!0}function vo(e,t,n){var r=e.stateNode;if(!r)throw Error(g(169));n?(e=ea(e,t,Tt),r.__reactInternalMemoizedMergedChildContext=e,A(pe),A(ie),I(ie,e)):A(pe),I(pe,n)}var He=null,al=!1,$l=!1;function ta(e){He===null?He=[e]:He.push(e)}function bf(e){al=!0,ta(e)}function gt(){if(!$l&&He!==null){$l=!0;var e=0,t=D;try{var n=He;for(D=1;e>=i,l-=i,We=1<<32-Re(t)+l|n<z?(E=P,P=null):E=P.sibling;var T=m(f,P,d[z],y);if(T===null){P===null&&(P=E);break}e&&P&&T.alternate===null&&t(f,P),a=u(T,a,z),N===null?x=T:N.sibling=T,N=T,P=E}if(z===d.length)return n(f,P),V&&xt(f,z),x;if(P===null){for(;zz?(E=P,P=null):E=P.sibling;var F=m(f,P,T.value,y);if(F===null){P===null&&(P=E);break}e&&P&&F.alternate===null&&t(f,P),a=u(F,a,z),N===null?x=F:N.sibling=F,N=F,P=E}if(T.done)return n(f,P),V&&xt(f,z),x;if(P===null){for(;!T.done;z++,T=d.next())T=p(f,T.value,y),T!==null&&(a=u(T,a,z),N===null?x=T:N.sibling=T,N=T);return V&&xt(f,z),x}for(P=r(f,P);!T.done;z++,T=d.next())T=v(P,f,z,T.value,y),T!==null&&(e&&T.alternate!==null&&P.delete(T.key===null?z:T.key),a=u(T,a,z),N===null?x=T:N.sibling=T,N=T);return e&&P.forEach(function(De){return t(f,De)}),V&&xt(f,z),x}function O(f,a,d,y){if(typeof d=="object"&&d!==null&&d.type===$t&&d.key===null&&(d=d.props.children),typeof d=="object"&&d!==null){switch(d.$$typeof){case ur:e:{for(var x=d.key,N=a;N!==null;){if(N.key===x){if(x=d.type,x===$t){if(N.tag===7){n(f,N.sibling),a=l(N,d.props.children),a.return=f,f=a;break e}}else if(N.elementType===x||typeof x=="object"&&x!==null&&x.$$typeof===et&&wo(x)===N.type){n(f,N.sibling),a=l(N,d.props),a.ref=gn(f,N,d),a.return=f,f=a;break e}n(f,N);break}else t(f,N);N=N.sibling}d.type===$t?(a=zt(d.props.children,f.mode,y,d.key),a.return=f,f=a):(y=Rr(d.type,d.key,d.props,null,f.mode,y),y.ref=gn(f,a,d),y.return=f,f=y)}return i(f);case Ut:e:{for(N=d.key;a!==null;){if(a.key===N)if(a.tag===4&&a.stateNode.containerInfo===d.containerInfo&&a.stateNode.implementation===d.implementation){n(f,a.sibling),a=l(a,d.children||[]),a.return=f,f=a;break e}else{n(f,a);break}else t(f,a);a=a.sibling}a=Yl(d,f.mode,y),a.return=f,f=a}return i(f);case et:return N=d._init,O(f,a,N(d._payload),y)}if(xn(d))return k(f,a,d,y);if(pn(d))return S(f,a,d,y);vr(f,d)}return typeof d=="string"&&d!==""||typeof d=="number"?(d=""+d,a!==null&&a.tag===6?(n(f,a.sibling),a=l(a,d),a.return=f,f=a):(n(f,a),a=Kl(d,f.mode,y),a.return=f,f=a),i(f)):n(f,a)}return O}var un=ua(!0),ia=ua(!1),Kr=yt(null),Yr=null,Yt=null,ci=null;function fi(){ci=Yt=Yr=null}function di(e){var t=Kr.current;A(Kr),e._currentValue=t}function Cu(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function en(e,t){Yr=e,ci=Yt=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(de=!0),e.firstContext=null)}function Ne(e){var t=e._currentValue;if(ci!==e)if(e={context:e,memoizedValue:t,next:null},Yt===null){if(Yr===null)throw Error(g(308));Yt=e,Yr.dependencies={lanes:0,firstContext:e}}else Yt=Yt.next=e;return t}var _t=null;function pi(e){_t===null?_t=[e]:_t.push(e)}function oa(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,pi(t)):(n.next=l.next,l.next=n),t.interleaved=n,Ze(e,r)}function Ze(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var tt=!1;function mi(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function sa(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Ye(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function ct(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,M&2){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,Ze(e,n)}return l=r.interleaved,l===null?(t.next=t,pi(r)):(t.next=l.next,l.next=t),r.interleaved=t,Ze(e,n)}function Nr(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,bu(e,n)}}function So(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,u=null;if(n=n.firstBaseUpdate,n!==null){do{var i={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};u===null?l=u=i:u=u.next=i,n=n.next}while(n!==null);u===null?l=u=t:u=u.next=t}else l=u=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:u,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function Xr(e,t,n,r){var l=e.updateQueue;tt=!1;var u=l.firstBaseUpdate,i=l.lastBaseUpdate,o=l.shared.pending;if(o!==null){l.shared.pending=null;var s=o,c=s.next;s.next=null,i===null?u=c:i.next=c,i=s;var h=e.alternate;h!==null&&(h=h.updateQueue,o=h.lastBaseUpdate,o!==i&&(o===null?h.firstBaseUpdate=c:o.next=c,h.lastBaseUpdate=s))}if(u!==null){var p=l.baseState;i=0,h=c=s=null,o=u;do{var m=o.lane,v=o.eventTime;if((r&m)===m){h!==null&&(h=h.next={eventTime:v,lane:0,tag:o.tag,payload:o.payload,callback:o.callback,next:null});e:{var k=e,S=o;switch(m=t,v=n,S.tag){case 1:if(k=S.payload,typeof k=="function"){p=k.call(v,p,m);break e}p=k;break e;case 3:k.flags=k.flags&-65537|128;case 0:if(k=S.payload,m=typeof k=="function"?k.call(v,p,m):k,m==null)break e;p=W({},p,m);break e;case 2:tt=!0}}o.callback!==null&&o.lane!==0&&(e.flags|=64,m=l.effects,m===null?l.effects=[o]:m.push(o))}else v={eventTime:v,lane:m,tag:o.tag,payload:o.payload,callback:o.callback,next:null},h===null?(c=h=v,s=p):h=h.next=v,i|=m;if(o=o.next,o===null){if(o=l.shared.pending,o===null)break;m=o,o=m.next,m.next=null,l.lastBaseUpdate=m,l.shared.pending=null}}while(!0);if(h===null&&(s=p),l.baseState=s,l.firstBaseUpdate=c,l.lastBaseUpdate=h,t=l.shared.interleaved,t!==null){l=t;do i|=l.lane,l=l.next;while(l!==t)}else u===null&&(l.shared.lanes=0);Rt|=i,e.lanes=i,e.memoizedState=p}}function ko(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=Vl.transition;Vl.transition={};try{e(!1),t()}finally{D=n,Vl.transition=r}}function _a(){return Pe().memoizedState}function rd(e,t,n){var r=dt(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},Na(e))Pa(t,n);else if(n=oa(e,t,n,r),n!==null){var l=se();Oe(n,e,r,l),za(n,t,r)}}function ld(e,t,n){var r=dt(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(Na(e))Pa(t,l);else{var u=e.alternate;if(e.lanes===0&&(u===null||u.lanes===0)&&(u=t.lastRenderedReducer,u!==null))try{var i=t.lastRenderedState,o=u(i,n);if(l.hasEagerState=!0,l.eagerState=o,Me(o,i)){var s=t.interleaved;s===null?(l.next=l,pi(t)):(l.next=s.next,s.next=l),t.interleaved=l;return}}catch{}finally{}n=oa(e,t,l,r),n!==null&&(l=se(),Oe(n,e,r,l),za(n,t,r))}}function Na(e){var t=e.alternate;return e===H||t!==null&&t===H}function Pa(e,t){Ln=Zr=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function za(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,bu(e,n)}}var Jr={readContext:Ne,useCallback:re,useContext:re,useEffect:re,useImperativeHandle:re,useInsertionEffect:re,useLayoutEffect:re,useMemo:re,useReducer:re,useRef:re,useState:re,useDebugValue:re,useDeferredValue:re,useTransition:re,useMutableSource:re,useSyncExternalStore:re,useId:re,unstable_isNewReconciler:!1},ud={readContext:Ne,useCallback:function(e,t){return Fe().memoizedState=[e,t===void 0?null:t],e},useContext:Ne,useEffect:Eo,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,zr(4194308,4,Sa.bind(null,t,e),n)},useLayoutEffect:function(e,t){return zr(4194308,4,e,t)},useInsertionEffect:function(e,t){return zr(4,2,e,t)},useMemo:function(e,t){var n=Fe();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Fe();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=rd.bind(null,H,e),[r.memoizedState,e]},useRef:function(e){var t=Fe();return e={current:e},t.memoizedState=e},useState:xo,useDebugValue:xi,useDeferredValue:function(e){return Fe().memoizedState=e},useTransition:function(){var e=xo(!1),t=e[0];return e=nd.bind(null,e[1]),Fe().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=H,l=Fe();if(V){if(n===void 0)throw Error(g(407));n=n()}else{if(n=t(),b===null)throw Error(g(349));jt&30||da(r,t,n)}l.memoizedState=n;var u={value:n,getSnapshot:t};return l.queue=u,Eo(ma.bind(null,r,u,e),[e]),r.flags|=2048,Gn(9,pa.bind(null,r,u,n,t),void 0,null),n},useId:function(){var e=Fe(),t=b.identifierPrefix;if(V){var n=Qe,r=We;n=(r&~(1<<32-Re(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=Yn++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=i.createElement(n,{is:r.is}):(e=i.createElement(n),n==="select"&&(i=e,r.multiple?i.multiple=!0:r.size&&(i.size=r.size))):e=i.createElementNS(e,n),e[Ue]=t,e[Wn]=r,Ua(e,t,!1,!1),t.stateNode=e;e:{switch(i=iu(n,r),n){case"dialog":U("cancel",e),U("close",e),l=r;break;case"iframe":case"object":case"embed":U("load",e),l=r;break;case"video":case"audio":for(l=0;lan&&(t.flags|=128,r=!0,wn(u,!1),t.lanes=4194304)}else{if(!r)if(e=Gr(i),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),wn(u,!0),u.tail===null&&u.tailMode==="hidden"&&!i.alternate&&!V)return le(t),null}else 2*Y()-u.renderingStartTime>an&&n!==1073741824&&(t.flags|=128,r=!0,wn(u,!1),t.lanes=4194304);u.isBackwards?(i.sibling=t.child,t.child=i):(n=u.last,n!==null?n.sibling=i:t.child=i,u.last=i)}return u.tail!==null?(t=u.tail,u.rendering=t,u.tail=t.sibling,u.renderingStartTime=Y(),t.sibling=null,n=B.current,I(B,r?n&1|2:n&1),t):(le(t),null);case 22:case 23:return zi(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?ve&1073741824&&(le(t),t.subtreeFlags&6&&(t.flags|=8192)):le(t),null;case 24:return null;case 25:return null}throw Error(g(156,t.tag))}function pd(e,t){switch(si(t),t.tag){case 1:return me(t.type)&&Br(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return on(),A(pe),A(ie),yi(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return vi(t),null;case 13:if(A(B),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(g(340));ln()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return A(B),null;case 4:return on(),null;case 10:return di(t.type._context),null;case 22:case 23:return zi(),null;case 24:return null;default:return null}}var gr=!1,ue=!1,md=typeof WeakSet=="function"?WeakSet:Set,C=null;function Xt(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){Q(e,t,r)}else n.current=null}function Ou(e,t,n){try{n()}catch(r){Q(e,t,r)}}var Mo=!1;function hd(e,t){if(vu=Ur,e=Qs(),ii(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,u=r.focusNode;r=r.focusOffset;try{n.nodeType,u.nodeType}catch{n=null;break e}var i=0,o=-1,s=-1,c=0,h=0,p=e,m=null;t:for(;;){for(var v;p!==n||l!==0&&p.nodeType!==3||(o=i+l),p!==u||r!==0&&p.nodeType!==3||(s=i+r),p.nodeType===3&&(i+=p.nodeValue.length),(v=p.firstChild)!==null;)m=p,p=v;for(;;){if(p===e)break t;if(m===n&&++c===l&&(o=i),m===u&&++h===r&&(s=i),(v=p.nextSibling)!==null)break;p=m,m=p.parentNode}p=v}n=o===-1||s===-1?null:{start:o,end:s}}else n=null}n=n||{start:0,end:0}}else n=null;for(yu={focusedElem:e,selectionRange:n},Ur=!1,C=t;C!==null;)if(t=C,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,C=e;else for(;C!==null;){t=C;try{var k=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(k!==null){var S=k.memoizedProps,O=k.memoizedState,f=t.stateNode,a=f.getSnapshotBeforeUpdate(t.elementType===t.type?S:Te(t.type,S),O);f.__reactInternalSnapshotBeforeUpdate=a}break;case 3:var d=t.stateNode.containerInfo;d.nodeType===1?d.textContent="":d.nodeType===9&&d.documentElement&&d.removeChild(d.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(g(163))}}catch(y){Q(t,t.return,y)}if(e=t.sibling,e!==null){e.return=t.return,C=e;break}C=t.return}return k=Mo,Mo=!1,k}function jn(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var u=l.destroy;l.destroy=void 0,u!==void 0&&Ou(t,n,u)}l=l.next}while(l!==r)}}function dl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function Mu(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function Va(e){var t=e.alternate;t!==null&&(e.alternate=null,Va(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Ue],delete t[Wn],delete t[Su],delete t[Jf],delete t[qf])),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 Ba(e){return e.tag===5||e.tag===3||e.tag===4}function Do(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||Ba(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 Du(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Vr));else if(r!==4&&(e=e.child,e!==null))for(Du(e,t,n),e=e.sibling;e!==null;)Du(e,t,n),e=e.sibling}function Iu(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Iu(e,t,n),e=e.sibling;e!==null;)Iu(e,t,n),e=e.sibling}var ee=null,Le=!1;function be(e,t,n){for(n=n.child;n!==null;)Ha(e,t,n),n=n.sibling}function Ha(e,t,n){if($e&&typeof $e.onCommitFiberUnmount=="function")try{$e.onCommitFiberUnmount(ll,n)}catch{}switch(n.tag){case 5:ue||Xt(n,t);case 6:var r=ee,l=Le;ee=null,be(e,t,n),ee=r,Le=l,ee!==null&&(Le?(e=ee,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):ee.removeChild(n.stateNode));break;case 18:ee!==null&&(Le?(e=ee,n=n.stateNode,e.nodeType===8?Ul(e.parentNode,n):e.nodeType===1&&Ul(e,n),$n(e)):Ul(ee,n.stateNode));break;case 4:r=ee,l=Le,ee=n.stateNode.containerInfo,Le=!0,be(e,t,n),ee=r,Le=l;break;case 0:case 11:case 14:case 15:if(!ue&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var u=l,i=u.destroy;u=u.tag,i!==void 0&&(u&2||u&4)&&Ou(n,t,i),l=l.next}while(l!==r)}be(e,t,n);break;case 1:if(!ue&&(Xt(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(o){Q(n,t,o)}be(e,t,n);break;case 21:be(e,t,n);break;case 22:n.mode&1?(ue=(r=ue)||n.memoizedState!==null,be(e,t,n),ue=r):be(e,t,n);break;default:be(e,t,n)}}function Io(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new md),t.forEach(function(r){var l=Cd.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function ze(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=i),r&=~u}if(r=l,r=Y()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*yd(r/1960))-r,10e?16:e,ut===null)var r=!1;else{if(e=ut,ut=null,el=0,M&6)throw Error(g(331));var l=M;for(M|=4,C=e.current;C!==null;){var u=C,i=u.child;if(C.flags&16){var o=u.deletions;if(o!==null){for(var s=0;sY()-Ni?Pt(e,0):_i|=n),he(e,t)}function Ja(e,t){t===0&&(e.mode&1?(t=ar,ar<<=1,!(ar&130023424)&&(ar=4194304)):t=1);var n=se();e=Ze(e,t),e!==null&&(qn(e,t,n),he(e,n))}function Ed(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Ja(e,n)}function Cd(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(g(314))}r!==null&&r.delete(t),Ja(e,n)}var qa;qa=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||pe.current)de=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return de=!1,fd(e,t,n);de=!!(e.flags&131072)}else de=!1,V&&t.flags&1048576&&na(t,Qr,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;Tr(e,t),e=t.pendingProps;var l=rn(t,ie.current);en(t,n),l=wi(null,t,r,e,l,n);var u=Si();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,me(r)?(u=!0,Hr(t)):u=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,mi(t),l.updater=fl,t.stateNode=l,l._reactInternals=t,Nu(t,r,e,n),t=Tu(null,t,r,!0,u,n)):(t.tag=0,V&&u&&oi(t),oe(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(Tr(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=Nd(r),e=Te(r,e),l){case 0:t=zu(null,t,r,e,n);break e;case 1:t=jo(null,t,r,e,n);break e;case 11:t=To(null,t,r,e,n);break e;case 14:t=Lo(null,t,r,Te(r.type,e),n);break e}throw Error(g(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Te(r,l),zu(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Te(r,l),jo(e,t,r,l,n);case 3:e:{if(Da(t),e===null)throw Error(g(387));r=t.pendingProps,u=t.memoizedState,l=u.element,sa(e,t),Xr(t,r,null,n);var i=t.memoizedState;if(r=i.element,u.isDehydrated)if(u={element:r,isDehydrated:!1,cache:i.cache,pendingSuspenseBoundaries:i.pendingSuspenseBoundaries,transitions:i.transitions},t.updateQueue.baseState=u,t.memoizedState=u,t.flags&256){l=sn(Error(g(423)),t),t=Ro(e,t,r,n,l);break e}else if(r!==l){l=sn(Error(g(424)),t),t=Ro(e,t,r,n,l);break e}else for(ye=at(t.stateNode.containerInfo.firstChild),ge=t,V=!0,je=null,n=ia(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(ln(),r===l){t=Je(e,t,n);break e}oe(e,t,r,n)}t=t.child}return t;case 5:return aa(t),e===null&&Eu(t),r=t.type,l=t.pendingProps,u=e!==null?e.memoizedProps:null,i=l.children,gu(r,l)?i=null:u!==null&&gu(r,u)&&(t.flags|=32),Ma(e,t),oe(e,t,i,n),t.child;case 6:return e===null&&Eu(t),null;case 13:return Ia(e,t,n);case 4:return hi(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=un(t,null,r,n):oe(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Te(r,l),To(e,t,r,l,n);case 7:return oe(e,t,t.pendingProps,n),t.child;case 8:return oe(e,t,t.pendingProps.children,n),t.child;case 12:return oe(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,u=t.memoizedProps,i=l.value,I(Kr,r._currentValue),r._currentValue=i,u!==null)if(Me(u.value,i)){if(u.children===l.children&&!pe.current){t=Je(e,t,n);break e}}else for(u=t.child,u!==null&&(u.return=t);u!==null;){var o=u.dependencies;if(o!==null){i=u.child;for(var s=o.firstContext;s!==null;){if(s.context===r){if(u.tag===1){s=Ye(-1,n&-n),s.tag=2;var c=u.updateQueue;if(c!==null){c=c.shared;var h=c.pending;h===null?s.next=s:(s.next=h.next,h.next=s),c.pending=s}}u.lanes|=n,s=u.alternate,s!==null&&(s.lanes|=n),Cu(u.return,n,t),o.lanes|=n;break}s=s.next}}else if(u.tag===10)i=u.type===t.type?null:u.child;else if(u.tag===18){if(i=u.return,i===null)throw Error(g(341));i.lanes|=n,o=i.alternate,o!==null&&(o.lanes|=n),Cu(i,n,t),i=u.sibling}else i=u.child;if(i!==null)i.return=u;else for(i=u;i!==null;){if(i===t){i=null;break}if(u=i.sibling,u!==null){u.return=i.return,i=u;break}i=i.return}u=i}oe(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,en(t,n),l=Ne(l),r=r(l),t.flags|=1,oe(e,t,r,n),t.child;case 14:return r=t.type,l=Te(r,t.pendingProps),l=Te(r.type,l),Lo(e,t,r,l,n);case 15:return Ra(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Te(r,l),Tr(e,t),t.tag=1,me(r)?(e=!0,Hr(t)):e=!1,en(t,n),Ta(t,r,l),Nu(t,r,l,n),Tu(null,t,r,!0,e,n);case 19:return Fa(e,t,n);case 22:return Oa(e,t,n)}throw Error(g(156,t.tag))};function ba(e,t){return Ns(e,t)}function _d(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,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 Ce(e,t,n,r){return new _d(e,t,n,r)}function Li(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Nd(e){if(typeof e=="function")return Li(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Gu)return 11;if(e===Zu)return 14}return 2}function pt(e,t){var n=e.alternate;return n===null?(n=Ce(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Rr(e,t,n,r,l,u){var i=2;if(r=e,typeof e=="function")Li(e)&&(i=1);else if(typeof e=="string")i=5;else e:switch(e){case $t:return zt(n.children,l,u,t);case Xu:i=8,l|=8;break;case Zl:return e=Ce(12,n,t,l|2),e.elementType=Zl,e.lanes=u,e;case Jl:return e=Ce(13,n,t,l),e.elementType=Jl,e.lanes=u,e;case ql:return e=Ce(19,n,t,l),e.elementType=ql,e.lanes=u,e;case as:return ml(n,l,u,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case os:i=10;break e;case ss:i=9;break e;case Gu:i=11;break e;case Zu:i=14;break e;case et:i=16,r=null;break e}throw Error(g(130,e==null?e:typeof e,""))}return t=Ce(i,n,t,l),t.elementType=e,t.type=r,t.lanes=u,t}function zt(e,t,n,r){return e=Ce(7,e,r,t),e.lanes=n,e}function ml(e,t,n,r){return e=Ce(22,e,r,t),e.elementType=as,e.lanes=n,e.stateNode={isHidden:!1},e}function Kl(e,t,n){return e=Ce(6,e,null,t),e.lanes=n,e}function Yl(e,t,n){return t=Ce(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Pd(e,t,n,r,l){this.tag=t,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=Pl(0),this.expirationTimes=Pl(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Pl(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function ji(e,t,n,r,l,u,i,o,s){return e=new Pd(e,t,n,o,s),t===1?(t=1,u===!0&&(t|=8)):t=0,u=Ce(3,null,null,t),e.current=u,u.stateNode=e,u.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},mi(u),e}function zd(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(rc)}catch(e){console.error(e)}}rc(),rs.exports=Se;var Od=rs.exports,Wo=Od;Xl.createRoot=Wo.createRoot,Xl.hydrateRoot=Wo.hydrateRoot;async function kt(e){if(!e.ok)throw new Error(await e.text()||e.statusText);return e.json()}const Ke={recipes:()=>fetch("/api/recipes").then(kt),upload:e=>{const t=new FormData;return t.append("file",e),fetch("/api/upload",{method:"POST",body:t}).then(kt)},analyze:(e,t=24)=>fetch(`/api/analyze?audio_id=${e}&fps=${t}`,{method:"POST"}).then(kt),startRun:e=>fetch("/api/runs",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)}).then(kt),job:e=>fetch(`/api/jobs/${e}`).then(kt),runs:()=>fetch("/api/runs").then(kt),run:e=>fetch(`/api/runs/${e}`).then(kt),finalUrl:e=>`/api/runs/${e}/final`,fileUrl:(e,t)=>`/api/runs/${e}/files/${t}`,watchJob:(e,t)=>{const n=location.protocol==="https:"?"wss":"ws",r=new WebSocket(`${n}://${location.host}/ws/jobs/${e}`);return r.onmessage=l=>t(JSON.parse(l.data)),r}},Qo=["analyze","simulate","control","diffuse","post"],Md={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)"};function Dd({waveform:e,duration:t,sections:n,selection:r,onSelect:l}){const u=$.useRef(null),[i,o]=$.useState(null);$.useEffect(()=>{const c=u.current;if(!c)return;const h=window.devicePixelRatio||1,p=c.clientWidth,m=c.clientHeight;c.width=p*h,c.height=m*h;const v=c.getContext("2d");v.scale(h,h),v.clearRect(0,0,p,m);for(const S of n){const O=S.start/t*p,f=(S.end-S.start)/t*p;v.fillStyle=Md[S.label]||"rgba(140,149,161,0.10)",v.fillRect(O,0,f,m)}v.strokeStyle="#8fc7bc",v.lineWidth=1;const k=m/2;if(v.beginPath(),e.forEach((S,O)=>{const f=O/e.length*p;v.moveTo(f,k-S*k*.92),v.lineTo(f,k+S*k*.92)}),v.stroke(),r){const S=r[0]/t*p,O=(r[1]-r[0])/t*p;v.fillStyle="rgba(184,74,116,0.22)",v.fillRect(S,0,O,m),v.strokeStyle="#b84a74",v.lineWidth=1.5,v.strokeRect(S,0,O,m)}},[e,t,n,r]);const s=c=>{const h=u.current.getBoundingClientRect();return Math.max(0,Math.min(t,(c-h.left)/h.width*t))};return w.jsxs("div",{className:"wave-wrap",children:[w.jsx("canvas",{ref:u,className:"wave",onMouseDown:c=>o({x0:s(c.clientX)}),onMouseMove:c=>{if(!i)return;const h=s(c.clientX);l([Math.min(i.x0,h),Math.max(i.x0,h)])},onMouseUp:()=>o(null),onMouseLeave:()=>o(null)}),w.jsx("div",{className:"sec-row",children:n.map((c,h)=>w.jsxs("span",{className:`sec-chip ${c.label}`,children:[c.label," · ",c.start.toFixed(1),"–",c.end.toFixed(1),"s"]},h))}),w.jsx("p",{className:"muted",style:{marginTop:8},children:"Drag on the waveform to select an extract for a fast partial render."})]})}function Id({onStarted:e}){const[t,n]=$.useState([]),[r,l]=$.useState("eclosion"),[u,i]=$.useState(null),[o,s]=$.useState(null),[c,h]=$.useState(""),[p,m]=$.useState(null),[v,k]=$.useState(null),[S,O]=$.useState(!1),[f,a]=$.useState(!1),[d,y]=$.useState("");$.useEffect(()=>{Ke.recipes().then(E=>{n(E);const T=E.find(F=>F.name===r)||E[0];T&&(l(T.name),i(structuredClone(T.recipe)))})},[]);const x=E=>{const T=t.find(F=>F.name===E);T&&(l(E),i(structuredClone(T.recipe)))},N=async E=>{var T;y(""),O(!0);try{const{audio_id:F}=await Ke.upload(E);s(F),h(E.name);const De=await Ke.analyze(F,((T=u==null?void 0:u.post)==null?void 0:T.fps)??24);m(De),k(null)}catch(F){y(String(F.message||F))}finally{O(!1)}},P=async E=>{if(o){O(!0),y("");try{const T=E||!v?void 0:v[1]-v[0],{job_id:F}=await Ke.startRun({audio_id:o,recipe:u,seconds:T});e(F)}catch(T){y(String(T.message||T)),O(!1)}}},z=(E,T)=>{i(F=>{const De=structuredClone(F);let Ve=De;for(let It=0;It{E.preventDefault(),a(!0)},onDragLeave:()=>a(!1),onDrop:E=>{E.preventDefault(),a(!1);const T=E.dataTransfer.files[0];T&&N(T)},children:[w.jsx("p",{style:{fontSize:18,marginBottom:10},children:"Drop an audio file here"}),w.jsx("p",{className:"muted",children:"or"}),w.jsxs("label",{className:"btn ghost",style:{display:"inline-block",width:"auto",marginTop:12},children:["Choose file",w.jsx("input",{type:"file",accept:"audio/*",style:{display:"none"},onChange:E=>{var T;return((T=E.target.files)==null?void 0:T[0])&&N(E.target.files[0])}})]})]}),p&&w.jsxs("div",{className:"card",children:[w.jsx("h3",{children:c}),w.jsxs("p",{className:"muted mono",style:{marginBottom:12},children:[p.tempo_bpm," BPM · ",p.duration_s.toFixed(1),"s ·"," ",p.n_frames," frames · onsets"," ",Object.entries(p.onset_counts).map(([E,T])=>`${E}:${T}`).join(" ")]}),w.jsx(Dd,{waveform:p.waveform,duration:p.duration_s,sections:p.sections,selection:v,onSelect:k})]}),d&&w.jsx("p",{className:"err",children:d})]}),w.jsx("aside",{children:w.jsxs("div",{className:"card",children:[w.jsx("h3",{children:"Recipe"}),w.jsx("label",{className:"field",children:"Identity"}),w.jsx("select",{value:r,onChange:E=>x(E.target.value),children:t.map(E=>w.jsx("option",{value:E.name,children:E.name},E.name))}),u&&w.jsxs(w.Fragment,{children:[w.jsxs("label",{className:"field",children:["Denoise strength ",w.jsx("span",{className:"val",children:u.diffusion.strength})]}),w.jsx("input",{type:"range",min:.1,max:.9,step:.05,value:u.diffusion.strength,onChange:E=>z(["diffusion","strength"],parseFloat(E.target.value))}),w.jsxs("label",{className:"field",children:["Vorticity max ",w.jsx("span",{className:"val",children:u.fluid.vorticity.max})]}),w.jsx("input",{type:"range",min:5,max:80,step:1,value:u.fluid.vorticity.max,onChange:E=>z(["fluid","vorticity","max"],parseInt(E.target.value))}),w.jsxs("label",{className:"field",children:["Lookahead (s) ",w.jsx("span",{className:"val",children:u.fluid.lookahead_s})]}),w.jsx("input",{type:"range",min:0,max:16,step:.5,value:u.fluid.lookahead_s,onChange:E=>z(["fluid","lookahead_s"],parseFloat(E.target.value))}),w.jsx("label",{className:"field",children:"Seed"}),w.jsx("input",{type:"number",value:u.seed,onChange:E=>z(["seed"],parseInt(E.target.value)||0)}),w.jsx("label",{className:"field",children:"Prompt · base"}),w.jsx("textarea",{value:u.prompts.base,onChange:E=>z(["prompts","base"],E.target.value)}),w.jsx("label",{className:"field",children:"Prompt · drop"}),w.jsx("textarea",{value:u.prompts.drop||"",onChange:E=>z(["prompts","drop"],E.target.value)})]}),w.jsx("button",{className:"btn",disabled:!o||S,onClick:()=>P(!1),children:v?`Render extract (${(v[1]-v[0]).toFixed(1)}s)`:"Render extract"}),w.jsx("button",{className:"btn ghost",disabled:!o||S,onClick:()=>P(!0),children:"Render full clip"})]})})]})}function Fd({jobId:e,onSeeGallery:t}){const[n,r]=$.useState(null),[l,u]=$.useState(null);if($.useEffect(()=>{if(!e)return;u(null);const o=Ke.watchJob(e,r);return()=>o.close()},[e]),$.useEffect(()=>{(n==null?void 0:n.status)==="done"&&n.run_id&&Ke.run(n.run_id).then(u)},[n==null?void 0:n.status,n==null?void 0:n.run_id]),!e)return w.jsx("div",{className:"card",style:{marginTop:26},children:w.jsx("p",{className:"muted",children:"No active render. Start one from the Studio."})});const i=n!=null&&n.stage?Qo.indexOf(n.stage):-1;return w.jsxs("div",{className:"grid",children:[w.jsxs("div",{className:"card",children:[w.jsx("h3",{children:"Pipeline"}),w.jsx("div",{className:"pipeline",children:Qo.map((o,s)=>{const c=(n==null?void 0:n.stage)===o&&n.status==="running",h=(n==null?void 0:n.status)==="done"||i>s||i===s&&(n==null?void 0:n.done)===(n==null?void 0:n.total)&&(n==null?void 0:n.total)>0,p=h?100:c&&(n!=null&&n.total)?Math.round(n.done/n.total*100):0;return w.jsxs("div",{className:`stage-row ${h?"done":""}`,children:[w.jsx("span",{className:"stage-name",children:o}),w.jsx("div",{className:"bar",children:w.jsx("i",{style:{width:`${p}%`}})}),w.jsx("span",{className:"pct",children:h?"done":c?`${p}%`:"—"})]},o)})}),(n==null?void 0:n.status)==="error"&&w.jsxs("p",{className:"err",children:["Error: ",n.error]})]}),w.jsxs("aside",{className:"card",children:[w.jsx("h3",{children:"Result"}),l?w.jsxs(w.Fragment,{children:[w.jsx("video",{src:Ke.finalUrl(l.id),controls:!0,autoPlay:!0,loop:!0}),w.jsxs("div",{className:"run-meta",children:[w.jsx("span",{className:"mono",children:l.recipe}),w.jsxs("span",{className:"mono",children:[l.n_frames," frames"]})]}),l.sync&&w.jsxs("p",{className:"muted mono",style:{marginTop:6},children:["sync lag ",l.sync.lag_frames,"f · corr ",l.sync.correlation]}),w.jsx("button",{className:"btn ghost",onClick:t,children:"See in Gallery"})]}):w.jsx("p",{className:"muted",children:(n==null?void 0:n.status)==="error"?"Render failed.":"Rendering… previews appear here when ready."})]})]})}function Ud(){const[e,t]=$.useState([]);return $.useEffect(()=>{Ke.runs().then(t).catch(()=>t([]))},[]),e.length===0?w.jsx("div",{className:"card",style:{marginTop:26},children:w.jsx("p",{className:"muted",children:"No runs yet. Render something in the Studio."})}):w.jsx("div",{className:"runs",children:e.map(n=>w.jsxs("div",{className:"card run-card",children:[n.status==="done"?w.jsx("video",{src:Ke.finalUrl(n.id),controls:!0,loop:!0,preload:"metadata"}):w.jsxs("p",{className:"muted",children:["(",n.status,")"]}),w.jsxs("div",{className:"run-meta",children:[w.jsx("span",{className:"mono",children:n.recipe}),w.jsx("span",{className:`badge ${n.status}`,children:n.status})]}),w.jsxs("div",{className:"run-meta",children:[w.jsxs("span",{children:[n.n_frames??"—"," frames"]}),n.sync&&w.jsxs("span",{className:"mono",children:["corr ",n.sync.correlation]})]}),w.jsx("p",{className:"muted mono",style:{fontSize:11,marginTop:4},children:new Date(n.created*1e3).toLocaleString()})]},n.id))})}function $d(){const[e,t]=$.useState("studio"),[n,r]=$.useState(null),l=(u,i)=>w.jsx("button",{className:e===u?"active":"",onClick:()=>t(u),children:i});return w.jsxs("div",{className:"app",children:[w.jsxs("header",{className:"top",children:[w.jsxs("span",{className:"brand",children:["Kaika ",w.jsx("span",{className:"kanji",children:"開花"})]}),w.jsxs("nav",{className:"tabs",children:[l("studio","Studio"),l("render","Render"),l("gallery","Gallery")]})]}),e==="studio"&&w.jsx(Id,{onStarted:u=>{r(u),t("render")}}),e==="render"&&w.jsx(Fd,{jobId:n,onSeeGallery:()=>t("gallery")}),e==="gallery"&&w.jsx(Ud,{})]})}Xl.createRoot(document.getElementById("root")).render(w.jsx(kc.StrictMode,{children:w.jsx($d,{})})); diff --git a/kaika/src/kaika/webapp_dist/assets/index-UeejODpn.css b/kaika/src/kaika/webapp_dist/assets/index-UeejODpn.css new file mode 100644 index 0000000..e75475e --- /dev/null +++ b/kaika/src/kaika/webapp_dist/assets/index-UeejODpn.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}.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{background:#fef0c7;color:#92660a}.muted{color:var(--brume);font-size:13px}.err{color:var(--petale);font-size:13px;margin-top:8px} diff --git a/kaika/src/kaika/webapp_dist/index.html b/kaika/src/kaika/webapp_dist/index.html new file mode 100644 index 0000000..9898a9f --- /dev/null +++ b/kaika/src/kaika/webapp_dist/index.html @@ -0,0 +1,13 @@ + + + + + + Kaika 開花 + + + + +
+ + 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/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..3f3b3b8 --- /dev/null +++ b/kaika/webapp/package-lock.json @@ -0,0 +1,1732 @@ +{ + "name": "kaika-webapp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kaika-webapp", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@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/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/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/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..6d6c2f2 --- /dev/null +++ b/kaika/webapp/package.json @@ -0,0 +1,22 @@ +{ + "name": "kaika-webapp", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@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..8fbdd3c --- /dev/null +++ b/kaika/webapp/src/App.tsx @@ -0,0 +1,45 @@ +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 [jobId, setJobId] = useState(null); + + const tab = (v: View, label: string) => ( + + ); + + return ( +
+
+ + Kaika 開花 + + +
+ + {view === "studio" && ( + { + setJobId(id); + setView("render"); + }} + /> + )} + {view === "render" && ( + setView("gallery")} /> + )} + {view === "gallery" && } +
+ ); +} diff --git a/kaika/webapp/src/api.ts b/kaika/webapp/src/api.ts new file mode 100644 index 0000000..6e19c04 --- /dev/null +++ b/kaika/webapp/src/api.ts @@ -0,0 +1,67 @@ +// 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 Analysis { + audio_id: string; + tempo_bpm: number; + duration_s: number; + fps: number; + n_frames: number; + sections: Section[]; + beats: Beat[]; + onset_counts: Record; + waveform: number[]; +} +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; +} +export interface RunManifest { + id: string; created: number; recipe: string; fps: number; n_frames: number; + status: string; sync: { lag_frames: number; correlation: number } | null; + final?: 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; +} + +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 }>); + }, + + analyze: (audio_id: string, fps = 24) => + fetch(`/api/analyze?audio_id=${audio_id}&fps=${fps}`, { method: "POST" }) + .then(j), + + startRun: (body: { audio_id: string; recipe?: any; recipe_name?: string; seconds?: number }) => + fetch("/api/runs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }).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`, + 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..c7ec8fc --- /dev/null +++ b/kaika/webapp/src/components/Gallery.tsx @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; +import { api, RunManifest } from "../api"; + +export default function Gallery() { + const [runs, setRuns] = useState([]); + + useEffect(() => { + api.runs().then(setRuns).catch(() => setRuns([])); + }, []); + + if (runs.length === 0) { + return ( +
+

No runs yet. Render something in the Studio.

+
+ ); + } + + return ( +
+ {runs.map((r) => ( +
+ {r.status === "done" ? ( +
+ ))} +
+ ); +} diff --git a/kaika/webapp/src/components/RenderView.tsx b/kaika/webapp/src/components/RenderView.tsx new file mode 100644 index 0000000..16ded85 --- /dev/null +++ b/kaika/webapp/src/components/RenderView.tsx @@ -0,0 +1,90 @@ +import { useEffect, useState } from "react"; +import { api, JobState, RunManifest, STAGES } from "../api"; + +interface Props { + jobId: string | null; + onSeeGallery: () => void; +} + +export default function RenderView({ jobId, onSeeGallery }: Props) { + const [job, setJob] = useState(null); + const [run, setRun] = useState(null); + + useEffect(() => { + if (!jobId) return; + setRun(null); + const ws = api.watchJob(jobId, setJob); + return () => ws.close(); + }, [jobId]); + + useEffect(() => { + if (job?.status === "done" && job.run_id) { + api.run(job.run_id).then(setRun); + } + }, [job?.status, job?.run_id]); + + if (!jobId) { + return ( +
+

No active render. Start one from the Studio.

+
+ ); + } + + const stageIndex = job?.stage ? STAGES.indexOf(job.stage) : -1; + + return ( +
+
+

Pipeline

+
+ {STAGES.map((name, i) => { + const isCurrent = job?.stage === name && job.status === "running"; + const isDone = + job?.status === "done" || (stageIndex > i) || + (stageIndex === i && job?.done === job?.total && job?.total! > 0); + 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}

} +
+ + +
+ ); +} diff --git a/kaika/webapp/src/components/Studio.tsx b/kaika/webapp/src/components/Studio.tsx new file mode 100644 index 0000000..60de049 --- /dev/null +++ b/kaika/webapp/src/components/Studio.tsx @@ -0,0 +1,213 @@ +import { useEffect, useState } from "react"; +import { api, Analysis, RecipeEntry } from "../api"; +import Waveform from "./Waveform"; + +interface Props { + onStarted: (jobId: string) => void; +} + +export default function Studio({ onStarted }: Props) { + const [recipes, setRecipes] = useState([]); + const [recipeName, setRecipeName] = useState("eclosion"); + const [recipe, setRecipe] = useState(null); + const [audioId, setAudioId] = useState(null); + const [fileName, setFileName] = useState(""); + const [analysis, setAnalysis] = useState(null); + const [selection, setSelection] = useState<[number, number] | null>(null); + const [busy, setBusy] = useState(false); + const [hover, setHover] = useState(false); + const [err, setErr] = useState(""); + + useEffect(() => { + api.recipes().then((r) => { + setRecipes(r); + const e = r.find((x) => x.name === recipeName) || r[0]; + if (e) { + setRecipeName(e.name); + setRecipe(structuredClone(e.recipe)); + } + }); + }, []); + + const pickRecipe = (name: string) => { + const e = recipes.find((x) => x.name === name); + if (e) { + setRecipeName(name); + setRecipe(structuredClone(e.recipe)); + } + }; + + const upload = async (file: File) => { + setErr(""); + setBusy(true); + try { + const { audio_id } = await api.upload(file); + setAudioId(audio_id); + setFileName(file.name); + const a = await api.analyze(audio_id, recipe?.post?.fps ?? 24); + setAnalysis(a); + setSelection(null); + } catch (e: any) { + setErr(String(e.message || e)); + } finally { + setBusy(false); + } + }; + + const start = async (full: boolean) => { + if (!audioId) return; + setBusy(true); + setErr(""); + try { + const seconds = full || !selection ? undefined : selection[1] - selection[0]; + const { job_id } = await api.startRun({ audio_id: audioId, recipe, seconds }); + onStarted(job_id); + } catch (e: any) { + setErr(String(e.message || e)); + setBusy(false); + } + }; + + const setIn = (path: string[], value: any) => { + setRecipe((r: any) => { + const next = structuredClone(r); + let o = next; + for (let i = 0; i < path.length - 1; i++) o = o[path[i]]; + o[path[path.length - 1]] = value; + return next; + }); + }; + + return ( +
+
+ {!analysis && ( +
{ + 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

+

or

+ +
+ )} + + {analysis && ( +
+

{fileName}

+

+ {analysis.tempo_bpm} BPM · {analysis.duration_s.toFixed(1)}s ·{" "} + {analysis.n_frames} frames · onsets{" "} + {Object.entries(analysis.onset_counts).map(([k, v]) => `${k}:${v}`).join(" ")} +

+ +
+ )} + {err &&

{err}

} +
+ +