diff --git a/.github/workflows/music-plan-contract.yml b/.github/workflows/music-plan-contract.yml new file mode 100644 index 00000000..cf3ecd98 --- /dev/null +++ b/.github/workflows/music-plan-contract.yml @@ -0,0 +1,72 @@ +name: MusicPlan Contract + +on: + pull_request: + branches: [main] + paths: + - "schemas/music-plan-v1.schema.json" + - "src/music_plan.py" + - "src/music_provider.py" + - "scripts/music_plan_mutation_probe.py" + - "scripts/music_plan_holdout_evaluator.py" + - "tests/test_music_plan.py" + - "tests/fixtures/music_plan/**" + - "docs/music-planning-layer-v1.md" + - ".github/workflows/music-plan-contract.yml" + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install focused validation dependencies + run: python -m pip install 'pytest>=8,<10' 'jsonschema>=4.23,<5' + - name: Compile model-free contracts + run: python -m py_compile src/music_plan.py src/music_provider.py scripts/music_plan_mutation_probe.py scripts/music_plan_holdout_evaluator.py + - name: Run MusicPlan contract tests + run: python -m pytest -q tests/test_music_plan.py + - name: Run controlled negative mutation probe + run: python -m scripts.music_plan_mutation_probe + - name: Verify private holdout evaluator protocol + run: | + python - <<'PY' + import json + import subprocess + import sys + from pathlib import Path + + plan = json.loads(Path('tests/fixtures/music_plan/full.json').read_text(encoding='utf-8')) + request = {"id": "public-protocol-smoke", "input": {"plan": plan, "expected_valid": True}} + r = subprocess.run( + [sys.executable, '-m', 'scripts.music_plan_holdout_evaluator'], + input=json.dumps(request), text=True, capture_output=True, check=True, + ) + response = json.loads(r.stdout) + assert response == {"passed": True}, response + print('holdout evaluator protocol OK') + PY + - name: Verify Draft 2020-12 schema and reference fixtures + run: | + python - <<'PY' + import json + from pathlib import Path + from jsonschema import Draft202012Validator + + schema_path = Path('schemas/music-plan-v1.schema.json') + schema = json.loads(schema_path.read_text(encoding='utf-8')) + Draft202012Validator.check_schema(schema) + validator = Draft202012Validator(schema) + for path in sorted(Path('tests/fixtures/music_plan').glob('*.json')): + data = json.loads(path.read_text(encoding='utf-8')) + validator.validate(data) + print(f'OK {path}') + PY diff --git a/.github/workflows/repository-checkout-ci.yml b/.github/workflows/repository-checkout-ci.yml new file mode 100644 index 00000000..e8615624 --- /dev/null +++ b/.github/workflows/repository-checkout-ci.yml @@ -0,0 +1,19 @@ +name: Repository Checkout CI + +on: + pull_request: + paths: + - 'GemReward-Service-Repo' + - '.gitmodules' + - '.github/workflows/repository-checkout-ci.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + checkout: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 diff --git a/.github/workflows/semantic-prevalidator-ci.yml b/.github/workflows/semantic-prevalidator-ci.yml new file mode 100644 index 00000000..c1ca166e --- /dev/null +++ b/.github/workflows/semantic-prevalidator-ci.yml @@ -0,0 +1,36 @@ +name: Semantic Prevalidator CI + +on: + pull_request: + paths: + - 'src/video_validation/**' + - 'tests/test_semantic_prevalidator.py' + - '.github/workflows/semantic-prevalidator-ci.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + semantic-prevalidator: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Materialize exact PR commit without git submodule cleanup + env: + REPOSITORY: ${{ github.repository }} + SHA: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + curl --fail --location --silent --show-error \ + "https://codeload.github.com/${REPOSITORY}/tar.gz/${SHA}" \ + | tar -xz --strip-components=1 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install targeted test dependency + run: python -m pip install --disable-pip-version-check pytest + - name: Compile targeted module + run: python -m compileall -q src/video_validation tests/test_semantic_prevalidator.py + - name: Run semantic prevalidator tests + run: python -m pytest -q tests/test_semantic_prevalidator.py diff --git a/FUTURE_ROADMAP.md b/FUTURE_ROADMAP.md index 27fac6e1..cd7284cf 100644 --- a/FUTURE_ROADMAP.md +++ b/FUTURE_ROADMAP.md @@ -90,4 +90,32 @@ A dynamic-residency optimization graduates only if it improves at least one usef --- **Maintained by:** StoryCore-Engine Team -**Last Updated:** August 22, 2026 \ No newline at end of file +**Last Updated:** August 22, 2026 + +## 6. Scientific transfer: structural drawing and physical consistency (2026-09-10) + +**Planned.** This work follows existing resource admission and render stability +gates; it does not enable a new generation workflow. + +- [ ] Define a versioned storyboard input carrying strokes, contours, junctions, perspective constraints, coordinate frames, stable identities and provenance. +- [ ] Reuse Human Skills practice outputs and CogniARC structural critique; distinguish practiced trajectories from fitted reference curves. +- [ ] Compare unchanged, random-correction and targeted-correction storyboards on held-out compositions with equal action/render budgets. +- [ ] Measure junction/outline preservation, proportions, camera consistency and identity drift separately from visual style. +- [ ] Test bounded brush-style controls after structural quality passes, using [Procedural Brush Synthesis](https://users.cg.tuwien.ac.at/zsolnai/gfx/procedural-brush-synthesis-paper/) and [DiffVG](https://people.csail.mit.edu/tzumao/diffvg/) as individually sourced methods. +- [ ] Add offline physical-consistency fixtures for motion and contact only after numerical reference validation; compare persistent/constant-velocity predictions before introducing a learned predictor. + +**First implementation:** a tiny synthetic storyboard fixture and validator for +strokes, frames and identities, reusing existing project schemas where possible. +No external assets, model weights or generation services are required for that +contract experiment. + +**Acceptance:** reproducible improvement on reserved compositions, intact +identities and camera/geometry constraints, recorded failures and measured +resource use on stated hardware. Reference curve fitting alone is not evidence +of learned drawing skill; a physically plausible-looking clip is not a physics +validation. + +Physical prerequisite: +[CogniARC's initial particle-neighbour reference and limits](https://github.com/zedarvates/cogniarc/blob/a4aac4a5f42e546c4a4ad777c508e4a1b0f0f136/experiments/particle_graph/README.md). +That implementation is separate from StoryCore and is not a calibrated water +simulator or a generation integration. diff --git a/GemReward-Service-Repo b/GemReward-Service-Repo deleted file mode 160000 index 9219f19f..00000000 --- a/GemReward-Service-Repo +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 9219f19f353651e10b12fdcb00d6688bddf79b7f diff --git a/addons/official/storycore_asset_creator/COMFYUI_CLIENT.md b/addons/official/storycore_asset_creator/COMFYUI_CLIENT.md new file mode 100644 index 00000000..805868cd --- /dev/null +++ b/addons/official/storycore_asset_creator/COMFYUI_CLIENT.md @@ -0,0 +1,107 @@ +# Suivi des tâches ComfyUI + +Le client Python existant reste le point d'accès HTTP de l'Asset Creator. +Cette correction n'ajoute aucun service externe, modèle ou abonnement. + +## Deux défauts reproduits + +Sur le commit `5b6c83bd26f60f7eb5e4da53f958f2d3b06edb4a`, avec des réponses +simulées et les sockets interdites : + +- Une entrée d'historique contenant `status_str="error"`, `completed=false` + et des sorties partielles était renvoyée comme un résultat réussi. +- L'appel HTTP à `/history/{prompt_id}` ne recevait aucun `timeout`. + La lecture du client montre la même omission pour les autres appels, + sauf `/system_stats`. + +## Comportement du client + +| Situation | Résultat | +|---|---| +| `status.status_str == "success"` et `status.completed is True` | Retourne le dictionnaire `outputs`, même vide. | +| `status.status_str == "error"` | Lève `RuntimeError`, même avec des fichiers intermédiaires. | +| Ancien champ `error` au premier niveau | Lève `RuntimeError`. | +| Historique absent, statut inconnu ou incomplet | Continue le suivi jusqu'à son expiration. | +| Budget de suivi écoulé | `ComfyUIWaitTimeout`, avec `reason="wait_deadline"`. | +| `requests.exceptions.Timeout` pendant un GET de suivi | `ComfyUIWaitTimeout`, avec `reason="http_timeout"` et cause conservée. | +| Autre erreur HTTP ou réseau | Exception Requests transmise à l'appelant. | + +`ComfyUIWaitTimeout` hérite de `TimeoutError` et expose `prompt_id`. +Le client ne supprime pas la tâche, ne l'annule pas et ne la soumet pas à +nouveau quand le suivi expire. Une expiration ne prouve pas que la tâche a +échoué ou qu'elle tourne encore ; son état doit être relu sur le serveur. + +## Deux délais distincts + +- `request_timeout=30.0` : délai Requests appliqué à tous les appels HTTP. + Le contrôle de disponibilité garde un plafond de 5 secondes. +- `wait_for_result(..., timeout=300.0, poll_interval=2.0)` : budget du suivi + et intervalle entre lectures. Le budget utilise une horloge monotone. + Avant chaque GET, le délai HTTP est limité au budget restant ; les pauses + sont également limitées. Le budget est revérifié au retour des GET et + après le callback avant de dormir. + +Ces trois paramètres doivent être finis et strictement positifs. +`get_history` et `get_queue_status` acceptent aussi un `timeout` nommé pour +réduire leur délai HTTP, sans dépasser celui du client. + +Le délai Requests concerne la connexion et l'inactivité de lecture. Il +ne garantit **pas** une durée totale stricte : plusieurs adresses réseau, +une réponse qui arrive lentement ou un callback bloquant peuvent dépasser +le budget. Aucun thread n'est abandonné pour simuler une telle garantie. +Voir la [documentation officielle Requests sur les délais](https://requests.readthedocs.io/en/latest/user/advanced/#timeouts). + +## Reprendre le suivi + +```python +from addons.official.storycore_asset_creator.src.comfyui_client import ( + ComfyUIClient, + ComfyUIWaitTimeout, +) + +client = ComfyUIClient.from_project_config(request_timeout=30.0) +# prompt_id est l'identifiant déjà renvoyé par queue_workflow et sauvegardé. +try: + outputs = client.wait_for_result(prompt_id, timeout=300.0) +except ComfyUIWaitTimeout as error: + prompt_id_a_reprendre = error.prompt_id + # Plus tard : client.wait_for_result(prompt_id_a_reprendre, timeout=300.0) +``` + +L'appelant doit sauvegarder l'identifiant dès l'acceptation. Cette correction +ne crée pas de stockage persistant de tâches et ne raccorde pas encore la +reprise à une interface MCP, CLI ou Blender. + +Si le POST `/prompt` expire avant de renvoyer un identifiant, l'acceptation +reste incertaine. Le client laisse remonter l'exception Requests et n'ajoute +aucune relance automatique. Il faut vérifier la file du serveur avant de +décider d'une nouvelle soumission. + +Les réponses des téléchargements sont fermées même en cas d'erreur. +Un téléchargement interrompu peut toujours laisser un fichier partiel : +la correction ne rend pas l'écriture atomique. + +## Contrat et vérification + +Contrat lu dans ComfyUI au commit +[`387f98aa2822f684b8597959a52a467d88cc4806`](https://github.com/Comfy-Org/ComfyUI/commit/387f98aa2822f684b8597959a52a467d88cc4806) : +[`execution.py`](https://github.com/Comfy-Org/ComfyUI/blob/387f98aa2822f684b8597959a52a467d88cc4806/execution.py#L1316-L1340) +décrit le statut enregistré ; +[`main.py`](https://github.com/Comfy-Org/ComfyUI/blob/387f98aa2822f684b8597959a52a467d88cc4806/main.py#L367-L372) +le renseigne à la fin du traitement. Le client ne déduit pas la réussite +de la seule présence d'un fichier. + +Depuis la racine du dépôt, avec `requests` installé : + +```bash +python -m unittest discover -s tests/asset_creator -p test_comfyui_client.py -v +``` + +Les 18 tests utilisent des réponses HTTP simulées et une horloge contrôlée. +La création de sockets y est interdite. Ils couvrent les statuts terminaux, +les sorties partielles, les délais, la reprise et les ressources HTTP. +Vérification locale effectuée avec Python 3.12.14 et Requests 2.34.2. + +Il ne s'agit pas d'un test sur un serveur ComfyUI, Blender ou un GPU. +Les recettes Trellis2 locales, leurs extensions et le format API réellement +soumis restent à vérifier dans l'environnement d'exécution. diff --git a/addons/official/storycore_asset_creator/README.md b/addons/official/storycore_asset_creator/README.md index 2cd61736..a9cd2086 100644 --- a/addons/official/storycore_asset_creator/README.md +++ b/addons/official/storycore_asset_creator/README.md @@ -51,6 +51,12 @@ Le port ComfyUI **n'est jamais supposé** — il varie selon l'édition install 3. Variables d'environnement ← STORYCORE_COMFYUI_HOST / STORYCORE_COMFYUI_PORT ``` +### Délais et reprise du suivi + +Le client distingue le délai HTTP du budget d'attente de la génération. +Une expiration du suivi conserve l'identifiant du prompt ; elle ne l'annule +pas. Voir [le contrat du client et les tests](COMFYUI_CLIENT.md). + ### Override via variables d'environnement ```bash @@ -91,12 +97,37 @@ workflows/ trellis2_lowvram.json ← workflow Low VRAM (recommandé) trellis2_standard.json ← workflow qualité standard trellis2_lowpoly.json ← workflow low poly - trellis2_trunk_only.json ← workflow tronc seul (inclus) + trellis2_trunk_only.json ← workflow tronc seul (à fournir) ``` > Les workflows `PixelArtistry_Trellis2_*.json` de votre dossier `Downloads/3s/` > doivent être copiés ici et renommés selon la convention ci-dessus. +Ces quatre fichiers ne sont pas inclus dans l'arbre du dépôt examiné pour cette +correction. Un nom de preset déclaré ne garantit pas qu'une recette est installée. + +Le pipeline prépare désormais la recette **avant tout appel client et tout upload**. +Une image source absente, un preset inconnu, un fichier JSON absent ou illisible, +une structure d'éditeur incompatible ou un nœud d'image non modifiable provoquent +une erreur locale. Un preset inconnu ne sélectionne plus silencieusement `lowvram`. +Le template préparé est conservé en mémoire ; après upload, seul son nom d'image +est remplacé par celui renvoyé par ComfyUI, sans relecture de la recette sur disque. + +**Portée de cette validation :** le chargeur manipule encore les champs d'éditeur +`nodes` / `widgets_values`. Cette correction ne convertit pas le template au format +API de `/prompt` et ne prouve pas que le graphe est exécutable. La conversion, la +disponibilité des nœuds/modèles, les délais HTTP et un essai réel restent à vérifier +à partir des workflows effectivement utilisés dans Asset Factory / ComfyUI. + +Tests locaux sur recettes synthétiques, depuis la racine du dépôt : + +```bash +python -m unittest discover -s tests/asset_creator -v +``` + +Ils ne nécessitent ni Blender, ni serveur ComfyUI, ni GPU. Voir leur +[portée précise](../../../tests/asset_creator/README.md). + ### 3. Installation de l'addon ``` @@ -181,7 +212,7 @@ storycore_asset_creator/ ├── trellis2_lowvram.json ← Workflow ComfyUI (à copier) ├── trellis2_standard.json ← Workflow ComfyUI (à copier) ├── trellis2_lowpoly.json ← Workflow ComfyUI (à copier) - └── trellis2_trunk_only.json ← Workflow tronc seul (placeholder inclus) + └── trellis2_trunk_only.json ← Workflow tronc seul (à copier) ``` --- diff --git a/addons/official/storycore_asset_creator/src/comfyui_client.py b/addons/official/storycore_asset_creator/src/comfyui_client.py index 6e5cf545..4a6fafe6 100644 --- a/addons/official/storycore_asset_creator/src/comfyui_client.py +++ b/addons/official/storycore_asset_creator/src/comfyui_client.py @@ -10,10 +10,11 @@ from __future__ import annotations +import math import time import uuid from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any try: import requests @@ -21,6 +22,29 @@ requests = None # Blender embeds its own Python; requests peut manquer +class ComfyUIWaitTimeout(TimeoutError): + """Suivi interrompu; prompt_id permet de reprendre sans soumettre de nouveau.""" + + def __init__(self, prompt_id: str, reason: str = "wait_deadline"): + self.prompt_id = prompt_id + self.reason = reason + detail = ( + "delai HTTP depasse" + if reason == "http_timeout" + else "delai d'attente depasse" + ) + super().__init__( + f"ComfyUI {prompt_id}: {detail}. " + "Le suivi est interrompu; le prompt n'a pas ete annule." + ) + + +def _positive_seconds(value: float, name: str) -> float: + if not math.isfinite(value) or value <= 0: + raise ValueError(f"{name} doit etre un nombre fini strictement positif") + return value + + class ComfyUIClient: """ Client HTTP pour ComfyUI (localhost ou remote). @@ -35,7 +59,14 @@ class ComfyUIClient: NE PAS hardcoder le port 8188 — lire depuis config/comfyui_config.json. """ - def __init__(self, host: str = "127.0.0.1", port: Optional[int] = None): + def __init__( + self, + host: str = "127.0.0.1", + port: int | None = None, + *, + request_timeout: float = 30.0, + ): + self.request_timeout = _positive_seconds(request_timeout, "request_timeout") if port is None: # Tenter de charger depuis la config projet try: @@ -51,7 +82,9 @@ def __init__(self, host: str = "127.0.0.1", port: Optional[int] = None): self.client_id = str(uuid.uuid4()) @classmethod - def from_project_config(cls, blender_prefs=None) -> "ComfyUIClient": + def from_project_config( + cls, blender_prefs=None, *, request_timeout: float = 30.0 + ) -> ComfyUIClient: """ Cree un client en lisant la config depuis config/comfyui_config.json (avec surcharge optionnelle depuis les preferences Blender). @@ -66,19 +99,28 @@ def from_project_config(cls, blender_prefs=None) -> "ComfyUIClient": from .config_loader import get_comfyui_connection host, port = get_comfyui_connection(blender_prefs=blender_prefs) - return cls(host=host, port=port) + return cls(host=host, port=port, request_timeout=request_timeout) + + def _http_timeout(self, timeout: float | None) -> float: + if timeout is None: + return self.request_timeout + return min(self.request_timeout, _positive_seconds(timeout, "timeout")) # ── API ────────────────────────────────────────────────────────────────── def is_alive(self) -> bool: """Verifie que ComfyUI repond.""" + if requests is None: + return False try: - r = requests.get(f"{self.base_url}/system_stats", timeout=5) + r = requests.get( + f"{self.base_url}/system_stats", timeout=min(5.0, self.request_timeout) + ) return r.status_code == 200 - except Exception: + except requests.exceptions.RequestException: return False - def upload_image(self, image_path: str, subfolder: str = "") -> Dict[str, Any]: + def upload_image(self, image_path: str, subfolder: str = "") -> dict[str, Any]: """ Upload une image dans ComfyUI input/. @@ -90,12 +132,17 @@ def upload_image(self, image_path: str, subfolder: str = "") -> Dict[str, Any]: data = {"type": "input", "overwrite": "true"} if subfolder: data["subfolder"] = subfolder - r = requests.post(f"{self.base_url}/upload/image", files=files, data=data) + r = requests.post( + f"{self.base_url}/upload/image", + files=files, + data=data, + timeout=self.request_timeout, + ) r.raise_for_status() return r.json() def queue_workflow( - self, workflow: Dict[str, Any], client_id: Optional[str] = None + self, workflow: dict[str, Any], client_id: str | None = None ) -> str: """ Envoie le workflow dans la queue ComfyUI. @@ -106,19 +153,25 @@ def queue_workflow( "prompt": workflow, "client_id": client_id or self.client_id, } - r = requests.post(f"{self.base_url}/prompt", json=payload) + r = requests.post( + f"{self.base_url}/prompt", json=payload, timeout=self.request_timeout + ) r.raise_for_status() return r.json()["prompt_id"] - def get_queue_status(self) -> Dict[str, Any]: + def get_queue_status(self, *, timeout: float | None = None) -> dict[str, Any]: """Retourne le statut de la queue.""" - r = requests.get(f"{self.base_url}/queue") + r = requests.get(f"{self.base_url}/queue", timeout=self._http_timeout(timeout)) r.raise_for_status() return r.json() - def get_history(self, prompt_id: str) -> Optional[Dict[str, Any]]: + def get_history( + self, prompt_id: str, *, timeout: float | None = None + ) -> dict[str, Any] | None: """Retourne l'historique d'un prompt execute.""" - r = requests.get(f"{self.base_url}/history/{prompt_id}") + r = requests.get( + f"{self.base_url}/history/{prompt_id}", timeout=self._http_timeout(timeout) + ) r.raise_for_status() data = r.json() return data.get(prompt_id) @@ -129,44 +182,79 @@ def wait_for_result( timeout: float = 300.0, poll_interval: float = 2.0, progress_callback=None, - ) -> Dict[str, Any]: + ) -> dict[str, Any]: """ Attend la fin d'un prompt en polling. Args: prompt_id : ID retourne par queue_workflow - timeout : secondes max avant abandon + timeout : budget de suivi (hors garanties de temps reel) poll_interval : intervalle de polling en secondes progress_callback: callable(status_str) optionnel Returns: outputs dict du prompt - Raises: TimeoutError si depasse le timeout + Raises: ComfyUIWaitTimeout si budget ecoule ou delai HTTP depasse RuntimeError si erreur dans le workflow + + Un timeout arrete le suivi, pas le prompt. Les delais Requests bornent + connexion/inactivite de lecture, pas la duree totale d'une requete. """ - start = time.time() - while True: - elapsed = time.time() - start - if elapsed > timeout: - raise TimeoutError(f"Trellis2: timeout apres {timeout}s") + _positive_seconds(timeout, "timeout") + _positive_seconds(poll_interval, "poll_interval") + if requests is None: + raise ImportError("Le client ComfyUI requiert le module requests") + start = time.monotonic() + deadline = start + timeout + + def remaining() -> float: + budget = deadline - time.monotonic() + if budget <= 0: + raise ComfyUIWaitTimeout(prompt_id) + return budget - history = self.get_history(prompt_id) + while True: + try: + history = self.get_history(prompt_id, timeout=remaining()) + except requests.exceptions.Timeout as error: + raise ComfyUIWaitTimeout(prompt_id, "http_timeout") from error + remaining() if history: if "error" in history: - raise RuntimeError(f"ComfyUI erreur: {history['error']}") - outputs = history.get("outputs", {}) - if outputs: - return outputs + raise RuntimeError( + f"ComfyUI {prompt_id} erreur: {history['error']}" + ) + status = history.get("status") + if isinstance(status, dict): + if status.get("status_str") == "error": + raise RuntimeError( + f"ComfyUI {prompt_id} erreur: {status.get('messages', [])}" + ) + if ( + status.get("status_str") == "success" + and status.get("completed") is True + ): + outputs = history.get("outputs", {}) + if not isinstance(outputs, dict): + raise RuntimeError( + f"ComfyUI {prompt_id}: outputs invalides" + ) + return outputs if progress_callback: - queue = self.get_queue_status() + try: + queue = self.get_queue_status(timeout=remaining()) + except requests.exceptions.Timeout as error: + raise ComfyUIWaitTimeout(prompt_id, "http_timeout") from error + remaining() running = len(queue.get("queue_running", [])) pending = len(queue.get("queue_pending", [])) + elapsed = time.monotonic() - start progress_callback( f"Running: {running} | Pending: {pending} | {elapsed:.0f}s" ) - time.sleep(poll_interval) + time.sleep(min(poll_interval, remaining())) def download_output(self, filename: str, dest_dir: str, subfolder: str = "") -> str: """ @@ -177,36 +265,37 @@ def download_output(self, filename: str, dest_dir: str, subfolder: str = "") -> params = {"filename": filename, "type": "output"} if subfolder: params["subfolder"] = subfolder - r = requests.get(f"{self.base_url}/view", params=params, stream=True) - r.raise_for_status() - - dest = Path(dest_dir) - dest.mkdir(parents=True, exist_ok=True) - out_path = dest / filename + with requests.get( + f"{self.base_url}/view", + params=params, + stream=True, + timeout=self.request_timeout, + ) as r: + r.raise_for_status() + dest = Path(dest_dir) + dest.mkdir(parents=True, exist_ok=True) + out_path = dest / filename - with open(out_path, "wb") as f: - for chunk in r.iter_content(chunk_size=8192): - f.write(chunk) + with open(out_path, "wb") as f: + f.writelines(r.iter_content(chunk_size=8192)) return str(out_path) - def get_output_files(self, outputs: Dict[str, Any]) -> list[str]: + def get_output_files(self, outputs: dict[str, Any]) -> list[str]: """ Extrait la liste des noms de fichiers depuis les outputs d'un prompt. Cherche les nodes de type 'images', 'gltf', 'glb_path' etc. """ files = [] - for node_id, node_outputs in outputs.items(): - for key, values in node_outputs.items(): + for node_outputs in outputs.values(): + for values in node_outputs.values(): if isinstance(values, list): for v in values: if isinstance(v, dict) and "filename" in v: files.append(v["filename"]) - elif isinstance(values, str) and ( - values.endswith(".glb") - or values.endswith(".gltf") - or values.endswith(".png") + elif isinstance(values, str) and values.endswith( + (".glb", ".gltf", ".png") ): files.append(Path(values).name) return files diff --git a/addons/official/storycore_asset_creator/src/pipeline_image_to_3d.py b/addons/official/storycore_asset_creator/src/pipeline_image_to_3d.py index 68f458d1..7b94e0b5 100644 --- a/addons/official/storycore_asset_creator/src/pipeline_image_to_3d.py +++ b/addons/official/storycore_asset_creator/src/pipeline_image_to_3d.py @@ -20,7 +20,11 @@ from typing import Any, Callable, Dict, Optional from .comfyui_client import ComfyUIClient -from .trellis_workflows import build_workflow, get_expected_output_names +from .trellis_workflows import ( + build_workflow, + get_expected_output_names, + patch_input_image, +) class ImageTo3DPipeline: @@ -94,7 +98,19 @@ def run( start = time.time() img_path = Path(image_path) output_path = Path(output_dir) - output_path.mkdir(parents=True, exist_ok=True) + + # Preparer une seule fois avant tout appel client ou creation de sortie. + if not img_path.is_file(): + raise FileNotFoundError(f"Image source introuvable: {img_path}") + self._log(f"Preparation workflow Trellis2 ({preset})...", progress_callback) + workflow = build_workflow( + image_filename=img_path.name, + asset_name=asset_name, + preset=preset, + seed=seed, + remove_background=remove_background, + resolution=512 if preset == "lowvram" else 1024, + ) # 1. Verifier ComfyUI self._log("Verification ComfyUI...", progress_callback) @@ -104,21 +120,15 @@ def run( ) # 2. Upload image + output_path.mkdir(parents=True, exist_ok=True) self._log(f"Upload image: {img_path.name}", progress_callback) upload_info = self.client.upload_image(str(img_path)) uploaded_filename = upload_info.get("name", img_path.name) self._log(f"Image uploadee: {uploaded_filename}", progress_callback) - # 3. Construire et envoyer le workflow + # 3. Reutiliser le template prepare avec le nom retourne par l'upload. self._log(f"Lancement Trellis2 ({preset})...", progress_callback) - workflow = build_workflow( - image_filename=uploaded_filename, - asset_name=asset_name, - preset=preset, - seed=seed, - remove_background=remove_background, - resolution=512 if preset == "lowvram" else 1024, - ) + workflow = patch_input_image(workflow, uploaded_filename) prompt_id = self.client.queue_workflow(workflow) self._log(f"Workflow en queue: {prompt_id}", progress_callback) diff --git a/addons/official/storycore_asset_creator/src/trellis_workflows.py b/addons/official/storycore_asset_creator/src/trellis_workflows.py index 67791040..5b1d524e 100644 --- a/addons/official/storycore_asset_creator/src/trellis_workflows.py +++ b/addons/official/storycore_asset_creator/src/trellis_workflows.py @@ -44,12 +44,21 @@ def load_workflow(preset: str = "lowvram") -> Dict[str, Any]: Returns: dict workflow (deepcopy pour eviter mutations) """ - filename = PRESETS.get(preset, WORKFLOW_LOWVRAM) + if not isinstance(preset, str) or preset not in PRESETS: + raise ValueError(f"Preset Trellis2 inconnu: {preset!r}") + filename = PRESETS[preset] path = _WORKFLOWS_DIR / filename if not path.exists(): raise FileNotFoundError(f"Workflow introuvable: {path}") with open(path, encoding="utf-8") as f: - return copy.deepcopy(json.load(f)) + workflow = json.load(f) + if ( + not isinstance(workflow, dict) + or not isinstance(workflow.get("nodes"), list) + or not all(isinstance(node, dict) for node in workflow["nodes"]) + ): + raise ValueError("Workflow Trellis2: liste de nodes d'editeur attendue") + return copy.deepcopy(workflow) def patch_input_image(workflow: Dict[str, Any], image_filename: str) -> Dict[str, Any]: @@ -59,10 +68,18 @@ def patch_input_image(workflow: Dict[str, Any], image_filename: str) -> Dict[str Le node d'entree image a type 'Trellis2LoadImageWithTransparency'. widgets_values[0] = nom du fichier image. """ - for node in workflow.get("nodes", []): - if node.get("type") == "Trellis2LoadImageWithTransparency": - if "widgets_values" in node and len(node["widgets_values"]) > 0: - node["widgets_values"][0] = image_filename + image_nodes = [ + node + for node in workflow.get("nodes", []) + if node.get("type") == "Trellis2LoadImageWithTransparency" + ] + if not image_nodes or any( + not isinstance(node.get("widgets_values"), list) or not node["widgets_values"] + for node in image_nodes + ): + raise ValueError("Workflow Trellis2: noeud d'image absent ou non modifiable") + for node in image_nodes: + node["widgets_values"][0] = image_filename return workflow @@ -140,9 +157,10 @@ def build_workflow( resolution: int = 512, ) -> Dict[str, Any]: """ - Construit un workflow pret a envoyer a ComfyUI. + Prepare les champs du template d'editeur Trellis2. Applique tous les patches dans l'ordre correct. + Ne valide ni le format de soumission API ni les dependances installees. Args: image_filename : nom du fichier upload dans ComfyUI (ex: "hero.png") @@ -152,7 +170,7 @@ def build_workflow( remove_background: True si l'image n'a pas de fond transparent resolution : 512 (lowvram) ou 1024 (qualite) - Returns: workflow dict pret pour ComfyUIClient.queue_workflow() + Returns: template dict prepare (compatibilite API a verifier separement). """ wf = load_workflow(preset) wf = patch_input_image(wf, image_filename) diff --git a/apps/storycore-harbour/README.md b/apps/storycore-harbour/README.md index 87b67679..dfbc0ce4 100644 --- a/apps/storycore-harbour/README.md +++ b/apps/storycore-harbour/README.md @@ -67,6 +67,12 @@ npm run dev:mock `npm install` also generates the bundle copy of the canonical acceptance corpus. `npm run check` runs the tests, contract, mock response, acceptance corpus/synchronization, and strict Anna validator. +With the mock Anna harness already running, set `BROWSER_EXECUTABLE` to Edge or +another compatible Chromium binary and run `npm run browser:check`. This runs +the complete generation/export smoke and the storage-deletion smoke +sequentially. Do not run them in parallel against one mock harness because the +fixture stream is shared. + For the real-model protocol, follow `acceptance/README.md`. Do not run the collector without an authenticated Anna test account, an enabled model, sufficient quota, and explicit confirmation in its UI. ## Release identity diff --git a/apps/storycore-harbour/STATUS.md b/apps/storycore-harbour/STATUS.md index aff223d7..c8444c35 100644 --- a/apps/storycore-harbour/STATUS.md +++ b/apps/storycore-harbour/STATUS.md @@ -288,4 +288,32 @@ Codex must inspect the latest CI and Sonar results first. The next legitimate im - The Anna adapter now exposes an optional user-entered model preference. Blank or invalid values preserve the Anna default; valid hints are advisory and only match models already enabled for that user. Normal StoryCore data and provider credentials remain unaffected. Automated gates pass: 65/65 Node tests, strict validation, Edge end-to-end smoke, and Edge deletion/storage-preservation smoke. - The complete fixed corpus with user preference `gemma` finished at 14/20: median 21.67 seconds, p95 39.78 seconds, 6 repaired passes, and no JSON truncation. Failures were A02 `reference_invalid`, A06/A09/A11 `contract_invalid`, A10 `warning_severity_invalid`, and A19 `required_field_invalid`. - Exact private-output replay identified two bounded schema aliases: A02 used warning `sceneId: "null"`; A10 used severity `minor`. Both now normalize to canonical `null` and `info`, and their exact real outputs pass the validator locally. This projects Gemma to 16/20 but does not replace the measured 14/20 score. Three malformed JSON responses and one structurally empty project remain rejected. +- A loopback-only cross-model experiment kept Anna default for primary generation and hinted Gemma only for the single repair. It passed A02/A15 directly but failed A07 (`contract_invalid`) and A18 (`unknown`): 2/4, worse than Gemma-only 3/4. The uncommitted experiment was removed; production retains one user-selected preference for both calls. +- A fresh Gemma A06 reproduction ruled out a bounded syntax-only repair. The primary output was missing one final brace, but appending it still left characters, locations, scenes, score, and warnings absent. The model repair was valid JSON yet again omitted the production bible and all core arrays. Parser recovery must remain fail-closed because required creative structure cannot be inferred safely. +- A single A06 diagnostic using Anna's documented example hint `gpt-4o` produced no completion or model metadata. The UI recorded timeout while the harness request remained pending for more than six minutes and only window heartbeats continued. The server was stopped to terminate the orphaned request; do not retry this hint until Anna exposes model grants or fixes cancellation/deadline propagation. - Reliability evidence is now a two-profile matrix: Anna default 16/20; Gemma preference 14/20. Both fail the 18/20 gate. No readiness, version cut, review, or release claim is permitted. +- An owner-authorized complete corpus rerun on 2026-08-27 used Anna default `minimax/minimax-m3` through OpenRouter and regressed to 6/20. Median successful duration was 49.13 seconds, p95 was 57.87 seconds, and 2 successful projects used repair. Privacy-safe failures were eight `json_invalid`, five `contract_invalid`, and one timeout. The complete private result remains ignored at `acceptance/results.2026-08-27.local.jsonl`. This stochastic regression does not justify weakening the contract or cutting a version; it reinforces the unresolved default-model reliability blocker. +- A second owner-authorized complete corpus rerun on 2026-08-27 used the advisory `gemma` hint and finished at 15/20. Median successful duration was 21.91 seconds, p95 was 41.92 seconds, and 6 successful projects used repair. Privacy-safe failures were A07/A10/A12/A19 `contract_invalid` and A20 `required_field_invalid`; there were no timeouts or JSON truncations. The private result remains ignored at `acceptance/results.2026-08-27.gemma.local.jsonl`. This improves the measured Gemma profile from 14/20 to 15/20 but remains below the 18/20 gate, so no readiness, version cut, review submission, or release claim is permitted. +- Future acceptance runs now classify schema, timestamp, duplicate, ordering, continuity-score, structure, and parent-scene reference failures with stable privacy-safe names. Unknown validation details still collapse to `contract_invalid`; generated content and private identifiers are never persisted in the public result. +- The privacy-safe diagnostic update is synchronized to Anna working draft revision 10. The 15-file, 104,031-byte bundle is ready with content hash `7d3edf2a89b942055c4af9d53b7dcfb6de1777e0437b27711b05bef09e2049b9`. The App remains a mutable draft with zero immutable versions; this synchronization is not an installation, review, or release claim. +- Read-only `apps grants storycore-harbour --json` currently returns `grants: null`. In CLI 0.1.30 this specifically represents a 404 from the informational grants endpoint (`no grants endpoint data available`), not evidence that the declared Host APIs were denied. Prior authenticated Host calls remain the capability evidence; do not use this null result to claim either a grant or a denial. +- Fresh Edge verification on 2026-08-28 passed the 520 × 680 generation/export flow, keyboard navigation, focus management, reduced-motion context, 400% text reflow, project reset/restore, and contract-valid export. The deletion smoke also passed sequentially with two ETag deletions, paginated listing, unrelated-key preservation, and a not-found reload. A new `browser:check` script fixes the intended sequential order because both smokes share one mock fixture stream. +- Edge now emulates `forced-colors: active` during the browser smoke and verifies visible keyboard focus, a non-colour selected-step outline, fully opaque dashed disabled controls, and a solid error boundary. The App also supplies forced-colour treatments for warning/success/error deletion states, armed destructive actions, and the continuity score. This automated evidence reduces risk but does not replace the remaining human Windows High Contrast and screen-reader passes. +- The forced-colour fix is synchronized to Anna working draft revision 11. Its 15-file, 104,890-byte bundle is ready with content hash `c5ddb6b3a45b42cebb3ceed713f65121ba3efac9808fece05aa05ad6ce863a17`; the App still has zero immutable versions and remains unpublished. +- Revision 11 demo evidence was regenerated locally in under twenty seconds: four visually inspected 900 × 820 PNGs plus a 3,288-byte contract-valid JSON export. The generated files remain ignored under `demo/output.local/revision-11/`; their sizes and one-run SHA-256 values are recorded in `demo/EVIDENCE_2026-08-28_REVISION_11.md`. The screenshot helper now resets every App scroll container before capture, fixing the measured missing-header defect on the World draft. +- Anna's public Developer Hub and pinned CLI schema were rechecked on 2026-08-29. They identify the Developer Console Listing tab as the metadata/media surface but expose no numeric screenshot or Marketplace-logo limits. Both available browser sessions required a fresh owner sign-in, so no authenticated field hints were claimed. `review/ANNA_MEDIA_REQUIREMENTS_REQUEST.md` contains the exact ready-to-send question; the 900 × 820 PNGs remain drafts and nothing was uploaded or submitted. +- Concurrent owner work on 2026-08-29 made the single repair prompt schema-complete: it now enumerates every required project, bible, entity, scene, shot, and continuity field; fixes the three-scene/one-shot shape; preserves exact source input; and still discards the failed response. The changes were merged without rewriting history and pass 67/67 Node tests, strict validation, the sequential Edge generation/export smoke, forced-colour assertions, and the ETag deletion smoke. +- The schema-complete repair prompt is synchronized to Anna working draft revision 12. Its 15-file, 107,699-byte bundle is ready with content hash `ad383957f123c1a2ea6c20e6ebb499f192f9ad161af4b0a9b0cc2bdb8e353fad`; the App remains an unpublished draft with zero immutable versions. No real-model run or quota consumption was performed for this synchronization. +- Authenticated Listing inspection on 2026-08-29 confirmed logo formats PNG/JPG/WebP/GIF, a 2MB maximum, and 256 × 256 cropping. Screenshot metadata remains one URL per line with no visible or HTML-enforced count, dimensions, aspect ratio, format, or byte limit. No field was changed and no asset was uploaded. +- A reproducible Marketplace logo candidate now renders from the committed SVG through Edge at `review/marketplace-media/storycore-harbour-logo-256.png`. It is 256 × 256, 5,302 bytes, SHA-256 `4a54e1955ac5ebe67eef8c82a260b1b1cdc351a7e87902f2bba2b15532cea7dd`, and passes PNG header, size, dimension, and representative-pixel validation. The first renderer attempt was correctly rejected after visual inspection exposed an unloaded black image; the renderer now embeds and decodes the SVG before capture. +- Authenticated Console inspection on 2026-08-29 confirmed working draft r12, bundle `ready`, content hash prefix `ad383957f123…`, and no version history. CLI status independently confirms `draft`, unpublished, and zero versions. The current Versions tab now exposes a distinct **Install & test** working-draft action, so an immutable cut is no longer assumed to be the only test path. It was not clicked. **View manifest** returned `Could not validate credentials`; this is recorded as a web-session/platform blocker rather than a bundle failure. +- A single instrumented **View manifest** reproduction produced the same credential message but no observable network event, HTTP status, or console error through the available browser diagnostics. Root cause remains unproven. `review/ANNA_VIEW_MANIFEST_CREDENTIAL_REPORT.md` contains privacy-safe steps, independent CLI evidence, the exact uncertainty boundary, and a ready-to-send support question; nothing was posted. +- After a fresh owner sign-in, **View manifest** succeeded and displayed the normalized working r12 manifest. It matches the committed Schema 2 Host-API-only boundary: no Executas, no top-level permissions, no external origins, one desktop view, `llm.complete`, App storage get/set/list/delete, `window.set_title`, self-only script CSP, and `last_writer_wins`. The credential incident is operationally resolved by reauthentication, while the exact token/frontend root cause remains unproven. No support message was sent. +- Installed Apps already contains StoryCore Harbour as `v0.0.0-dev`, so **Install & test** was not clicked again. Its read-only Permissions panel returns `Failed to load permissions: App version not found`. CLI status simultaneously confirms draft r12 has zero immutable versions and the grants endpoint exposes no data. The facts identify a dev-install/version-resolution blocker for permission management; they do not prove whether refresh, immutable cut, or server repair is the correct fix. `review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md` contains the bounded support question, and nothing was sent or changed. +- Public Anna guidance rechecked on 2026-08-30 is internally ambiguous for this exact boundary: one guide moves installation/permissions after an immutable cut, another team response directs working-draft installation, and beta.126 excludes `0.0.0-draft` projections from release-candidate selection. The evidence is recorded in the permissions report and does not authorize a speculative reinstall or `0.1.0` cut. +- An owner-authorized complete corpus on 2026-08-31 used the advisory `gemma` hint after the schema-complete repair change. The connected Anna UI finished at 15/20, median 23.90 seconds, p95 44.00 seconds, and 7 repaired passes. Failures were HBR-A01 `unknown` after one transport `fetch failed`, plus HBR-A06/A08/A15/A20 `json_invalid`. Every malformed primary/repair response used `gemma-4-E4B-it` through Runpod, ended with `endTurn`, stayed between 1,269 and 1,722 output tokens, and ended with a closing brace; the remaining failures are internal JSON syntax errors rather than 4,096-token truncation. The score remains below 18/20, so no readiness, immutable cut, review, merge, or release claim is permitted. +- The iframe sandbox did not surface its Blob download to Browser automation. The private result was therefore recovered from the same run's RPC log: exactly 15 `projects/current` writes were matched to the immutable corpus, each recovered project passed the canonical contract, UI-displayed successful durations were retained at 0.1-second precision, and failure durations came from the sequential RPC timestamps. The canonical evaluator independently reproduced 15/20 and the same five privacy-safe failures. Ignored evidence remains at `acceptance/results.2026-08-31.gemma.recovered.local.jsonl`, `acceptance/harness.2026-08-31.gemma.local.log`, and companion local logs; generated content was not committed or posted. +- A private offline punctuation-recovery experiment made no model calls and never changed generated words. A bounded search of at most three edits from `{ } [ ] , :` recovered A08 and A15 with two edits each, so the best projected score was only 17/20. A06 remained unparsable after 28,876 bounded candidates. Closing A20's unbalanced JSON made it parse but left five canonical contract errors. There is no single bounded syntax rule that reaches the release gate, so the experimental helper was removed and production parsing remains fail-closed. +- Structured-output research on 2026-09-01 found that Anna documents `json_object`/`json_schema` for Executa `sampling/createMessage`, not for StoryCore's direct iframe `anna.llm.complete`. The pinned CLI 0.1.30 has no structured-output implementation; a read-only inspection of npm CLI 0.1.49 found negotiation only in the sampling bridge and still no direct Host API field. An Executa migration would violate the Host-API-only MVP boundary, and a current Cloud Agent report shows `json_schema` failures even with fallback. `review/ANNA_STRUCTURED_OUTPUT_REQUEST.md` contains the exact support question and a one-prompt gate; no package was upgraded and no model call was made. +- A 2026-09-05 refresh found no direct-Host structured-output update. CLI 0.1.51 still confines `response_format` negotiation to Executa sampling. Its enhanced read-only `apps grants` command also returned only `grants: null` for StoryCore, without the new `satisfied`/`missing` fields, matching the unresolved `v0.0.0-dev` permission-version gap. The newer CLI was executed ephemerally and not added to the project; no quota, grant, draft, version, or installation state changed. +- With explicit owner approval, one combined plain-text support email was sent to `hi@anna.partners` on 2026-09-05. It asks how to repair the existing `v0.0.0-dev` permission/version mismatch and whether direct iframe `anna.llm.complete` supports negotiated `json_object`/`json_schema`. It includes App id 214, slug, revision, privacy-safe 15/20 evidence, and the non-action boundaries; it contains no attachment, raw generated response, private JSONL, credential, or token. Do not send a duplicate while awaiting Anna's reply. diff --git a/apps/storycore-harbour/bundle/acceptance-failure.js b/apps/storycore-harbour/bundle/acceptance-failure.js index 65cedd80..366e2547 100644 --- a/apps/storycore-harbour/bundle/acceptance-failure.js +++ b/apps/storycore-harbour/bundle/acceptance-failure.js @@ -20,12 +20,27 @@ export function publicFailureName(message, category) { return "json_invalid"; } if (value.includes("severity must be")) return "warning_severity_invalid"; - if (value.includes("references unknown") || value.includes("unknown scene") || value.includes("unknown character")) { + if ( + value.includes("references unknown") || + value.includes("unknown scene") || + value.includes("unknown character") || + value.includes("not listed in the parent scene") + ) { return "reference_invalid"; } if (value.includes("duration")) return "duration_invalid"; + if (value.includes("schemaversion must be") || value.includes("project.format is unsupported")) { + return "schema_invalid"; + } + if (value.includes("iso-compatible date-time")) return "timestamp_invalid"; + if (value.includes("duplicate")) return "duplicate_invalid"; + if (value.includes("must be a positive integer")) return "ordering_invalid"; + if (value.includes("continuityreport.score")) return "continuity_score_invalid"; if (value.includes("required") || value.includes("must contain") || value.includes("must be a string")) { return "required_field_invalid"; } + if (value.includes("must be an array") || value.includes("must be an object")) { + return "structure_invalid"; + } return "contract_invalid"; } diff --git a/apps/storycore-harbour/bundle/repair-prompt.js b/apps/storycore-harbour/bundle/repair-prompt.js index 3f55ed8b..24664dec 100644 --- a/apps/storycore-harbour/bundle/repair-prompt.js +++ b/apps/storycore-harbour/bundle/repair-prompt.js @@ -1,10 +1,91 @@ +const REQUIRED_SHAPE = Object.freeze({ + schemaVersion: "storycore-harbour.project.v1", + project: [ + "id", + "title", + "language", + "format", + "durationMinutes", + "audience", + "tone", + "sourceIdea", + "createdAt", + "updatedAt", + ], + productionBible: [ + "logline", + "synopsis", + "themes[]", + "visualDirection.style", + "visualDirection.palette[]", + "visualDirection.lighting", + "visualDirection.cameraLanguage", + "continuityRules[]", + ], + character: [ + "id", + "name", + "role", + "goal", + "conflict", + "visualIdentity", + "continuityRules[]", + ], + location: [ + "id", + "name", + "purpose", + "visualIdentity", + "continuityRules[]", + ], + scene: [ + "id", + "order", + "title", + "purpose", + "locationId", + "characterIds[]", + "durationSeconds", + "shots[]", + ], + shot: [ + "id", + "order", + "framing", + "camera", + "action", + "dialogue", + "sound", + "characterIds[]", + "generationPrompt", + ], + continuityReport: ["score", "warnings[]", "checkedAt"], + warning: ["severity", "message", "sceneId"], +}); + export function createRepairPrompt(input, errors) { return JSON.stringify({ - task: "Rebuild a complete StoryCore Harbour production package from the source input.", + task: "Rebuild the complete StoryCore Harbour project from the source input. The previous answer failed validation, so return a fresh self-contained project rather than a patch.", input, validationErrors: errors, - constraints: { - jsonOnly: true, + requiredShape: REQUIRED_SHAPE, + hardRules: [ + "Return exactly one JSON object and nothing else.", + "Include every required field listed in requiredShape, even when a string is intentionally empty.", + "Create exactly 3 scenes and exactly 1 shot inside each scene.", + "Create 1-3 characters and 1-3 locations; every scene locationId must reference a declared location.", + "Every scene characterIds entry must reference a declared character.", + "Every shot characterIds entry must reference a declared character also listed in its parent scene.", + "Use unique ids, scene orders 1,2,3, and shot order 1 in every scene.", + "dialogue and sound are always strings; use an empty string when there is intentionally none.", + "themes, palette, continuityRules, characterIds, shots, and warnings are always arrays.", + "warning severity is only info, warning, or error; sceneId is a declared scene id or null.", + "continuityReport.score is a number from 0 through 100.", + "Preserve input.title exactly when it is non-empty, and preserve input language, format, duration, audience, tone, and source idea.", + "Keep the entire JSON below 12000 characters; do not omit required structure to save space.", + "Before returning, silently verify the project contains project, productionBible, characters, locations, scenes, and continuityReport.", + ], + sizeBudget: { maxCharacters: 12_000, scenes: 3, shotsPerScene: 1, @@ -13,7 +94,10 @@ export function createRepairPrompt(input, errors) { synopsisMaxWords: 80, descriptionMaxWords: 40, generationPromptMaxWords: 60, - discardPreviousResponse: true, + maxThemes: 3, + maxProductionContinuityRules: 3, + maxEntityContinuityRules: 2, }, + discardPreviousResponse: true, }); } diff --git a/apps/storycore-harbour/bundle/style.css b/apps/storycore-harbour/bundle/style.css index 4438a806..1d7cb26e 100644 --- a/apps/storycore-harbour/bundle/style.css +++ b/apps/storycore-harbour/bundle/style.css @@ -244,3 +244,32 @@ footer { @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; } } +@media (forced-colors: active) { + button:focus, input:focus, select:focus, textarea:focus { + outline: 3px solid Highlight; + outline-offset: 2px; + } + button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { + outline-color: Highlight; + } + .step.active { + color: HighlightText; + background: Highlight; + outline: 2px solid Highlight; + outline-offset: -3px; + } + button:disabled { + opacity: 1; + color: GrayText; + border-color: GrayText; + border-style: dashed; + } + .message.error, .error-panel, .deletion-status.error { + color: CanvasText; + border: 2px solid CanvasText; + } + .deletion-status.warning { border-style: dashed; } + .deletion-status.success { border-style: double; } + button.danger-outline.armed { outline: 3px double CanvasText; } + .score { border-color: CanvasText; } +} diff --git a/apps/storycore-harbour/demo/EVIDENCE_2026-08-28_REVISION_11.md b/apps/storycore-harbour/demo/EVIDENCE_2026-08-28_REVISION_11.md new file mode 100644 index 00000000..55fd5b7f --- /dev/null +++ b/apps/storycore-harbour/demo/EVIDENCE_2026-08-28_REVISION_11.md @@ -0,0 +1,24 @@ +# StoryCore Harbour demo evidence — Anna draft revision 11 + +Generated locally on 2026-08-28 with the deterministic Anna mock harness and +Microsoft Edge. No Anna model quota, production storage, provider key, or +private prompt was used. The local run completed in under twenty seconds. + +| Artifact | Dimensions | Bytes | SHA-256 | +| --- | ---: | ---: | --- | +| `01-concept.png` | 900 x 820 | 100,272 | `4e5f6ee1f694d30ba8639db511e7b843b92a57228c06b17a44a56be6a60b1e9a` | +| `02-world.png` | 900 x 820 | 105,664 | `60da7be8ca9c56aa6e94f65a36e6b9f23ce0271ee8cc9dc272f571a6b489a4f0` | +| `03-scenes.png` | 900 x 820 | 93,409 | `73e3745867bc21c99701a6b334a7311a86ef7b7a3c10665434cff3c1271c6dc5` | +| `04-continuity.png` | 900 x 820 | 94,405 | `1add93ddd51cd37086b88f73592f1e7864006384f375a2f50f4ad82af823df7d` | +| `browser-smoke-story.storycore-harbour.json` | n/a | 3,288 | `fe10fa513a326fc26d71aea7ddd6a508e9d5f69d57fbf7696c3c4aa95f2a72e2` | + +The JSON export passed the canonical `storycore-harbour.project.v1` validator. +The four screenshots were visually inspected for clipping, inconsistent scroll +position, missing headers, misleading media claims, and obvious unreadable +content. They remain draft Marketplace evidence until Anna confirms its exact +image dimensions and file-size rules. + +The generated artifacts remain intentionally ignored under +`demo/output.local/revision-11/`. Reproduce them with the exact procedure in +`demo/README.md`; do not commit generated project text or screenshots merely to +replace the authenticated real-model, accessibility, or beta gates. diff --git a/apps/storycore-harbour/demo/README.md b/apps/storycore-harbour/demo/README.md index 5bfd10e2..8fda9c0c 100644 --- a/apps/storycore-harbour/demo/README.md +++ b/apps/storycore-harbour/demo/README.md @@ -28,21 +28,23 @@ Keep that terminal open. In a second PowerShell terminal, from the same director ```powershell $env:BROWSER_EXECUTABLE = 'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe' $env:HARBOUR_URL = 'http://127.0.0.1:5180/' -$env:HARBOUR_SCREENSHOT_DIR = (Resolve-Path '.').Path + '\demo\output.local' -npm run browser:smoke +$env:HARBOUR_SCREENSHOT_DIR = (Resolve-Path '.').Path + '\demo\output.local\current' +npm run browser:check ``` Expected final line: ```text -{"result":"pass",...,"exportContract":"valid",...,"screenshotsCaptured":4,...} +{"result":"pass",...,"forcedColors":"pass",...,"exportContract":"valid",...,"screenshotsCaptured":4,...} +{"result":"pass","deletedProjectRecords":2,...,"unrelatedKeyPreserved":true,...} ``` Stop the mock harness with `Ctrl+C` after the browser command finishes. ## Produced evidence -The browser run writes these local, ignored artifacts to `demo/output.local/`: +The browser run writes these local, ignored artifacts to the selected +`HARBOUR_SCREENSHOT_DIR`: - `01-concept.png`; - `02-world.png`; @@ -50,7 +52,13 @@ The browser run writes these local, ignored artifacts to `demo/output.local/`: - `04-continuity.png`; - `browser-smoke-story.storycore-harbour.json`. -The browser test validates the actual export blob against `storycore-harbour.project.v1` before writing it. It also proves form validation, save/read-back, keyboard step navigation, focus management, the declared 520 x 680 minimum viewport, and 400% text reflow. +Each PNG is captured at 900 x 820. The browser test resets the App scroll before +every capture and validates the actual export blob against +`storycore-harbour.project.v1` before writing it. It also proves form +validation, save/read-back, keyboard step navigation, focus management, +forced-colour states, the declared 520 x 680 minimum viewport, 400% text +reflow, and safe project deletion. Hashes may vary with the Edge build and font +renderer; use them as one-run evidence, not portable golden-image assertions. ## Presenter script diff --git a/apps/storycore-harbour/package-lock.json b/apps/storycore-harbour/package-lock.json index 22dd2b5f..096974a4 100644 --- a/apps/storycore-harbour/package-lock.json +++ b/apps/storycore-harbour/package-lock.json @@ -105,9 +105,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "dev": true, "funding": [ { diff --git a/apps/storycore-harbour/package.json b/apps/storycore-harbour/package.json index 1a8b13dd..a717772a 100644 --- a/apps/storycore-harbour/package.json +++ b/apps/storycore-harbour/package.json @@ -18,6 +18,9 @@ "test": "node --test tests/*.test.mjs", "browser:smoke": "node scripts/browser-smoke.mjs", "browser:deletion-smoke": "node scripts/browser-deletion-smoke.mjs", + "browser:check": "npm run browser:smoke && npm run browser:deletion-smoke", + "marketplace:logo": "node scripts/render-marketplace-logo.mjs", + "marketplace:logo:check": "node scripts/validate-marketplace-logo.mjs", "contract:check": "node scripts/validate-project.mjs examples/sample-project.json", "acceptance:sync": "node scripts/sync-acceptance-corpus.mjs", "acceptance:sync:check": "node scripts/check-bundled-corpus.mjs", diff --git a/apps/storycore-harbour/review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md b/apps/storycore-harbour/review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md new file mode 100644 index 00000000..a116ba23 --- /dev/null +++ b/apps/storycore-harbour/review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md @@ -0,0 +1,104 @@ +# Anna working-draft installation permissions report + +Prepared on 2026-08-29. An owner-approved combined support email was sent to +`hi@anna.partners` on 2026-09-05 with this installation question and the direct +structured-output question. No attachment or generated private result was sent. + +## Observed state + +- Developer Console: StoryCore Harbour working draft r12, bundle `ready`, no + immutable version history. +- Installed Apps: StoryCore Harbour is already present as `v0.0.0-dev`. +- Installed Apps > StoryCore Harbour > Permissions reports: + +```text +Failed to load permissions: App version not found +``` + +- CLI status independently reports App id 214, `draft`, unpublished, + `latest_version: null`, and `version_count: 0`. +- CLI versions independently returns an empty list. +- CLI grants returns `grants: null`, which in the pinned CLI means the grants + endpoint supplied no data; it does not independently prove grant or denial. + +## Interpretation boundary + +The verified facts show that a development installation record exists while +the permission-management surface cannot resolve an App version. This explains +why StoryCore appears installed but its expected LLM/storage grants cannot yet +be inspected through Installed Apps. + +It is reasonable to suspect a platform working-draft/version-resolution gap, +but the evidence does not prove whether the fix is to refresh **Install & +test**, create an immutable version, or repair the existing dev-install record +server-side. Do not cut `0.1.0`, reinstall, uninstall, or change permissions as +a diagnostic shortcut. + +## Public guidance checked on 2026-08-30 + +Current Anna guidance is not unambiguous enough to choose a destructive or +immutable workaround: + +- the beginner publishing guide describes a working draft as testable, but its + permission walkthrough says to cut a version and then install it; +- an Anna Forum team response separately instructs developers to install a + working draft from Developer Console; +- the beta.126 changelog calls `0.0.0-draft` a projection row and explicitly + excludes it from release-candidate selection. + +Sources: + +- +- +- + +This conflict reinforces the support question below. It does not justify +cutting `0.1.0` while StoryCore's measured reliability gate remains below +18/20. + +## Safety boundary + +- **Install & test** was not clicked again because StoryCore is already listed. +- The permission dialog was read but no control was changed or saved. +- No version was cut, no review was submitted, no App was removed, and no model + or storage quota was consumed. + +## Ready-to-send support question + +Sent in the combined 2026-09-05 support email. Do not send a duplicate while a +reply is pending. + +```text +Hi Anna team — StoryCore Harbour (App id 214) has a ready working draft at r12 +and is already listed in Installed Apps as v0.0.0-dev. However, opening its +Permissions panel returns “Failed to load permissions: App version not found”. + +The Developer Console and pinned CLI both confirm that the App is a draft with +zero immutable versions. Could you confirm the intended permissions path for a +working-draft Install & test record? + +1. Should a v0.0.0-dev installation resolve permissions directly from the + current working draft? +2. Is Install & test expected to refresh an existing dev-install record? +3. Is an immutable cut required for permission management, despite the + working-draft Install & test action? +4. If this is a stale dev-install record, can it be repaired server-side + without recreating the App or losing the reserved slug? + +We have not reinstalled, uninstalled, changed grants, cut a version, submitted +review, or published anything. +``` + +## 2026-09-05 CLI 0.1.51 read-only check + +Anna CLI 0.1.51 adds a richer `apps grants` reader that first queries the App +permissions endpoint and normally reports `satisfied`, `missing`, and bundled +Executa grants. Running that command ephemerally against StoryCore Harbour still +returned only `grants: null`, with none of those permissions fields. This is +consistent with the Console's `App version not found` result: the improved CLI +cannot resolve permissions for the existing `v0.0.0-dev` record either. + +The CLI was not added to the project, no grant was changed, and the temporary +package inspection directory was removed. Version 0.1.51 does not provide a +grant mutation command; the dashboard remains the documented permission-change +surface. diff --git a/apps/storycore-harbour/review/ANNA_MEDIA_REQUIREMENTS_REQUEST.md b/apps/storycore-harbour/review/ANNA_MEDIA_REQUIREMENTS_REQUEST.md new file mode 100644 index 00000000..2431f2f6 --- /dev/null +++ b/apps/storycore-harbour/review/ANNA_MEDIA_REQUIREMENTS_REQUEST.md @@ -0,0 +1,61 @@ +# Anna Marketplace media requirements request + +Prepared on 2026-08-29. This is a support-message draft, not a submission or +authorization to upload files. + +## Verified context + +- Anna's public App Manifest documentation says listing metadata, logos, and + screenshots are managed in the Developer Console Listing tab. +- The authenticated Listing inspected on 2026-08-29 accepts PNG/JPG/WebP/GIF + logos up to 2MB and states that they are cropped to 256 x 256. +- Screenshots are entered as one URL per line. The Listing exposes no visible + screenshot count, dimension, aspect-ratio, format, or byte limit, and the + textarea has no corresponding HTML constraint attributes. +- StoryCore Harbour currently has four deterministic fictional PNG drafts at + 900 x 820. Their largest file is 105,664 bytes. +- The draft is App id 214, slug `storycore-harbour`, working revision 11, with + zero immutable versions and no public release. + +Official pages checked: + +- +- + +## Ready-to-send support message + +```text +Hi Anna team — we are preparing the first review package for StoryCore Harbour +(@storycore-labs/storycore-harbour), a Schema 2 Host-API-only App with no +Executa. + +Could you confirm the current Marketplace media requirements shown to reviewers +and developers? + +1. required screenshot count and accepted formats; +2. exact pixel dimensions or aspect-ratio range; +3. maximum bytes per screenshot and whether Anna fetches each URL at review or + requires a long-lived public asset URL; +4. any screenshot safe-area, rounded-corner, text-overlay, localization, or dark/light + theme requirements; +5. whether screenshots from the local Anna harness are acceptable when they + contain only fictional data and accurately represent the submitted bundle. + +The Listing already confirms that logos may be PNG/JPG/WebP/GIF up to 2MB and +are cropped to 256 x 256. Our prepared PNG logo is 256 x 256 and 5,302 bytes. + +Our current screenshot drafts are four PNG files at 900 x 820, each below 106 KB. They show +Concept, World/production bible, Scenes/shots, and Continuity/export. They do not +show account identifiers, hidden acceptance controls, provider metadata, or +real user content. + +We will not treat these drafts as final until the current requirements are +confirmed. Thank you. +``` + +## Owner action + +The authenticated Listing constraints above have been inspected. The owner may +send the remaining screenshot question through Anna's official support channel. Do not +upload assets, submit a review, accept terms, or make a public post without the +owner's explicit approval at the time of the action. diff --git a/apps/storycore-harbour/review/ANNA_STRUCTURED_OUTPUT_REQUEST.md b/apps/storycore-harbour/review/ANNA_STRUCTURED_OUTPUT_REQUEST.md new file mode 100644 index 00000000..ad12b3d7 --- /dev/null +++ b/apps/storycore-harbour/review/ANNA_STRUCTURED_OUTPUT_REQUEST.md @@ -0,0 +1,104 @@ +# Anna direct Host LLM structured-output request + +Prepared on 2026-09-01. An owner-approved combined support email was sent to +`hi@anna.partners` on 2026-09-05 with this question and the working-draft +permission issue. No attachment or generated private result was sent, and no +model call was made for this investigation. + +## StoryCore evidence + +- StoryCore Harbour is a Schema 2, Host-API-only App with no Executa. +- Its current generation path is `anna.llm.complete` from the iframe bundle. +- The 2026-08-31 Gemma corpus measured 15/20. Four final repair responses were + complete, non-truncated, and ended with `}`, but contained internal JSON + syntax errors. +- A punctuation-only offline search could recover only two cases, projecting + 17/20; it does not justify a permissive production parser. + +## Verified Anna surfaces + +Anna supports structured output on Executa reverse sampling: + +- `sampling/createMessage` accepts `responseFormat` with `json_object` or + `json_schema` plus `onUnsupported`; +- the real bridge sends the negotiated value as `response_format` to the + platform completion endpoint; +- CLI structured-output emulation exists for the sampling development path. + +This support is not currently documented or typed for the iframe Host API +`anna.llm.complete`. Its published signature lists messages, `maxTokens`, +`modelPreferences`, `systemPrompt`, temperature, stop sequences, and metadata, +but no response-format field. + +Local package inspection confirms the same boundary: + +- pinned CLI 0.1.30 contains no `responseFormat`, `response_format`, or + `onUnsupported` implementation; +- latest npm CLI 0.1.49 adds structured negotiation to its sampling bridge; +- CLI 0.1.49 still exposes no response-format field for direct Host + `llm.complete`. + +Moving StoryCore generation into an Executa only to obtain JSON Schema would +add a backend/distribution/permission boundary and violate the MVP's intended +Host-API-only architecture. Do not make that change without explicit product +and architecture approval. + +## Current platform caution + +The Anna Forum currently reports a Cloud Agent failure for `json_schema` even +with `onUnsupported=json_object`. Structured sampling is therefore not yet a +drop-in reliability proof for StoryCore. + +Sources checked: + +- +- +- +- + +## Ready-to-send question + +Sent in the combined 2026-09-05 support email. Do not send a duplicate while a +reply is pending. + +```text +Hi Anna team — StoryCore Harbour is a Schema 2 Host-API-only App using direct +iframe anna.llm.complete, with no Executa. Our latest fixed Gemma corpus is +15/20; four failed repairs are complete/non-truncated responses with internal +JSON syntax errors. + +The current sampling/createMessage path supports responseFormat +(json_object/json_schema) and onUnsupported, but the documented direct +anna.llm.complete signature and current CLI types do not expose those fields. + +1. Does direct iframe anna.llm.complete currently accept responseFormat or + response_format in production? +2. If yes, what exact wire shape and capability-negotiation response should a + Schema 2 App use? +3. Is json_object supported for gemma-4-E4B-it/Runpod through the App-complete + path? +4. Can unsupported structured output fail explicitly without silently falling + back to prompt-only text? +5. Is there a recommended Host-API-only example or minimum runtime/CLI version? + +We will not add an Executa, send another full corpus, or claim reliability from +an undocumented field. With confirmation, we can run one owner-authorized +single-prompt probe before considering any code change. +``` + +## Probe gate + +Do not add an undocumented field to production or consume quota until Anna +confirms the direct Host API contract. If confirmed, implement the smallest +adapter-only opt-in and run one previously failing prompt first. A complete +corpus requires separate owner quota approval after that pilot succeeds. + +## 2026-09-05 refresh + +No newer public Anna reference or forum answer was found that adds +`responseFormat` to direct iframe `anna.llm.complete`. Anna CLI 0.1.51 was +inspected without installation into the project. Its real sampling bridge +negotiates `responseFormat` and forwards `response_format`, but its direct App +LLM bridge still exposes no structured-output contract. Do not upgrade the +pinned CLI merely for this issue and do not send an undocumented field to the +production endpoint. diff --git a/apps/storycore-harbour/review/ANNA_VIEW_MANIFEST_CREDENTIAL_REPORT.md b/apps/storycore-harbour/review/ANNA_VIEW_MANIFEST_CREDENTIAL_REPORT.md new file mode 100644 index 00000000..2d74cefe --- /dev/null +++ b/apps/storycore-harbour/review/ANNA_VIEW_MANIFEST_CREDENTIAL_REPORT.md @@ -0,0 +1,105 @@ +# Anna Developer Console credential-report draft + +Prepared on 2026-08-29. This is a private support-report draft; it has not been +sent or posted. + +## Resolution update + +After the owner completed a fresh Anna sign-in and returned through the +Dashboard, **View manifest** succeeded in the same in-app browser. It displayed +working draft r12 and the normalized remote manifest. The earlier failure is +therefore resolved operationally by reauthentication, although the exact token +or frontend validation mechanism remains unproven. + +The remote manifest confirms: + +- schema 2; +- no required or optional Executas; +- no top-level permissions; +- no external bundle origins; +- one desktop view with minimum 520 x 680 and default 900 x 820; +- `llm.complete` only in the LLM namespace; +- App storage `get`, `set`, `list`, and `delete`; +- `window.set_title`; +- CSP `script-src 'self'` and `last_writer_wins` state merge. + +These boundaries match the committed Host-API-only adapter. The support message +below should now be sent only if the credential error recurs after a fresh +login; it is retained as a reproducible diagnostic template. + +## Summary + +The authenticated Anna Developer Console can list StoryCore Harbour and show +its working draft, but the read-only **View manifest** action reports: + +```text +Could not validate credentials +``` + +## Reproduction + +1. Sign in to Anna and complete workspace onboarding. +2. Open Developer Console. +3. Open StoryCore Harbour, App id 214. +4. Open **Versions**. +5. Confirm the working draft shows revision r12, bundle `ready`, and content + hash prefix `ad383957f123…`. +6. Click **View manifest**. +7. Observe `Could not validate credentials`; no manifest modal/content appears. + +## Independent evidence + +- `anna-app apps status storycore-harbour --json` succeeds with the same owner + account and reports `draft`, unpublished, zero versions. +- `anna-app apps versions storycore-harbour --json` succeeds and returns an + empty immutable-version list. +- Working draft revision 12 was uploaded with the pinned Node 22-compatible + CLI flow and bundle status `ready`. +- Local strict validation, canonical contract, mock fixture, fixed corpus, + browser flow, and deletion flow pass. +- The Console can read the App list, Listing, working revision, bundle status, + Settings, and version history in the same browser session. + +## Instrumentation result + +One instrumented reproduction was performed after enabling browser network +observation. The UI reproduced the same message, but the available event buffer +and console logs exposed no request URL or HTTP status. Do not claim whether +the failure occurs before the request, in an unobserved request, or in response +handling without server/frontend evidence. + +## Expected behavior + +**View manifest** should display the immutable snapshot of the current working +manifest, or return an actionable authentication/authorization error that +identifies which owner/developer credential must be refreshed. + +## Safety and impact + +- No manifest, Listing field, App permission, installation, or version was + changed during reproduction. +- **Install & test**, **Cut version**, **Submit now**, **Discard**, and deletion + actions were not used. +- This blocks a web-console manifest comparison and reduces confidence in the + new working-draft install path. It does not prove that the uploaded manifest + or bundle is invalid. + +## Ready-to-send question + +```text +Hi Anna team — the authenticated Developer Console lists StoryCore Harbour +(App id 214) and shows working draft r12 with bundle ready, but Versions > View +manifest returns “Could not validate credentials”. The pinned CLI can read the +same App status and versions successfully, and strict local validation passes. + +Could you confirm which web credential or endpoint View manifest requires, and +whether refreshing that credential is also required before using the new +working-draft “Install & test” action? We have not clicked Install & test, cut a +version, submitted review, or changed permissions. +``` + +## Closure boundary + +This resolution does not authorize **Install & test**, enable Host API grants, +cut a version, spend model quota, submit review, merge, or publish. It only +closes the read-only remote-manifest comparison gate. diff --git a/apps/storycore-harbour/review/FINAL_HANDOFF_2026-08-24.md b/apps/storycore-harbour/review/FINAL_HANDOFF_2026-08-24.md index fec6a12a..db0d6998 100644 --- a/apps/storycore-harbour/review/FINAL_HANDOFF_2026-08-24.md +++ b/apps/storycore-harbour/review/FINAL_HANDOFF_2026-08-24.md @@ -1,5 +1,7 @@ # Anna opportunity final handoff — 24 August 2026 +Updated with verified remote state on 28 August 2026. + This document records the verified draft state. It is not authorization to cut a version, submit a review, publish, accept new terms, or spend additional model quota. ## StoryCore Harbour @@ -7,19 +9,19 @@ This document records the verified draft state. It is not authorization to cut a ### Local candidate - branch: `codex/anna-mvp-20260824`; -- latest integration commit before this handoff refresh: `ec2d9da7`; -- GitHub branch `agent/storycore-harbour-bootstrap` was updated through PR #30 without force-push; the PR remains draft and unmerged; +- latest integration commit at this handoff refresh: `03c2c54b`; +- GitHub branch `agent/storycore-harbour-bootstrap` is synchronized through draft PR #37 without force-push; PR #30 and PR #36 are merged, while PR #37 remains open, green, and unmerged; - core StoryCore Engine checkout was not merged, reset, cleaned, or overwritten; - Anna adapter remains isolated under `apps/storycore-harbour/`; -- automated gate: 60/60 Node tests, sample contract, mock fixture, fixed corpus, bundle synchronization, and strict Anna validation pass. +- automated gate: 66/66 Node tests, sample contract, mock fixture, fixed corpus, bundle synchronization, strict Anna validation, GitHub Anna CI, and SonarQube pass. ### Anna draft - public identity: `@storycore-labs/storycore-harbour`; - server App id: `214`; -- working revision: `9`; -- content hash: `762e175e7f150d47845b3fa4ace51d2f058d1758cc4c19f06d621b9756dada74`; -- bundle: 15 files, 103,405 bytes, `ready`; +- working revision: `10`; +- content hash: `7d3edf2a89b942055c4af9d53b7dcfb6de1777e0437b27711b05bef09e2049b9`; +- bundle: 15 files, 104,031 bytes, `ready`; - status: `draft`, not published; - immutable versions: 0; - Executas/local shims: 0. @@ -99,7 +101,13 @@ without addressing the failure. ### Submission decision -Do not cut `0.1.0`, submit for review, mark PR #30 ready, merge, or release while the official gate remains below 18/20. The App is demonstrable and its working draft is reserved, but it is not submission-ready under the repository's own rules. +Do not cut `0.1.0`, submit for review, mark PR #37 ready, merge, or release while the official gate remains below 18/20. The latest measured Gemma run is 15/20 and the latest Anna-default run is 6/20. The App is demonstrable and its working draft is reserved, but it is not submission-ready under the repository's own rules. + +The owner-authorized 2026-08-31 Gemma rerun after the schema-complete repair +also measured 15/20 (median 23.90 seconds, p95 44.00 seconds, 7 repaired +passes). A01 failed at transport; A06/A08/A15/A20 returned complete but +syntactically invalid JSON. This supersedes the 27 August Gemma run as the +latest real evidence without changing the submission decision. ### Anna installation diagnosis @@ -109,6 +117,22 @@ Do not cut `0.1.0`, submit for review, mark PR #30 ready, merge, or release whil - cutting `0.1.0` would create that immutable version but must not be used as a workaround while the 18/20 reliability gate still fails; - the Console web session also expired on reload and redirected to login, which is a separate authentication condition rather than the original installation cause. +Update from 2026-08-29: the current Console now exposes a separate **Install & +test** button inside the working-draft Versions tab at revision 12, while CLI +status still reports zero immutable versions. This may provide a draft-testing +path without cutting `0.1.0`, but it was not clicked because installation and +permission prompts require explicit owner approval. **View manifest** returned +`Could not validate credentials`; therefore the web-session credential path +must be healthy before any installation result can be claimed. + +Second update from 2026-08-29: after reauthentication, **View manifest** works +and matches the committed Host-API-only boundaries. Installed Apps already +contains StoryCore Harbour as `v0.0.0-dev`, so another installation was not +attempted. Its Permissions panel fails with `App version not found`, while CLI +status still confirms zero immutable versions. The current blocker is therefore +permission resolution for the existing development installation, not absence +of an Installed Apps record. See `ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md`. + ## AIMesher Anna App ### Local candidate diff --git a/apps/storycore-harbour/review/LAUNCH_CHECKLIST.md b/apps/storycore-harbour/review/LAUNCH_CHECKLIST.md index 5ffd0577..e2a97625 100644 --- a/apps/storycore-harbour/review/LAUNCH_CHECKLIST.md +++ b/apps/storycore-harbour/review/LAUNCH_CHECKLIST.md @@ -31,12 +31,17 @@ This checklist is a release gate. A checked implementation item does not overrid - [x] Manifest declares only LLM, App storage, and window-title Host APIs. - [x] Undeclared tool invocation is denied in the official test harness. - [x] Mock Anna harness starts in CI. +- [x] Authenticated Console View manifest matches the committed Schema 2 Host-API-only boundaries at r12. - [x] Browser flow runs inside the Anna harness at 520 × 680. - [ ] Production Host API handshake verified in the target Anna account. - [ ] Production App storage write/read/reload verified. - [ ] Production ETag conflict induced and safely rejected. - [ ] Qualified App MAU definition and dashboard visibility confirmed. - [ ] Host-API-only completion confirmed as eligible without local runtime installation. +- [ ] Owner-approved working-draft **Install & test** completes from revision 12 without cutting a version. +- [ ] Installed Apps exposes only the expected LLM, App storage, and window capabilities with no Executa. +- [x] Existing Installed Apps record identified as StoryCore Harbour `v0.0.0-dev`. +- [ ] Resolve `App version not found` when opening permissions for the existing dev installation. ## Reliability and CI diff --git a/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md b/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md index 3a571035..7c92f140 100644 --- a/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md +++ b/apps/storycore-harbour/review/MVP_ROADMAP_2026-08-24.md @@ -88,3 +88,11 @@ Do not replace production-platform gates with these local results. - User control complete: the Anna adapter offers an optional validated model hint and otherwise preserves the Anna default. It has 65-test, strict-validation, end-to-end Edge, and deletion/storage-preservation evidence. - Complete Gemma-preference corpus: 14/20, median 21.67 seconds, p95 39.78 seconds, 6 repaired passes. It is faster and avoids MiniMax truncation but creates six schema/reference failures. Keep this score separate from Anna-default 16/20; neither satisfies readiness. - Post-run exact replay: canonicalizing warning severity `minor→info` and string scene reference `"null"→null` makes the measured A02/A10 outputs valid, projecting 16/20 without weakening any structural rule. The measured score remains 14/20 until a real rerun; the four remaining outputs are genuinely malformed or structurally empty. +- Rejected experiment: Anna-default primary plus Gemma-only repair scored 2/4 on A02/A07/A15/A18, below Gemma-only 3/4. The diagnostic code was removed; do not add separate repair-model complexity without new platform evidence. +- A06 reproduction: one missing final brace was not the root cause. Both the completed primary object and the syntactically valid repair lacked the required creative structure. Keep fail-closed validation; do not synthesize characters, locations, scenes, or continuity data in the parser. +- Third-model probe blocked: hint `gpt-4o` returned no response metadata and remained pending beyond the App timeout. Treat it as a platform cancellation/grant issue, not model evidence; no further blind hint probes. +- 27 August default-model rerun: the complete immutable corpus used `minimax/minimax-m3` through OpenRouter and finished at 6/20, median 49.13 seconds, p95 57.87 seconds, and 2 repaired passes. The privacy-safe failure matrix was eight `json_invalid`, five `contract_invalid`, and one timeout. The ignored private JSONL is `acceptance/results.2026-08-27.local.jsonl`. This is a measured stochastic regression from the earlier 16/20 default run, so readiness remains blocked and no parser relaxation, immutable version, review submission, or release is justified. +- 27 August Gemma rerun: the complete immutable corpus with advisory hint `gemma` finished at 15/20, median 21.91 seconds, p95 41.92 seconds, and 6 repaired passes. Failures were A07/A10/A12/A19 `contract_invalid` and A20 `required_field_invalid`, with no timeout or JSON truncation. The ignored private JSONL is `acceptance/results.2026-08-27.gemma.local.jsonl`. This improves the measured Gemma profile from 14/20 to 15/20 but remains below the 18/20 gate; keep the PR draft and do not cut, submit, or release a version. +- 31 August post-repair Gemma rerun: the complete connected corpus again finished at 15/20, median 23.90 seconds, p95 44.00 seconds, and 7 repaired passes. A01 failed at transport; A06/A08/A15/A20 were `json_invalid` after complete, non-truncated Gemma/Runpod responses. The schema-complete repair changed which prompts pass and increased repaired passes, but did not improve the total score. The private result was canonically re-evaluated from an RPC-log recovery because the iframe download was not surfaced to automation. Keep the release gate failed and do not fund another blind rerun without a measured syntax intervention or platform/model change. +- Offline syntax experiment: a maximum-three-edit punctuation search recovered only A08/A15, projecting 17/20. A06 had no valid candidate; structurally closing A20 still left five contract failures. Do not add a general JSON-repair dependency or permissive parser from this evidence. Production remains fail-closed; the next useful evidence requires a model/platform change or a specifically measured generation constraint, not another blind corpus run. +- Structured-output boundary: Anna's JSON Schema support is currently verified for Executa sampling, not the direct iframe `anna.llm.complete` path used by StoryCore. CLI 0.1.49 still confines response-format negotiation to sampling, and current Cloud Agent reports show structured fallback failures. Keep StoryCore Host-API-only; ask Anna for the direct contract, then permit at most one failing-prompt pilot before any adapter change or full rerun. diff --git a/apps/storycore-harbour/review/OWNER_ACTIVATION_AND_FIRST_REVIEW.md b/apps/storycore-harbour/review/OWNER_ACTIVATION_AND_FIRST_REVIEW.md index de345d56..c51c890c 100644 --- a/apps/storycore-harbour/review/OWNER_ACTIVATION_AND_FIRST_REVIEW.md +++ b/apps/storycore-harbour/review/OWNER_ACTIVATION_AND_FIRST_REVIEW.md @@ -173,10 +173,18 @@ Only after checking the dry-run output: npx --no-install anna-app apps push --json npx --no-install anna-app apps status storycore-harbour --json npx --no-install anna-app apps list --json +npx --no-install anna-app apps grants storycore-harbour --json ``` `apps push` creates or updates a mutable **working draft**. It is not a public release and should not make the App visible in the public Store. +The grants endpoint is informational only. With the currently pinned CLI, a +JSON result containing `"grants": null` means the server returned 404 for the +public grants endpoint and the CLI has no endpoint data available. It does not +prove that the App was denied the Host APIs declared in the manifest. Confirm +actual LLM, storage, and window capabilities through the authenticated harness +and recorded Host calls; do not infer permission state from `null`. + After the first successful push: - verify that `.anna/app.json` identifies the expected remote App; @@ -184,6 +192,38 @@ After the first successful push: - open the Developer Console and confirm the App is shown as a working/draft App; - confirm the locked slug is exactly `storycore-harbour`. +### Working-draft installation path + +The authenticated Developer Console inspected on 2026-08-29 exposes an +**Install & test** action inside the **Versions** tab for the mutable working +draft, even while the App has zero immutable versions. This is distinct from +the App-list **Install** action that previously failed with “no available +published version”. Do not cut `0.1.0` merely to discover whether the current +working-draft installation path is usable. + +Installing changes the owner's Installed Apps state and may open Host API +permission controls. Use **Install & test** only after explicit owner approval +at action time, then verify the exact App id, working revision, requested +capabilities, and absence of unexpected Executas before confirming anything. + +During the same inspection, **View manifest** returned `Could not validate +credentials` although the Console displayed working revision 12 and the CLI +independently confirmed the draft. Treat that as a web-session/platform +credential condition, not as evidence that the uploaded manifest or bundle is +invalid. Reauthenticate or ask Anna support rather than recreating the App. + +A fresh owner sign-in subsequently restored **View manifest**. The normalized +remote r12 manifest matched the committed Schema 2 Host-API-only boundaries. +Keep `review/ANNA_VIEW_MANIFEST_CREDENTIAL_REPORT.md` as the recurrence report; +do not send it while the read path remains healthy. + +Installed Apps inspection then showed StoryCore Harbour already present as +`v0.0.0-dev`. Do not click **Install & test** again merely because the +Developer card remains at `v0.0.0`. The existing installation's Permissions +panel currently returns `App version not found`; stop there and use +`review/ANNA_DEV_INSTALL_PERMISSIONS_REPORT.md` rather than reinstalling, +uninstalling, cutting a version, or changing grants speculatively. + If the CLI or Console creates an unexpected second App, stop before cutting a version. Preserve the outputs needed for diagnosis, but redact tokens. ## 6. Run StoryCore Harbour against real Anna services diff --git a/apps/storycore-harbour/review/SONAR_SECURITY_FOLLOWUP_2026-08-30.md b/apps/storycore-harbour/review/SONAR_SECURITY_FOLLOWUP_2026-08-30.md new file mode 100644 index 00000000..183b0b72 --- /dev/null +++ b/apps/storycore-harbour/review/SONAR_SECURITY_FOLLOWUP_2026-08-30.md @@ -0,0 +1,79 @@ +# Sonar security follow-up — 2026-08-30 + +## Result + +The StoryCore Harbour security gate is restored. + +After the Marketplace-logo helper hardening, SonarQube Cloud completed a fresh analysis on PR #37 and reported: + +- **Quality Gate passed**; +- **0 New issues**; +- **0 Accepted issues**; +- **0 Security Hotspots**. + +This supersedes the earlier C Security Rating result and the intervening Automatic Analysis failures. No issue was suppressed or accepted and the Quality Gate was not lowered. + +## Context + +Draft PR #37 had previously passed the StoryCore Harbour functional CI while SonarQube Cloud reported a **C Security Rating on New Code** with two annotations. The regression appeared after the Marketplace-logo helper scripts were added. + +The two helper surfaces were then hardened. Several intervening Sonar Automatic Analysis attempts reported only `The last analysis has failed`; those attempts were treated as inconclusive rather than as evidence of recovery. + +## Bounded remediation + +The two new Marketplace-logo scripts were hardened without broadening App permissions or runtime capabilities. + +### Renderer + +`./scripts/render-marketplace-logo.mjs` + +- no longer accepts CLI-supplied input or output paths; +- derives the App root from `import.meta.url`; +- reads only committed `bundle/icon.svg`; +- writes only `review/marketplace-media/storycore-harbour-logo-256.png`; +- no longer interpolates the SVG data URL into an HTML template; +- assigns the fixed local source as the image `src` property; +- no longer prints local filesystem paths in its result log. + +### Validator + +`./scripts/validate-marketplace-logo.mjs` + +- no longer accepts a CLI-supplied logo path; +- reads only the committed StoryCore Harbour Marketplace PNG; +- checks PNG signature, IHDR, 256 × 256 dimensions, the 2 MB limit, and representative pixels; +- no longer prints the local filesystem path. + +### Regression coverage + +`tests/marketplace-logo.test.mjs` independently verifies that the committed asset: + +- is a PNG; +- begins with IHDR; +- is exactly 256 × 256; +- is non-empty; +- remains below 2 MB. + +StoryCore Harbour CI run #136 is green after the final evidence update. + +## Sonar service evidence during diagnosis + +Public Sonar Community reports dated 2026-08-28 and 2026-08-29 described contemporaneous SonarQube Cloud Automatic Analysis failures with the same broad symptom seen during the intervening attempts: ordinary CI remained green while Automatic Analysis reported `The last analysis has failed`, and one report described no Compute Engine task being created. + +Relevant public reports included: + +- `Automatic Analysis silently failing since 2026-08-28 — failing analysis ID provided` (2026-08-29); +- `SonarQube Cloud Automatic Analysis queues GitHub checks but starts no CE task for PR #8` (2026-08-28); +- `New repo, Error: The last analysis has failed` (2026-08-28). + +Those reports remain corroborating evidence that the intervening analysis failures may have involved a service-side problem. They are not needed to justify the final result because a subsequent completed StoryCore analysis now passes. + +## Evidence boundary + +The available connector did not expose the detailed text of the original two Sonar annotations, so this report does not claim a proven one-to-one mapping between those annotations and the two path flows. The remediation was the smallest security-oriented change to the executable code introduced in the same change window, and the completed post-remediation Sonar analysis now reports a clean Quality Gate. + +## Current gate + +The Sonar security blocker is closed for the current PR state. It must reopen automatically if a later Harbour change produces a failed or missing required Sonar Quality Gate. + +This does **not** close StoryCore Harbour's separate real-model reliability gate. The immutable acceptance target remains at least 18/20 with median successful completion at or below 180 seconds. diff --git a/apps/storycore-harbour/review/SUBMISSION_BRIEF.md b/apps/storycore-harbour/review/SUBMISSION_BRIEF.md index 5a38193f..e151b526 100644 --- a/apps/storycore-harbour/review/SUBMISSION_BRIEF.md +++ b/apps/storycore-harbour/review/SUBMISSION_BRIEF.md @@ -23,6 +23,17 @@ StoryCore Harbour turns a concept, synopsis, or short script into a coherent vis The deterministic browser demo produces draft versions as `01-concept.png` through `04-continuity.png`. Confirm Anna's exact dimensions and file limits before treating them as final Marketplace assets. +The current revision 11 drafts are 900 x 820 PNG files, each below 106 KB. +Public Anna documentation reviewed on 2026-08-29 did not expose numeric listing +media limits. Use `ANNA_MEDIA_REQUIREMENTS_REQUEST.md` for the prepared support +question; do not upload or submit the drafts until those requirements are +confirmed. + +The authenticated Listing subsequently confirmed PNG/JPG/WebP/GIF logos up to +2MB, cropped to 256 x 256. The validated 5,302-byte candidate is +`marketplace-media/storycore-harbour-logo-256.png`. Screenshot requirements remain the +unconfirmed part of the media gate. + ## Demonstration procedure Follow `../demo/README.md`. The expected reviewer-visible flow takes less than five minutes after dependencies are installed: diff --git a/apps/storycore-harbour/review/marketplace-media/README.md b/apps/storycore-harbour/review/marketplace-media/README.md new file mode 100644 index 00000000..6ef90b85 --- /dev/null +++ b/apps/storycore-harbour/review/marketplace-media/README.md @@ -0,0 +1,26 @@ +# StoryCore Harbour Marketplace media + +## Logo candidate + +- file: `storycore-harbour-logo-256.png`; +- source: `../../bundle/icon.svg`; +- format and dimensions: PNG, 256 x 256; +- size: 5,302 bytes, below Anna's visible 2MB maximum; +- SHA-256: `4a54e1955ac5ebe67eef8c82a260b1b1cdc351a7e87902f2bba2b15532cea7dd`. + +The authenticated Anna Listing inspected on 2026-08-29 accepts PNG, JPG, +WebP, or GIF logos up to 2MB and states that they are cropped to 256 x 256. +This asset is generated from the committed SVG without changing the product +identity. + +Reproduce and validate on Windows with Edge: + +```powershell +$env:BROWSER_EXECUTABLE = 'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe' +npm run marketplace:logo +npm run marketplace:logo:check +``` + +The validator checks PNG signature, exact dimensions, maximum bytes, and +representative gold, white, red, and cyan pixels. Do not upload the file or +save Listing changes without explicit owner approval at action time. diff --git a/apps/storycore-harbour/review/marketplace-media/storycore-harbour-logo-256.png b/apps/storycore-harbour/review/marketplace-media/storycore-harbour-logo-256.png new file mode 100644 index 00000000..3644aa04 Binary files /dev/null and b/apps/storycore-harbour/review/marketplace-media/storycore-harbour-logo-256.png differ diff --git a/apps/storycore-harbour/scripts/browser-smoke.mjs b/apps/storycore-harbour/scripts/browser-smoke.mjs index 8f574340..5e743258 100644 --- a/apps/storycore-harbour/scripts/browser-smoke.mjs +++ b/apps/storycore-harbour/scripts/browser-smoke.mjs @@ -94,6 +94,7 @@ try { await waitForFocus(app.locator("#form-error")); assert.equal(await app.locator("#step-1").isVisible(), true); assert.equal(await app.locator("#step-2").isVisible(), false); + await assertForcedColorsAccessibility(page, app); await fillReferenceProject(app); assert.match((await app.locator("#idea-count").textContent()) || "", /\/ 12,000/); @@ -254,6 +255,7 @@ try { result: "pass", viewport: { width: dimensions.clientWidth, height: minimumFrameSize.height }, textReflow400Percent: "pass", + forcedColors: "pass", formValidationFocus: "pass", keyboardStepNavigation: "pass", panelFocusManagement: "pass", @@ -289,7 +291,10 @@ async function captureMarketplaceScreenshot({ app, frameElement, filename }) { await setFrameSize(frameElement, marketplaceFrameSize); await app.locator("html").evaluate(() => { if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); - window.scrollTo({ top: 0, behavior: "instant" }); + window.scrollTo(0, 0); + if (document.scrollingElement) document.scrollingElement.scrollTop = 0; + document.documentElement.scrollTop = 0; + document.body.scrollTop = 0; }); const path = join(screenshotDirectory, filename); @@ -316,6 +321,43 @@ async function setFrameSize(frameElement, size) { }, size); } +async function assertForcedColorsAccessibility(page, app) { + await page.emulateMedia({ forcedColors: "active" }); + try { + await app.getByRole("button", { name: "Build my visual story" }).focus(); + const state = await app.locator("html").evaluate(() => { + const style = (selector) => getComputedStyle(document.querySelector(selector)); + const activeStep = style(".step.active"); + const disabledStep = style("#step-tab-2"); + const error = style("#form-error"); + const focusedButton = style("#generate-button"); + return { + mediaActive: matchMedia("(forced-colors: active)").matches, + activeOutlineStyle: activeStep.outlineStyle, + activeOutlineWidth: activeStep.outlineWidth, + disabledOpacity: disabledStep.opacity, + disabledBorderStyle: disabledStep.borderTopStyle, + errorBorderStyle: error.borderTopStyle, + errorBorderWidth: error.borderTopWidth, + focusOutlineStyle: focusedButton.outlineStyle, + focusOutlineWidth: focusedButton.outlineWidth, + }; + }); + + assert.equal(state.mediaActive, true, "Edge must activate the forced-colours media query."); + assert.equal(state.activeOutlineStyle, "solid", "The selected step needs a non-colour outline."); + assert.equal(state.activeOutlineWidth, "2px"); + assert.equal(state.disabledOpacity, "1", "Disabled controls must remain legible in forced colours."); + assert.equal(state.disabledBorderStyle, "dashed", "Disabled controls need a non-colour distinction."); + assert.equal(state.errorBorderStyle, "solid", "Errors need a visible forced-colour boundary."); + assert.equal(state.errorBorderWidth, "2px"); + assert.equal(state.focusOutlineStyle, "solid", "Keyboard focus must remain visible in forced colours."); + assert.equal(state.focusOutlineWidth, "3px"); + } finally { + await page.emulateMedia({ forcedColors: "none" }); + } +} + async function installDeterministicTestStorage(app) { return app.locator("html").evaluate(() => { const runtime = window.anna; diff --git a/apps/storycore-harbour/scripts/render-marketplace-logo.mjs b/apps/storycore-harbour/scripts/render-marketplace-logo.mjs new file mode 100644 index 00000000..adb34b74 --- /dev/null +++ b/apps/storycore-harbour/scripts/render-marketplace-logo.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +import { mkdir, readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { resolve } from "node:path"; +import { chromium } from "playwright-core"; + +const APP_ROOT = fileURLToPath(new URL("../", import.meta.url)); +const inputPath = resolve(APP_ROOT, "bundle/icon.svg"); +const outputDirectory = resolve(APP_ROOT, "review/marketplace-media"); +const outputPath = resolve(outputDirectory, "storycore-harbour-logo-256.png"); +const executablePath = process.env.BROWSER_EXECUTABLE; + +if (!executablePath) { + console.error("BROWSER_EXECUTABLE is required to render the Marketplace logo."); + process.exit(2); +} + +await mkdir(outputDirectory, { recursive: true }); +const browser = await chromium.launch({ + executablePath, + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage"], +}); + +try { + const page = await browser.newPage({ viewport: { width: 256, height: 256 } }); + const source = await readFile(inputPath); + const sourceUrl = `data:image/svg+xml;base64,${source.toString("base64")}`; + await page.setContent(` + + + `); + const logo = page.locator("#marketplace-logo"); + await logo.evaluate((image, src) => { + image.src = src; + }, sourceUrl); + await logo.waitFor({ state: "visible" }); + await logo.evaluate(async (image) => { + await image.decode(); + if (!image.complete || image.naturalWidth <= 0 || image.naturalHeight <= 0) { + throw new Error("The source logo did not decode before capture."); + } + }); + await logo.screenshot({ path: outputPath, omitBackground: true }); + console.log(JSON.stringify({ result: "pass", width: 256, height: 256 })); +} finally { + await browser.close(); +} diff --git a/apps/storycore-harbour/scripts/validate-marketplace-logo.mjs b/apps/storycore-harbour/scripts/validate-marketplace-logo.mjs new file mode 100644 index 00000000..e2909254 --- /dev/null +++ b/apps/storycore-harbour/scripts/validate-marketplace-logo.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { chromium } from "playwright-core"; + +const APP_ROOT = fileURLToPath(new URL("../", import.meta.url)); +const logoPath = resolve(APP_ROOT, "review/marketplace-media/storycore-harbour-logo-256.png"); +const executablePath = process.env.BROWSER_EXECUTABLE; +const bytes = await readFile(logoPath); +const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + +if (!executablePath) { + console.error("BROWSER_EXECUTABLE is required to inspect the rendered Marketplace logo."); + process.exit(2); +} + +assert.ok(bytes.subarray(0, 8).equals(pngSignature), "Marketplace logo must be a PNG file."); +assert.equal(bytes.subarray(12, 16).toString("ascii"), "IHDR", "PNG must start with an IHDR chunk."); +assert.equal(bytes.readUInt32BE(16), 256, "Marketplace logo width must be 256 pixels."); +assert.equal(bytes.readUInt32BE(20), 256, "Marketplace logo height must be 256 pixels."); +assert.ok(bytes.length <= 2 * 1024 * 1024, "Marketplace logo must not exceed Anna's 2MB limit."); + +const browser = await chromium.launch({ + executablePath, + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage"], +}); +let samples; +try { + const page = await browser.newPage({ viewport: { width: 256, height: 256 } }); + await page.goto(pathToFileURL(logoPath).href); + samples = await page.locator("img").evaluate((image) => { + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext("2d", { willReadFrequently: true }); + context.drawImage(image, 0, 0); + const pixel = (x, y) => [...context.getImageData(x, y, 1, 1).data]; + return { + naturalWidth: image.naturalWidth, + naturalHeight: image.naturalHeight, + goldBeacon: pixel(128, 56), + whiteMast: pixel(128, 128), + redHull: pixel(128, 152), + cyanWave: pixel(48, 172), + }; + }); +} finally { + await browser.close(); +} + +const near = (actual, expected, tolerance = 12) => + expected.every((channel, index) => Math.abs(actual[index] - channel) <= tolerance); +assert.deepEqual([samples.naturalWidth, samples.naturalHeight], [256, 256]); +assert.ok(near(samples.goldBeacon, [247, 198, 90, 255]), "Logo beacon must render gold."); +assert.ok(near(samples.whiteMast, [244, 247, 251, 255]), "Logo mast must render white."); +assert.ok(near(samples.redHull, [239, 75, 95, 255]), "Logo hull must render red."); +assert.ok(near(samples.cyanWave, [71, 215, 232, 255]), "Logo wave must render cyan."); + +console.log(JSON.stringify({ + result: "pass", + format: "PNG", + width: 256, + height: 256, + bytes: bytes.length, + maximumBytes: 2 * 1024 * 1024, + pixelSamples: "pass", +})); diff --git a/apps/storycore-harbour/tests/acceptance-failure-name.test.mjs b/apps/storycore-harbour/tests/acceptance-failure-name.test.mjs index 92c40de3..a00be603 100644 --- a/apps/storycore-harbour/tests/acceptance-failure-name.test.mjs +++ b/apps/storycore-harbour/tests/acceptance-failure-name.test.mjs @@ -7,7 +7,18 @@ test("contract failure details map to stable privacy-safe names", () => { assert.equal(publicFailureName("JSON parse failed: Unterminated string", "contract"), "json_invalid"); assert.equal(publicFailureName("warnings[0].severity must be info", "contract"), "warning_severity_invalid"); assert.equal(publicFailureName("sceneId references unknown scene secret-scene", "contract"), "reference_invalid"); + assert.equal( + publicFailureName("shot references secret-character, but that character is not listed in the parent scene", "contract"), + "reference_invalid", + ); assert.equal(publicFailureName("Total scene duration is implausibly short", "contract"), "duration_invalid"); + assert.equal(publicFailureName("schemaVersion must be storycore.project.v1", "contract"), "schema_invalid"); + assert.equal(publicFailureName("project.format is unsupported: private-format", "contract"), "schema_invalid"); + assert.equal(publicFailureName("project.createdAt must be an ISO-compatible date-time", "contract"), "timestamp_invalid"); + assert.equal(publicFailureName("Duplicate id at characters[1]: private-id", "contract"), "duplicate_invalid"); + assert.equal(publicFailureName("scenes[0].order must be a positive integer", "contract"), "ordering_invalid"); + assert.equal(publicFailureName("continuityReport.score must be between 0 and 100", "contract"), "continuity_score_invalid"); + assert.equal(publicFailureName("scenes must be an array", "contract"), "structure_invalid"); }); test("unknown contract details never enter the public failure name", () => { diff --git a/apps/storycore-harbour/tests/marketplace-logo.test.mjs b/apps/storycore-harbour/tests/marketplace-logo.test.mjs new file mode 100644 index 00000000..8c6b2232 --- /dev/null +++ b/apps/storycore-harbour/tests/marketplace-logo.test.mjs @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { resolve } from "node:path"; +import test from "node:test"; + +const APP_ROOT = fileURLToPath(new URL("../", import.meta.url)); +const logoPath = resolve(APP_ROOT, "review/marketplace-media/storycore-harbour-logo-256.png"); +const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); + +test("committed Marketplace logo is a bounded 256px PNG", async () => { + const bytes = await readFile(logoPath); + + assert.ok(bytes.subarray(0, 8).equals(pngSignature)); + assert.equal(bytes.subarray(12, 16).toString("ascii"), "IHDR"); + assert.equal(bytes.readUInt32BE(16), 256); + assert.equal(bytes.readUInt32BE(20), 256); + assert.ok(bytes.length > 0); + assert.ok(bytes.length <= 2 * 1024 * 1024); +}); diff --git a/apps/storycore-harbour/tests/repair-prompt.test.mjs b/apps/storycore-harbour/tests/repair-prompt.test.mjs index 30413251..cafe33ba 100644 --- a/apps/storycore-harbour/tests/repair-prompt.test.mjs +++ b/apps/storycore-harbour/tests/repair-prompt.test.mjs @@ -21,7 +21,32 @@ test("repair rebuilds from the exact user input without carrying truncated model assert.deepEqual(request.input, input); assert.deepEqual(request.validationErrors, ["JSON parse failed: truncated object"]); - assert.equal(request.task, "Rebuild a complete StoryCore Harbour production package from the source input."); + assert.match(request.task, /Rebuild the complete StoryCore Harbour project/); assert.equal(Object.hasOwn(request, "previousResponse"), false); assert.equal(prompt.includes("PREVIOUS RESPONSE"), false); }); + +test("repair prompt carries a complete structural checklist without weakening the contract", async () => { + const { createRepairPrompt } = await import("../bundle/repair-prompt.js"); + const request = JSON.parse(createRepairPrompt({ + idea: "A sufficiently long fictional source idea for a repair test.", + title: "Repair shape", + format: "short-film", + durationMinutes: 3, + language: "en", + tone: "Clear", + audience: "General audience", + }, ["continuityReport.score must be between 0 and 100."])); + + assert.equal(request.requiredShape.schemaVersion, "storycore-harbour.project.v1"); + assert.ok(request.requiredShape.project.includes("sourceIdea")); + assert.ok(request.requiredShape.productionBible.includes("visualDirection.cameraLanguage")); + assert.ok(request.requiredShape.shot.includes("dialogue")); + assert.ok(request.requiredShape.shot.includes("sound")); + assert.deepEqual(request.requiredShape.continuityReport, ["score", "warnings[]", "checkedAt"]); + assert.ok(request.hardRules.some((rule) => /exactly 3 scenes/i.test(rule))); + assert.ok(request.hardRules.some((rule) => /dialogue and sound are always strings/i.test(rule))); + assert.ok(request.hardRules.some((rule) => /warning severity is only info, warning, or error/i.test(rule))); + assert.equal(request.discardPreviousResponse, true); + assert.equal(request.sizeBudget.maxCharacters, 12_000); +}); diff --git a/config/package-lock.json b/config/package-lock.json index 186fdfae..35ccf0c6 100644 --- a/config/package-lock.json +++ b/config/package-lock.json @@ -33,13 +33,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "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.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -48,9 +48,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", - "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "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": { @@ -58,21 +58,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", - "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "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.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", + "@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", @@ -89,14 +89,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", - "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -106,14 +106,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "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.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@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" @@ -140,9 +140,9 @@ "license": "ISC" }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "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": { @@ -150,29 +150,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "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.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "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.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -192,9 +192,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "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": { @@ -202,9 +202,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "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": { @@ -212,9 +212,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "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": { @@ -222,27 +222,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "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.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -491,33 +491,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "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.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@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.28.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", - "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -525,14 +525,14 @@ } }, "node_modules/@babel/types": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -945,9 +945,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", "dev": true, "license": "MIT", "dependencies": { @@ -1717,9 +1717,9 @@ "license": "MIT" }, "node_modules/@xmldom/xmldom": { - "version": "0.8.14", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.14.tgz", - "integrity": "sha512-T4EDRUBVZYRldYApjEJiU0e1stYWaRAX7CuSnKzrpwdZKo53zGV8/pqfzV6FfwNl9YThD2OumQYvqtvjvgG7aQ==", + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", "dev": true, "license": "MIT", "engines": { @@ -2246,13 +2246,16 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.15", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", - "integrity": "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg==", + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/bluebird": { @@ -2296,9 +2299,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "dev": true, "funding": [ { @@ -2316,11 +2319,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -2473,9 +2476,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001764", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", - "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -3147,9 +3150,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "version": "1.5.423", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.423.tgz", + "integrity": "sha512-rRZfTSY8ptHYMQxa+uIycJMFKmY1T0GIApNMXJYGehguTZa56TEEl19pKPCoBqk5Gpf7QizZn/jt7xur+DYxag==", "dev": true, "license": "ISC" }, @@ -3520,9 +3523,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "dev": true, "funding": [ { @@ -4934,9 +4937,9 @@ } }, "node_modules/joi": { - "version": "18.0.2", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.2.tgz", - "integrity": "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.9.tgz", + "integrity": "sha512-2mD929bUVKUhOLQQEVhlf6EZ0Mlo0DeRb5MO7cViR9AXLtBauuccEtB1py9Ocxpo/P7ucnh442iY/iOwrh3IQw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -4946,7 +4949,7 @@ "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", - "@standard-schema/spec": "^1.0.0" + "@standard-schema/spec": "^1.1.0" }, "engines": { "node": ">= 20" @@ -4960,10 +4963,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -5543,11 +5556,14 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/nopt": { "version": "9.0.0", @@ -7049,9 +7065,9 @@ } }, "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==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { diff --git a/creative-studio-ui/package-lock.json b/creative-studio-ui/package-lock.json index f5821eb3..ffacab4e 100644 --- a/creative-studio-ui/package-lock.json +++ b/creative-studio-ui/package-lock.json @@ -71,13 +71,13 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react-swc": "^4.2.2", - "@vitest/coverage-v8": "^4.1.8", - "@vitest/ui": "^4.1.8", + "@vitest/coverage-v8": "^4.1.11", + "@vitest/ui": "^4.1.11", "autoprefixer": "^10.4.20", "axe-core": "^4.11.1", "electron": "^41.10.3", "esbuild": "^0.28.1", - "eslint": "^9.39.1", + "eslint": "^10.9.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "fast-check": "^4.5.3", @@ -89,9 +89,9 @@ "rollup-plugin-visualizer": "^6.0.5", "tailwindcss": "^3.4.17", "typescript": "~5.9.3", - "typescript-eslint": "^8.46.4", + "typescript-eslint": "^8.69.0", "vite": "^7.3.6", - "vitest": "^4.1.8" + "vitest": "^4.1.11" } }, "node_modules/@acemir/cssom": { @@ -188,12 +188,12 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -202,9 +202,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", - "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "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": { @@ -212,21 +212,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", - "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "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.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", + "@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", @@ -243,13 +243,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", - "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -259,14 +259,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "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.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@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" @@ -276,37 +276,37 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "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==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "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.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -326,27 +326,27 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "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==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "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==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "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": { @@ -354,26 +354,26 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "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.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -424,31 +424,31 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@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.28.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", - "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -456,13 +456,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1346,81 +1346,44 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^10.2.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^1.2.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/js": { @@ -1437,27 +1400,27 @@ } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@exodus/bytes": { @@ -1529,29 +1492,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -4781,6 +4758,13 @@ "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -4959,20 +4943,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz", - "integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz", + "integrity": "sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.53.0", - "@typescript-eslint/type-utils": "8.53.0", - "@typescript-eslint/utils": "8.53.0", - "@typescript-eslint/visitor-keys": "8.53.0", + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/type-utils": "8.69.0", + "@typescript-eslint/utils": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4982,15 +4966,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.53.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "@typescript-eslint/parser": "^8.69.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", + "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", "dev": true, "license": "MIT", "engines": { @@ -4998,16 +4982,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.53.0.tgz", - "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.69.0.tgz", + "integrity": "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.53.0", - "@typescript-eslint/types": "8.53.0", - "@typescript-eslint/typescript-estree": "8.53.0", - "@typescript-eslint/visitor-keys": "8.53.0", + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", "debug": "^4.4.3" }, "engines": { @@ -5018,19 +5002,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.0.tgz", - "integrity": "sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.69.0.tgz", + "integrity": "sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.53.0", - "@typescript-eslint/types": "^8.53.0", + "@typescript-eslint/tsconfig-utils": "^8.69.0", + "@typescript-eslint/types": "^8.69.0", "debug": "^4.4.3" }, "engines": { @@ -5041,18 +5025,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.0.tgz", - "integrity": "sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz", + "integrity": "sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.53.0", - "@typescript-eslint/visitor-keys": "8.53.0" + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5063,9 +5047,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.0.tgz", - "integrity": "sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz", + "integrity": "sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==", "dev": true, "license": "MIT", "engines": { @@ -5076,21 +5060,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.53.0.tgz", - "integrity": "sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.69.0.tgz", + "integrity": "sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.53.0", - "@typescript-eslint/typescript-estree": "8.53.0", - "@typescript-eslint/utils": "8.53.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/utils": "8.69.0", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5100,14 +5084,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.0.tgz", - "integrity": "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.69.0.tgz", + "integrity": "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==", "dev": true, "license": "MIT", "engines": { @@ -5119,21 +5103,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.0.tgz", - "integrity": "sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz", + "integrity": "sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.53.0", - "@typescript-eslint/tsconfig-utils": "8.53.0", - "@typescript-eslint/types": "8.53.0", - "@typescript-eslint/visitor-keys": "8.53.0", + "@typescript-eslint/project-service": "8.69.0", + "@typescript-eslint/tsconfig-utils": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/visitor-keys": "8.69.0", "debug": "^4.4.3", - "minimatch": "^9.0.5", + "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5143,39 +5127,13 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -5186,16 +5144,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.53.0.tgz", - "integrity": "sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.69.0.tgz", + "integrity": "sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.53.0", - "@typescript-eslint/types": "8.53.0", - "@typescript-eslint/typescript-estree": "8.53.0" + "@typescript-eslint/scope-manager": "8.69.0", + "@typescript-eslint/types": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5205,19 +5163,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.0.tgz", - "integrity": "sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz", + "integrity": "sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.53.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.69.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5297,14 +5255,14 @@ "license": "MIT" }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", - "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.11", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -5318,8 +5276,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.8", - "vitest": "4.1.8" + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -5387,13 +5345,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", - "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.8", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -5414,9 +5372,9 @@ } }, "node_modules/@vitest/mocker/node_modules/@vitest/spy": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", - "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -5424,9 +5382,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", - "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -5437,13 +5395,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", - "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -5451,14 +5409,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", - "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -5481,13 +5439,13 @@ } }, "node_modules/@vitest/ui": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.8.tgz", - "integrity": "sha512-RUS2ZU2TsduVrI+9c12uTNaKrNUTsm6yFt3fueEUB9iKvyC2UP83F+sqIz00HQIah4UOL1TMoDAki8K0NjGvsA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.11.tgz", + "integrity": "sha512-r/rwyKoev21mWdRGSEkZOqkQ2BYy68mwjihg9M90nNRbf4NGrgzZ4cj6JNCEwlOGJkbKeMgsjlykvwKUbRr7gw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.11", "fflate": "^0.8.2", "flatted": "^3.4.2", "pathe": "^2.0.3", @@ -5499,17 +5457,17 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.1.8" + "vitest": "4.1.11" } }, "node_modules/@vitest/utils": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", - "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -5532,9 +5490,9 @@ "license": "BSD-3-Clause" }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", "bin": { @@ -5648,13 +5606,6 @@ "dev": true, "license": "MIT" }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -5836,11 +5787,14 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/base64-arraybuffer": { "version": "1.0.2", @@ -5873,13 +5827,16 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.14", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", - "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==", + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/bidi-js": { @@ -5911,14 +5868,16 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/braces": { @@ -5935,9 +5894,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -5955,11 +5914,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -6055,9 +6014,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001764", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", - "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -6123,23 +6082,6 @@ "node": ">=18" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -6370,13 +6312,6 @@ "node": ">= 6" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -6813,9 +6748,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "version": "1.5.420", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", + "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", "dev": true, "license": "ISC" }, @@ -6873,9 +6808,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -6971,33 +6906,33 @@ } }, "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.1.tgz", + "integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", + "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -7007,8 +6942,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -7016,7 +6950,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -7061,48 +6995,50 @@ } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" @@ -7190,9 +7126,9 @@ } }, "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -7318,9 +7254,9 @@ } }, "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", "license": "MIT" }, "node_modules/file-entry-cache": { @@ -8210,29 +8146,6 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, - "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/jsdom": { "version": "27.4.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", @@ -8410,13 +8323,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -9232,16 +9138,19 @@ } }, "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.8" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/motion-dom": { @@ -9356,11 +9265,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -9392,15 +9304,18 @@ } }, "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" ], - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } }, "node_modules/open": { "version": "8.4.2", @@ -9912,9 +9827,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11000,9 +10915,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, "license": "MIT" }, @@ -11129,19 +11044,6 @@ "node": ">=8" } }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -11391,9 +11293,9 @@ } }, "node_modules/three-stdlib/node_modules/fflate": { - "version": "0.6.10", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", - "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.11.tgz", + "integrity": "sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==", "license": "MIT" }, "node_modules/tiny-invariant": { @@ -11412,9 +11314,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { @@ -11439,9 +11341,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -11579,9 +11481,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -11669,16 +11571,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.53.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.53.0.tgz", - "integrity": "sha512-xHURCQNxZ1dsWn0sdOaOfCSQG0HKeqSj9OexIxrz6ypU6wHYOdX2I3D2b8s8wFSsSOYJb+6q283cLiLlkEsBYw==", + "version": "8.69.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.69.0.tgz", + "integrity": "sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.53.0", - "@typescript-eslint/parser": "8.53.0", - "@typescript-eslint/typescript-estree": "8.53.0", - "@typescript-eslint/utils": "8.53.0" + "@typescript-eslint/eslint-plugin": "8.69.0", + "@typescript-eslint/parser": "8.69.0", + "@typescript-eslint/typescript-estree": "8.69.0", + "@typescript-eslint/utils": "8.69.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -11688,8 +11590,8 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/undici": { @@ -11798,9 +11700,9 @@ } }, "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==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { @@ -12033,19 +11935,19 @@ } }, "node_modules/vitest": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", - "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.8", - "@vitest/mocker": "4.1.8", - "@vitest/pretty-format": "4.1.8", - "@vitest/runner": "4.1.8", - "@vitest/snapshot": "4.1.8", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -12073,12 +11975,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.8", - "@vitest/browser-preview": "4.1.8", - "@vitest/browser-webdriverio": "4.1.8", - "@vitest/coverage-istanbul": "4.1.8", - "@vitest/coverage-v8": "4.1.8", - "@vitest/ui": "4.1.8", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -12123,16 +12025,16 @@ } }, "node_modules/vitest/node_modules/@vitest/expect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", - "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -12141,9 +12043,9 @@ } }, "node_modules/vitest/node_modules/@vitest/spy": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", - "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { diff --git a/creative-studio-ui/package.json b/creative-studio-ui/package.json index fe46fa7b..33585e83 100644 --- a/creative-studio-ui/package.json +++ b/creative-studio-ui/package.json @@ -91,13 +91,13 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react-swc": "^4.2.2", - "@vitest/coverage-v8": "^4.1.8", - "@vitest/ui": "^4.1.8", + "@vitest/coverage-v8": "^4.1.11", + "@vitest/ui": "^4.1.11", "autoprefixer": "^10.4.20", "axe-core": "^4.11.1", "electron": "^41.10.3", "esbuild": "^0.28.1", - "eslint": "^9.39.1", + "eslint": "^10.9.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "fast-check": "^4.5.3", @@ -109,8 +109,8 @@ "rollup-plugin-visualizer": "^6.0.5", "tailwindcss": "^3.4.17", "typescript": "~5.9.3", - "typescript-eslint": "^8.46.4", + "typescript-eslint": "^8.69.0", "vite": "^7.3.6", - "vitest": "^4.1.8" + "vitest": "^4.1.11" } } diff --git a/docs/music-planning-layer-v1.md b/docs/music-planning-layer-v1.md new file mode 100644 index 00000000..ba26a143 --- /dev/null +++ b/docs/music-planning-layer-v1.md @@ -0,0 +1,150 @@ +# StoryCore Music Planning Layer v1 + +Status: **experimental contract; provider-neutral; no provider activated**. + +## Goal + +Insert an editable symbolic planning layer between narrative intent and any music +generator. StoryCore should be able to inspect, compare, revise and version a +score plan without coupling the project to one model or one licence regime. + +```text +story / scene / shot intent + | + v +MusicPlan v1 + | + v +Draft 2020-12 schema + deterministic invariants + | + v +provider capability / licence check + | + v +requested vs executed delta + | + v +candidate artifact + portable handoff + | + v +independent validation later + | + v +last-known-good promotion outside provider adapter +``` + +The design is inspired by open-source workflows that expose an editable +intermediate representation, but this contract is StoryCore-owned and does not +require or redistribute third-party model weights. + +## Three execution modes + +- `full`: strongest symbolic plan; use where continuity, motifs, timing or + reproducibility matter. +- `guided`: StoryCore fixes high-level structure and synchronization while the + provider may elaborate details. +- `free`: narrative/time brief remains binding, but detailed symbolic planning + is optional. It must not be represented as equivalent to a `full` render. + +## Validation before inference + +The model-free gate composes the repository's existing `jsonschema` dependency +with StoryCore-specific checks. It: + +- validates the Draft 2020-12 schema itself; +- validates required fields and bounded values in a requested plan; +- rejects invalid section timing and overlaps; +- checks unique IDs and motif references; +- checks cue bounds; +- rejects declared non-commercial model weights on commercial targets; +- treats known unknown/unqualified model-weight licence markers as ineligible + for commercial use. + +The reference fixtures cover `full`, `guided`, and `free`. No LLM, music model, +network request or weight download is needed for this gate. + +## Explicit degradation + +`execution_delta(requested, executed)` is recursive. It records mode changes, +removed or added fields, changed scalar values, list-length changes and nested +changes. Examples include: + +```text +mode:full->guided +dropped:motifs +changed:duration_seconds +changed:sync_cues.length +changed:sync_cues[0].time_seconds +``` + +A provider must never silently change requested state. + +`MockMusicProvider` is deterministic and performs no inference. A fully capable +mock keeps the requested state unchanged. A limited mock may use an explicit +fallback and list unsupported fields, but any material delta requires a +non-empty degradation reason or the preparation fails closed. + +## Portable provider handoff + +`build_provider_handoff()` creates a data-only interchange envelope containing: + +- plan ID; +- provider/version/model; +- adapter/harness and hardware identity; +- requested/executed modes; +- unsupported fields, deltas, reason and acknowledgement; +- candidate artifact identity and optional digests; +- verification state and evidence references. + +It does not import Botte Secrète, execute a provider or write memory. Provider +results remain candidates. The envelope always carries +`activation_allowed=false`, `promoted=false` and +`memory_write_performed=false`. A `verified` handoff requires evidence. + +## Commercial boundary + +Code, model weights, datasets and assets retain separate provenance/licences. +Permissive adapter code cannot make restricted model weights commercially +usable. Non-commercial or unknown/unqualified weights remain outside the +StoryCore/Obolune commercial path until independently qualified. + +## Benchmark identity + +Future measurements must retain the complete path: + +`story fixture x model/provider x adapter/harness x hardware x parameters` + +A model-only score is insufficient because the harness can materially change +plan preservation, failures, quality, latency and resource use. + +## Current proof boundary + +The isolated `MusicPlan Contract` workflow runs on Python 3.10 and 3.12. It +installs only focused test/schema dependencies, compiles the MusicPlan/provider +contracts, executes the focused regression suite, checks the Draft 2020-12 +schema, and validates all three reference fixtures against it. + +Exact-head CI remains the authority for PR claims. This contract does **not** +prove real audio quality, a production provider, output rights beyond the +explicit licence policy, hardware performance, end-to-end StoryCore integration, +or last-known-good audio recovery. + +## Relationship to Botte Secrète + +StoryCore remains standalone. A future Botte integration should consume the +portable handoff as data rather than importing Botte as a hard runtime +dependency. Conceptually: + +- MusicPlan + narrative refs -> context snapshot; +- provider capabilities/licence/hardware -> constraints; +- provider handoff -> execution delta + candidate artifact; +- independent validators -> evidence; +- accepted soundtrack -> recovery point; +- measured provider runs -> Capability Atlas observations. + +## Next bounded slice + +Before any real provider is connected, add deterministic artifact-digest and +independent-verification fixtures around the portable handoff. Real local or +external music inference waits for explicit licence/hardware qualification and +must remain non-promoting by default. diff --git a/docs/security/PR55_DEPENDENCY_VALIDATION.md b/docs/security/PR55_DEPENDENCY_VALIDATION.md new file mode 100644 index 00000000..ad5f2368 --- /dev/null +++ b/docs/security/PR55_DEPENDENCY_VALIDATION.md @@ -0,0 +1,76 @@ +# PR #55 dependency validation — 2026-09-12 + +This report records a security dependency correction and its validation limits. It does not authorize or claim a merge. + +## Source and scope + +- Repository: `zedarvates/StoryCore-Engine`. +- Compared base: `5b6c83bd26f60f7eb5e4da53f958f2d3b06edb4a`. +- Original Dependabot PR head: `0046d9328a8adc48d2a401685786bf2d9f5901c7`. +- Full isolated checkouts were used. Application source, test source and configuration are unchanged. +- Keep sharp **0.35.4** and Joi **18.2.9** from Dependabot, with their original root/config manifests and locks byte-for-byte unchanged. +- Change the UI's `vitest`, `@vitest/ui` and `@vitest/coverage-v8` ranges to **^4.1.11**, locked at **4.1.11**. npm also resolves the associated Vitest modules and transitive dependencies. +- Vitest 5 is deferred. Its partially upgraded graph required incompatible 4.x/5.x peers, and the existing nested mock in `AssetPanel.test.tsx` needs migration before v5. + +The [Vitest 4.1.11 release](https://github.com/vitest-dev/vitest/releases/tag/v4.1.11) includes the redirect-mock allowlist security fix. + +## Executed evidence + +Environment: **Linux x64, Node.js 24.19.0, npm 11.9.0**. These are local checkout results, not GitHub Actions or homelab results. + +| Check | Result | +| --- | --- | +| `npm ci --ignore-scripts --no-audit --no-fund`, root | PASS; 751 packages installed | +| Same installation, `config/` | PASS; 547 packages installed | +| Standard UI resolution, base and candidate: `npm ci --dry-run --ignore-scripts --no-audit --no-fund` | BLOCKED by the same pre-existing ESLint peer conflict | +| UI diagnostic installation, base and candidate: `npm ci --ignore-scripts --no-audit --no-fund --force` | Completed; 760 packages installed in each. Not a passing standard installation | +| npm Arborist virtual-tree peer check | Candidate and base have the same one invalid present peer: React Hooks ESLint plugin versus ESLint. Candidate has no invalid Vitest peer | +| `node addons/content_sensitivity/tests/test_censorship.js` | 11 passed, including real Sharp pixelation of a synthetic image | +| Native image smoke | Actual loaded versions: sharp 0.35.4, libvips 8.18.6, libheif 1.23.2; AVIF decode, resize and PNG encode passed | +| Joi and wait-on smoke, root and config | Joi 18.2.9 loaded; flat `__proto__` message code rejected; custom language input leaves global Object.prototype unchanged; wait-on detects a local HTTP fixture | +| UI `npm test -- --maxWorkers=2 --bail=1` | 108 passed, then one failure; stopped early, so the full suite is not validated | +| Base reproduction with Vitest 4.1.8 | Same failure and same 108 preceding successes in `RelationshipManager.test.ts` | +| Targeted `AssetPanel.test.tsx`, Vitest 4.1.11 | 6/6 passed; the nested mock produces a warning | +| Same AssetPanel tests with V8 coverage | 6/6 passed; coverage provider 4.1.11 works. For AssetPanel.tsx only: 95/174 lines, 54.59%. This is not repository-wide coverage | +| UI `npm run build:check` | BLOCKED at TypeScript; Vite build was not reached | +| TypeScript comparison with matching root/UI installation context | Base and candidate each report 2,147 diagnostic lines. After checkout-path normalization, their diagnostic multisets are identical | + +The repeated test failure is `ReferenceError: removeRelationship is not defined` at `creative-studio-ui/src/services/__tests__/RelationshipManager.test.ts:2016`. Its import is named `_removeRelationship`. The source is unchanged in this correction. + +For the TypeScript comparison, the base was rebuilt with `node node_modules/typescript/bin/tsc -b --force` after installing its root dependencies. Comparing against a base without root dependencies gives misleading differences due to missing Jest and Ant Design types; that preliminary comparison is not used as evidence. + +## Security audit before/after + +`npm audit --json --package-lock-only --ignore-scripts` was executed for all three lockfiles in the base and candidate. + +| Dependency tree | Base vulnerable package entries | Candidate entries | +| --- | ---: | ---: | +| Root | 2: sharp and Joi | 0 | +| config | 1: Joi | 0 | +| creative-studio-ui | 4: Vitest, mocker, UI and coverage | 0 | + +These are package entries per lockfile, not distinct vulnerability counts. Zero means no vulnerability reported by the npm advisory service at the time of this run. + +The reports cover: +- [sharp / libheif](https://github.com/advisories/GHSA-rgj7-g3m4-5g8c). +- [Joi recursive-link RangeError](https://github.com/advisories/GHSA-q7cg-457f-vx79), [custom-message prototype pollution](https://github.com/advisories/GHSA-6w3j-5fw6-r9vr), and [template rename prototype changes](https://github.com/advisories/GHSA-gg4h-3hg2-grpc). Joi 18.2.9 also includes the [additional flat-message-code fix](https://github.com/hapijs/joi/pull/3151). +- [Vitest redirect-mock arbitrary file read](https://github.com/advisories/GHSA-82fw-gwwq-j7x9). + +The private Dependabot alert #461 details and alert closure state were not verified. Candidate audit results do not imply alerts on the unchanged default branch have closed. + +## Remaining boundaries + +1. `eslint-plugin-react-hooks@7.0.1` accepts ESLint through 9, but the existing UI pins ESLint 10.9.1. This blocks normal npm resolution in both base and candidate. `--force` was used only for isolated lock generation and diagnostic installs; no persistent npm bypass or CI relaxation is added. +2. The full UI test suite and TypeScript/build gate remain unsuccessful. The reproduced failures do not originate in this correction. +3. Lifecycle scripts were not executed. Electron packaging, Windows and the actual homelab were not tested. +4. The original head had only SonarCloud Code Analysis. The existing Harbour workflow's path filter does not cover this dependency slice. No relevant GitHub Actions success is claimed. + +## Tested input fingerprints + +SHA-256 values bind the executable checks to the two modified UI dependency files; this report itself does not affect their contents. + +| File | SHA-256 | +| --- | --- | +| `creative-studio-ui/package.json` | `c36937b3ef143546973e73970c1c5161f2c1197553ab1b58ad542ab7ad09379a` | +| `creative-studio-ui/package-lock.json` | `32acf5282507fc87b69afac3b6061c307e795ed2fe8080d78586f7dc4bf732a3` | + diff --git a/fixtures/storycore-game/coinfall-chronicle/README.md b/fixtures/storycore-game/coinfall-chronicle/README.md new file mode 100644 index 00000000..468d8991 --- /dev/null +++ b/fixtures/storycore-game/coinfall-chronicle/README.md @@ -0,0 +1,74 @@ +# Coinfall Chronicle + +Coinfall Chronicle is an original, dependency-free vertical slice for the +public StoryCore-to-game contract. It turns a localized world, actor, item, +quest, reward, and deterministic gameplay seed into a small Godot 4.2 project. +The artwork is made from procedural shapes; no third-party game or asset is +copied. + +## What this proves + +- A StoryCore game specification can fail closed on unknown fields, broken + references, incomplete localization, and private-component leakage. +- The same normalized input produces the same SHA-256-addressed manifest. +- A Godot consumer can verify the embedded hash before loading any gameplay. +- The fixture works without an account, network service, model, or proprietary + backend. + +It does **not** contain or describe the Ultimate Odycer commercial server, +internal agents/reasoners, private algorithms, credentials, or provider +adapters. A production server may implement the public `external-contract` +authority boundary without becoming part of this MIT fixture. + +## Compile and test + +From the repository root: + +```bash +python -m src.game_bridge \ + --input fixtures/storycore-game/coinfall-chronicle/storycore_game_spec.json \ + --output-dir fixtures/storycore-game/coinfall-chronicle/godot +python -m unittest discover -s tests/game_bridge -v +``` + +The compiler writes `storycore_game_manifest.json` plus a reproducible evidence +record. The test suite also detects when the checked-in Godot inputs drift from +the compiler output. For path safety, CLI input and output must resolve inside +the current workspace; symlink and `..` escapes are rejected. + +## Play + +Open `godot/project.godot` with Godot 4.2 or newer and run the main scene. + +- Click or press Space to drop a rune. +- Press L to switch between English and French. +- Recover at least five runes and reach 100 points within twelve drops. +- Press R to restart. + +The automated quest smoke can be reproduced with a trusted Godot binary: + +```bash +godot --headless \ + --path fixtures/storycore-game/coinfall-chronicle/godot \ + --script res://SmokeTest.gd +``` + +Success prints `STORYCORE_GAME_SMOKE_PASS`. The recorded acceptance run used +the official Linux x86-64 Godot 4.7.2 binary (`ed1daf0bf`) after verifying its +published SHA-256. Because Godot may still exit with status zero after a script +parse error, automation must also reject `SCRIPT ERROR` and `ERROR:` in its log. + +## Acceptance gate + +| Check | Local status | Promotion requirement | +|---|---|---| +| Strict contract and negative cases | Automated | All unit tests pass | +| Deterministic manifest and evidence | Automated | Checked-in outputs match | +| Manifest tamper detection | Automated | Modified content is rejected | +| Godot import and script parse | Passed on 4.7.2 | Repeat on an isolated trusted runner | +| Full quest play-through | Automated smoke passed | `STORYCORE_GAME_SMOKE_PASS` is present | +| Private/public boundary | Automated + review | No private component or secret is present | + +The fixture remains experimental until the Quality Gate and human boundary +review also pass. Never run public fork code on a self-hosted runner carrying +personal, signing, deployment, or production credentials. diff --git a/fixtures/storycore-game/coinfall-chronicle/godot/Main.gd b/fixtures/storycore-game/coinfall-chronicle/godot/Main.gd new file mode 100644 index 00000000..01802876 --- /dev/null +++ b/fixtures/storycore-game/coinfall-chronicle/godot/Main.gd @@ -0,0 +1,391 @@ +extends Node2D + +const MANIFEST_PATH := "res://storycore_game_manifest.json" +const CONTRACT_VERSION := "0.1" +const BOARD_LEFT := 110.0 +const BOARD_RIGHT := 850.0 +const DROP_Y := 150.0 +const BIN_Y := 625.0 + +var manifest: Dictionary = {} +var locale := "en" +var score := 0 +var collected := 0 +var drop_count := 0 +var run_ended := false +var rng := RandomNumberGenerator.new() + +var title_label: Label +var instruction_label: Label +var quest_label: Label +var message_label: Label + + +func _ready() -> void: + if not _load_and_verify_manifest(): + return + rng.seed = int(manifest["gameplay"]["board_seed"]) + _create_background() + _create_board() + _create_interface() + _update_interface() + + +func _unhandled_input(event: InputEvent) -> void: + if event is InputEventKey and event.pressed and not event.echo: + if event.keycode == KEY_L: + locale = "fr" if locale == "en" else "en" + _update_interface() + return + if event.keycode == KEY_R: + get_tree().reload_current_scene() + return + if event.keycode == KEY_SPACE: + _drop_rune(rng.randf_range(BOARD_LEFT + 35.0, BOARD_RIGHT - 35.0)) + return + if event is InputEventMouseButton and event.pressed: + if event.button_index == MOUSE_BUTTON_LEFT: + _drop_rune(clampf(event.position.x, BOARD_LEFT + 25.0, BOARD_RIGHT - 25.0)) + if event is InputEventScreenTouch and event.pressed: + _drop_rune(clampf(event.position.x, BOARD_LEFT + 25.0, BOARD_RIGHT - 25.0)) + + +func _physics_process(_delta: float) -> void: + for rune in get_tree().get_nodes_in_group("active_runes"): + if rune.position.y > 780.0: + rune.queue_free() + + +func _load_and_verify_manifest() -> bool: + if not FileAccess.file_exists(MANIFEST_PATH): + _show_fatal("Manifest missing: " + MANIFEST_PATH) + return false + var file := FileAccess.open(MANIFEST_PATH, FileAccess.READ) + if file == null: + _show_fatal("Manifest cannot be opened.") + return false + var parsed: Variant = JSON.parse_string(file.get_as_text()) + if typeof(parsed) != TYPE_DICTIONARY: + _show_fatal("Manifest is not a JSON object.") + return false + manifest = parsed + var required := [ + "contract_version", "compiled_by", "content_sha256", "project", + "locales", "world", "actors", "items", "quest", "gameplay", + "runtime", "boundary" + ] + for key in required: + if not manifest.has(key): + _show_fatal("Manifest field missing: " + str(key)) + return false + if str(manifest["contract_version"]) != CONTRACT_VERSION: + _show_fatal("Unsupported contract version.") + return false + var boundary: Dictionary = manifest["boundary"] + if boundary.get("public_contract_only") != true: + _show_fatal("Public contract marker missing.") + return false + if boundary.get("private_components_included") != false: + _show_fatal("Private components are forbidden in this fixture.") + return false + if str(manifest["runtime"].get("authority", "")) != "local-fixture": + _show_fatal("This fixture accepts local-fixture authority only.") + return false + var core := manifest.duplicate(true) + var expected_hash := str(core.get("content_sha256", "")) + core.erase("content_sha256") + if _sha256(_canonical_json(core)) != expected_hash: + _show_fatal("Manifest content hash mismatch.") + return false + return true + + +func _canonical_json(value: Variant) -> String: + match typeof(value): + TYPE_DICTIONARY: + var keys: Array = value.keys() + keys.sort() + var pairs := PackedStringArray() + for key in keys: + pairs.append(JSON.stringify(str(key)) + ":" + _canonical_json(value[key])) + return "{" + ",".join(pairs) + "}" + TYPE_ARRAY: + var entries := PackedStringArray() + for entry in value: + entries.append(_canonical_json(entry)) + return "[" + ",".join(entries) + "]" + TYPE_FLOAT: + # Godot may parse integral JSON numbers as floats. The compiler's + # canonical JSON writes them without a decimal suffix. + if value == floor(value): + return str(int(value)) + return JSON.stringify(value) + _: + return JSON.stringify(value) + + +func _sha256(text: String) -> String: + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + context.update(text.to_utf8_buffer()) + return context.finish().hex_encode() + + +func _show_fatal(message: String) -> void: + var backdrop := ColorRect.new() + backdrop.color = Color("15101f") + backdrop.size = Vector2(960.0, 720.0) + add_child(backdrop) + var label := Label.new() + label.text = "Coinfall Chronicle stopped safely\n\n" + message + label.position = Vector2(120.0, 280.0) + label.size = Vector2(720.0, 160.0) + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.add_theme_font_size_override("font_size", 24) + label.add_theme_color_override("font_color", Color("ffb4ab")) + add_child(label) + set_process_unhandled_input(false) + set_physics_process(false) + + +func _create_background() -> void: + var backdrop := Polygon2D.new() + backdrop.polygon = PackedVector2Array([ + Vector2.ZERO, Vector2(960.0, 0.0), Vector2(960.0, 720.0), Vector2(0.0, 720.0) + ]) + backdrop.color = Color("171124") + backdrop.z_index = -10 + add_child(backdrop) + var board_glow := Polygon2D.new() + board_glow.polygon = _rectangle_points(Vector2(760.0, 520.0)) + board_glow.position = Vector2(480.0, 405.0) + board_glow.color = Color("241936") + board_glow.z_index = -9 + add_child(board_glow) + + +func _create_board() -> void: + _make_wall(Vector2(BOARD_LEFT, 405.0), Vector2(14.0, 510.0), Color("8d6bb8")) + _make_wall(Vector2(BOARD_RIGHT, 405.0), Vector2(14.0, 510.0), Color("8d6bb8")) + for row in range(7): + var columns := 9 if row % 2 == 0 else 8 + var offset := 0.0 if row % 2 == 0 else 40.0 + for column in range(columns): + var peg_x := 160.0 + offset + float(column) * 80.0 + var peg_y := 235.0 + float(row) * 48.0 + _make_peg(Vector2(peg_x, peg_y)) + _create_bins() + + +func _create_bins() -> void: + var scores: Array = manifest["gameplay"]["score_bins"] + var width := (BOARD_RIGHT - BOARD_LEFT) / float(scores.size()) + for index in range(scores.size()): + var center_x := BOARD_LEFT + width * (float(index) + 0.5) + var area := Area2D.new() + area.position = Vector2(center_x, BIN_Y) + area.collision_layer = 0 + area.collision_mask = 1 + var collision := CollisionShape2D.new() + var shape := RectangleShape2D.new() + shape.size = Vector2(width - 8.0, 58.0) + collision.shape = shape + area.add_child(collision) + area.body_entered.connect(_on_bin_entered.bind(index)) + add_child(area) + var tile := Polygon2D.new() + tile.polygon = _rectangle_points(Vector2(width - 8.0, 58.0)) + tile.color = Color("4b3567") if index != 2 else Color("7552a3") + area.add_child(tile) + var label := Label.new() + label.text = "+" + str(scores[index]) + label.position = Vector2(-width * 0.5, -13.0) + label.size = Vector2(width, 30.0) + label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + label.add_theme_font_size_override("font_size", 18) + label.add_theme_color_override("font_color", Color("f7d67a")) + area.add_child(label) + if scores.size() > 1: + for divider in range(1, scores.size()): + var divider_x := BOARD_LEFT + width * float(divider) + _make_wall(Vector2(divider_x, 585.0), Vector2(8.0, 95.0), Color("8d6bb8")) + + +func _make_peg(position_value: Vector2) -> void: + var body := StaticBody2D.new() + body.position = position_value + var collision := CollisionShape2D.new() + var circle := CircleShape2D.new() + circle.radius = 9.0 + collision.shape = circle + body.add_child(collision) + var visual := Polygon2D.new() + visual.polygon = _circle_points(9.0, 16) + visual.color = Color("c8a9eb") + body.add_child(visual) + add_child(body) + + +func _make_wall(position_value: Vector2, size: Vector2, color: Color) -> void: + var body := StaticBody2D.new() + body.position = position_value + var collision := CollisionShape2D.new() + var rectangle := RectangleShape2D.new() + rectangle.size = size + collision.shape = rectangle + body.add_child(collision) + var visual := Polygon2D.new() + visual.polygon = _rectangle_points(size) + visual.color = color + body.add_child(visual) + add_child(body) + + +func _drop_rune(x_position: float) -> void: + if run_ended or drop_count >= int(manifest["gameplay"]["drop_limit"]): + return + drop_count += 1 + var body := RigidBody2D.new() + body.position = Vector2(x_position, DROP_Y) + body.collision_layer = 1 + body.collision_mask = 1 + body.gravity_scale = 0.92 + body.mass = 0.8 + body.continuous_cd = RigidBody2D.CCD_MODE_CAST_RAY + body.angular_velocity = rng.randf_range(-2.0, 2.0) + body.set_meta("resolved", false) + body.add_to_group("active_runes") + var collision := CollisionShape2D.new() + var circle := CircleShape2D.new() + circle.radius = 14.0 + collision.shape = circle + body.add_child(collision) + var visual := Polygon2D.new() + visual.polygon = _circle_points(14.0, 20) + visual.color = Color("79d5ff") + body.add_child(visual) + var core := Polygon2D.new() + core.polygon = _circle_points(6.0, 12) + core.color = Color("f6e8ff") + body.add_child(core) + add_child(body) + _update_interface() + + +func _on_bin_entered(body: Node2D, bin_index: int) -> void: + if not body.is_in_group("active_runes") or bool(body.get_meta("resolved", false)): + return + body.set_meta("resolved", true) + var scores: Array = manifest["gameplay"]["score_bins"] + score += int(scores[bin_index]) + collected += 1 + body.queue_free() + _update_interface() + _evaluate_run() + + +func _evaluate_run() -> void: + var objective: Dictionary = manifest["quest"]["objectives"][0] + var objective_done := collected >= int(objective["required_count"]) + var score_done := score >= int(manifest["gameplay"]["score_target"]) + if objective_done and score_done: + run_ended = true + var reward: Dictionary = manifest["quest"]["rewards"][0] + var reward_name := _item_name(str(reward["item_id"])) + message_label.text = ( + ("Quest complete — Reward: " if locale == "en" else "Quête accomplie — Récompense : ") + + str(reward["count"]) + " × " + reward_name + " [R]" + ) + message_label.add_theme_color_override("font_color", Color("8ff0a4")) + elif drop_count >= int(manifest["gameplay"]["drop_limit"]): + run_ended = true + message_label.text = ( + "Run ended — press R to retry." + if locale == "en" + else "Partie terminée — appuie sur R pour recommencer." + ) + message_label.add_theme_color_override("font_color", Color("ffb4ab")) + + +func _create_interface() -> void: + title_label = Label.new() + title_label.position = Vector2(30.0, 16.0) + title_label.size = Vector2(900.0, 38.0) + title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + title_label.add_theme_font_size_override("font_size", 28) + title_label.add_theme_color_override("font_color", Color("f6e8ff")) + add_child(title_label) + instruction_label = Label.new() + instruction_label.position = Vector2(30.0, 55.0) + instruction_label.size = Vector2(900.0, 30.0) + instruction_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + instruction_label.add_theme_color_override("font_color", Color("c8a9eb")) + add_child(instruction_label) + quest_label = Label.new() + quest_label.position = Vector2(35.0, 92.0) + quest_label.size = Vector2(890.0, 48.0) + quest_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + quest_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + quest_label.add_theme_font_size_override("font_size", 17) + quest_label.add_theme_color_override("font_color", Color("f7d67a")) + add_child(quest_label) + message_label = Label.new() + message_label.position = Vector2(30.0, 684.0) + message_label.size = Vector2(900.0, 28.0) + message_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + message_label.add_theme_color_override("font_color", Color("d7c8e8")) + add_child(message_label) + + +func _update_interface() -> void: + if title_label == null: + return + title_label.text = _localized(manifest["project"]["title"]) + instruction_label.text = ( + "Click or Space: drop rune • L: français • R: restart" + if locale == "en" + else "Clic ou Espace : lâcher une rune • L : English • R : recommencer" + ) + var objective: Dictionary = manifest["quest"]["objectives"][0] + var quest_title: String = str(_localized(manifest["quest"]["title"])) + if locale == "en": + quest_label.text = "%s • Runes %d/%d • Score %d/%d • Drops %d/%d" % [ + quest_title, collected, int(objective["required_count"]), score, + int(manifest["gameplay"]["score_target"]), drop_count, + int(manifest["gameplay"]["drop_limit"]) + ] + else: + quest_label.text = "%s • Runes %d/%d • Score %d/%d • Lancers %d/%d" % [ + quest_title, collected, int(objective["required_count"]), score, + int(manifest["gameplay"]["score_target"]), drop_count, + int(manifest["gameplay"]["drop_limit"]) + ] + if not run_ended: + message_label.text = _localized(manifest["actors"][0]["dialogue"])[0] + + +func _localized(values: Dictionary) -> Variant: + return values.get(locale, values.get("en", "")) + + +func _item_name(item_id: String) -> String: + for item in manifest["items"]: + if str(item["id"]) == item_id: + return str(_localized(item["name"])) + return item_id + + +func _rectangle_points(size: Vector2) -> PackedVector2Array: + var half := size * 0.5 + return PackedVector2Array([ + Vector2(-half.x, -half.y), Vector2(half.x, -half.y), + Vector2(half.x, half.y), Vector2(-half.x, half.y) + ]) + + +func _circle_points(radius: float, sides: int) -> PackedVector2Array: + var points := PackedVector2Array() + for index in range(sides): + var angle := TAU * float(index) / float(sides) + points.append(Vector2(cos(angle), sin(angle)) * radius) + return points diff --git a/fixtures/storycore-game/coinfall-chronicle/godot/Main.tscn b/fixtures/storycore-game/coinfall-chronicle/godot/Main.tscn new file mode 100644 index 00000000..2a2fdbac --- /dev/null +++ b/fixtures/storycore-game/coinfall-chronicle/godot/Main.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://Main.gd" id="1_main"] + +[node name="CoinfallChronicle" type="Node2D"] +script = ExtResource("1_main") diff --git a/fixtures/storycore-game/coinfall-chronicle/godot/SmokeTest.gd b/fixtures/storycore-game/coinfall-chronicle/godot/SmokeTest.gd new file mode 100644 index 00000000..7a48ef77 --- /dev/null +++ b/fixtures/storycore-game/coinfall-chronicle/godot/SmokeTest.gd @@ -0,0 +1,78 @@ +extends SceneTree + +const MAIN_SCENE := "res://Main.tscn" +const CENTER_BIN := 2 +const DROP_X := 480.0 +const REQUIRED_DROPS := 5 + + +func _init() -> void: + call_deferred("_run") + + +func _run() -> void: + var packed_scene := ResourceLoader.load(MAIN_SCENE) as PackedScene + if packed_scene == null: + _fail("main scene could not be loaded") + return + var game: Node = packed_scene.instantiate() + root.add_child(game) + await process_frame + + var game_manifest: Dictionary = game.get("manifest") + if game_manifest.is_empty(): + _fail("verified manifest was not loaded") + return + for _drop_index in range(REQUIRED_DROPS): + game.call("_drop_rune", DROP_X) + await process_frame + var rune := _pending_rune() + if rune == null: + _fail("drop did not create an unresolved rune") + return + game.call("_on_bin_entered", rune, CENTER_BIN) + await process_frame + + var gameplay: Dictionary = game_manifest["gameplay"] + var scores: Array = gameplay["score_bins"] + var expected_score := REQUIRED_DROPS * int(scores[CENTER_BIN]) + if int(game.get("score")) != expected_score: + _fail("center-bin score does not match the manifest") + return + if int(game.get("collected")) != REQUIRED_DROPS: + _fail("quest collection progress is incorrect") + return + if int(game.get("drop_count")) != REQUIRED_DROPS: + _fail("drop counter is incorrect") + return + if not bool(game.get("run_ended")): + _fail("quest did not reach its completion state") + return + + game.set("locale", "fr") + game.call("_update_interface") + var title_label := game.get("title_label") as Label + if title_label == null or title_label.text != "La Chronique des runes": + _fail("French localization did not load") + return + + print( + "STORYCORE_GAME_SMOKE_PASS score=%d collected=%d locale=fr" % [ + int(game.get("score")), int(game.get("collected")) + ] + ) + game.queue_free() + await process_frame + quit(0) + + +func _pending_rune() -> Node2D: + for candidate in get_nodes_in_group("active_runes"): + if candidate is Node2D and not bool(candidate.get_meta("resolved", false)): + return candidate + return null + + +func _fail(message: String) -> void: + push_error("STORYCORE_GAME_SMOKE_FAIL: " + message) + quit(1) diff --git a/fixtures/storycore-game/coinfall-chronicle/godot/godot_smoke_evidence.json b/fixtures/storycore-game/coinfall-chronicle/godot/godot_smoke_evidence.json new file mode 100644 index 00000000..07588d82 --- /dev/null +++ b/fixtures/storycore-game/coinfall-chronicle/godot/godot_smoke_evidence.json @@ -0,0 +1,34 @@ +{ + "checks": [ + "official-engine-asset-digest", + "headless-editor-import", + "gdscript-parse-log-clean", + "manifest-hash-verified-at-runtime", + "quest-completion", + "score-progression", + "french-localization" + ], + "engine": { + "asset": "Godot_v4.7.2-stable_linux.x86_64.zip", + "asset_sha256": "cadd3204e728a35d3f13adb7fd0d7902636b79f6b95c40c265eb73b6c35329e4", + "commit": "ed1daf0bf", + "source": "https://github.com/godotengine/godot/releases/tag/4.7.2-stable", + "version": "4.7.2.stable.official" + }, + "fixture": { + "main_script_sha256": "1f9acfb94ea5f5d1c47a9e84b2f6005315236e10452b0da04bf52066e21a4e17", + "manifest_content_sha256": "a75e7e506f27cee8e6bec9bcb65054ad264808c3c01508ee4de3e8645196c43a", + "manifest_file_sha256": "21359cddd04a857dcee60c8ba9f320e3edc7d389a22dd61867318898201e1614", + "project_file_sha256": "298ed21234f666b72c4eef0af76bb0c3bcae6a333f6b02c6fceb5c465ee2d2e5", + "smoke_script_sha256": "cb85cbefabc0a1b996023c187ebb05003d79628f14f355eeb884951f79d76fc5" + }, + "observed_at": "2026-08-30T08:58:52Z", + "result": { + "collected": 5, + "locale": "fr", + "marker": "STORYCORE_GAME_SMOKE_PASS", + "score": 250 + }, + "schema_version": "0.1", + "status": "pass" +} diff --git a/fixtures/storycore-game/coinfall-chronicle/godot/project.godot b/fixtures/storycore-game/coinfall-chronicle/godot/project.godot new file mode 100644 index 00000000..4480ba58 --- /dev/null +++ b/fixtures/storycore-game/coinfall-chronicle/godot/project.godot @@ -0,0 +1,23 @@ +; Engine configuration file. +; Edit through Godot when possible; hand edits should remain deterministic. + +config_version=5 + +[application] + +config/name="Coinfall Chronicle" +run/main_scene="res://Main.tscn" + +[display] + +window/size/viewport_width=960 +window/size/viewport_height=720 +window/size/window_width_override=960 +window/size/window_height_override=720 +window/stretch/mode="canvas_items" + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" +textures/default_filters/use_nearest_mipmap_filter=false diff --git a/fixtures/storycore-game/coinfall-chronicle/godot/storycore_game_evidence.json b/fixtures/storycore-game/coinfall-chronicle/godot/storycore_game_evidence.json new file mode 100644 index 00000000..b37af732 --- /dev/null +++ b/fixtures/storycore-game/coinfall-chronicle/godot/storycore_game_evidence.json @@ -0,0 +1,15 @@ +{ + "checks": [ + "strict-schema", + "localized-text-complete", + "unique-identifiers", + "references-resolved", + "public-private-boundary", + "deterministic-content-hash" + ], + "content_sha256": "a75e7e506f27cee8e6bec9bcb65054ad264808c3c01508ee4de3e8645196c43a", + "contract_version": "0.1", + "input_sha256": "6d20fa71aa0ad3d6c722e03bb38c29435acd3815f69cbb5e6701daa37b7d3a81", + "manifest_sha256": "21359cddd04a857dcee60c8ba9f320e3edc7d389a22dd61867318898201e1614", + "status": "pass" +} diff --git a/fixtures/storycore-game/coinfall-chronicle/godot/storycore_game_manifest.json b/fixtures/storycore-game/coinfall-chronicle/godot/storycore_game_manifest.json new file mode 100644 index 00000000..9a2ecd07 --- /dev/null +++ b/fixtures/storycore-game/coinfall-chronicle/godot/storycore_game_manifest.json @@ -0,0 +1,112 @@ +{ + "actors": [ + { + "dialogue": { + "en": [ + "Guide the moon runes through the orchard's old wards.", + "Five recovered runes will relight the chronicle." + ], + "fr": [ + "Guide les runes lunaires à travers les anciennes protections du verger.", + "Cinq runes retrouvées rallumeront la chronique." + ] + }, + "id": "lyra", + "name": { + "en": "Lyra, Keeper of Embers", + "fr": "Lyra, gardienne des braises" + }, + "role": "quest_giver" + } + ], + "boundary": { + "authoritative_backend": "none-fixture", + "private_components_included": false, + "public_contract_only": true + }, + "compiled_by": "storycore-game-bridge/0.1", + "content_sha256": "a75e7e506f27cee8e6bec9bcb65054ad264808c3c01508ee4de3e8645196c43a", + "contract_version": "0.1", + "gameplay": { + "board_seed": 240821, + "drop_limit": 12, + "score_bins": [ + 10, + 20, + 50, + 20, + 10 + ], + "score_target": 100 + }, + "items": [ + { + "id": "moon_rune", + "name": { + "en": "Moon Rune", + "fr": "Rune lunaire" + }, + "score": 10 + }, + { + "id": "ember_seal", + "name": { + "en": "Ember Seal", + "fr": "Sceau de braise" + }, + "score": 0 + } + ], + "locales": [ + "en", + "fr" + ], + "project": { + "id": "coinfall_chronicle", + "license": "MIT", + "summary": { + "en": "A tiny deterministic rune-drop quest proving the public StoryCore-to-game contract.", + "fr": "Une courte quête déterministe de runes démontrant le contrat public StoryCore-vers-jeu." + }, + "title": { + "en": "Coinfall Chronicle", + "fr": "La Chronique des runes" + } + }, + "quest": { + "id": "relight_the_chronicle", + "objectives": [ + { + "id": "recover_moon_runes", + "kind": "collect", + "required_count": 5, + "target_id": "moon_rune" + } + ], + "rewards": [ + { + "count": 1, + "item_id": "ember_seal" + } + ], + "title": { + "en": "Relight the Chronicle", + "fr": "Rallumer la chronique" + } + }, + "runtime": { + "authority": "local-fixture", + "engine": "godot", + "entry_scene": "res://Main.tscn", + "minimum_version": "4.2" + }, + "schema_version": "0.1", + "world": { + "biome": "arcane_orchard", + "id": "ember_orchard", + "title": { + "en": "The Ember Orchard", + "fr": "Le Verger des braises" + } + } +} diff --git a/fixtures/storycore-game/coinfall-chronicle/storycore_game_spec.json b/fixtures/storycore-game/coinfall-chronicle/storycore_game_spec.json new file mode 100644 index 00000000..4eab293e --- /dev/null +++ b/fixtures/storycore-game/coinfall-chronicle/storycore_game_spec.json @@ -0,0 +1,109 @@ +{ + "actors": [ + { + "dialogue": { + "en": [ + "Guide the moon runes through the orchard's old wards.", + "Five recovered runes will relight the chronicle." + ], + "fr": [ + "Guide les runes lunaires à travers les anciennes protections du verger.", + "Cinq runes retrouvées rallumeront la chronique." + ] + }, + "id": "lyra", + "name": { + "en": "Lyra, Keeper of Embers", + "fr": "Lyra, gardienne des braises" + }, + "role": "quest_giver" + } + ], + "boundary": { + "authoritative_backend": "none-fixture", + "private_components_included": false, + "public_contract_only": true + }, + "gameplay": { + "board_seed": 240821, + "drop_limit": 12, + "score_bins": [ + 10, + 20, + 50, + 20, + 10 + ], + "score_target": 100 + }, + "items": [ + { + "id": "moon_rune", + "name": { + "en": "Moon Rune", + "fr": "Rune lunaire" + }, + "score": 10 + }, + { + "id": "ember_seal", + "name": { + "en": "Ember Seal", + "fr": "Sceau de braise" + }, + "score": 0 + } + ], + "locales": [ + "en", + "fr" + ], + "project": { + "id": "coinfall_chronicle", + "license": "MIT", + "summary": { + "en": "A tiny deterministic rune-drop quest proving the public StoryCore-to-game contract.", + "fr": "Une courte quête déterministe de runes démontrant le contrat public StoryCore-vers-jeu." + }, + "title": { + "en": "Coinfall Chronicle", + "fr": "La Chronique des runes" + } + }, + "quest": { + "id": "relight_the_chronicle", + "objectives": [ + { + "id": "recover_moon_runes", + "kind": "collect", + "required_count": 5, + "target_id": "moon_rune" + } + ], + "rewards": [ + { + "count": 1, + "item_id": "ember_seal" + } + ], + "title": { + "en": "Relight the Chronicle", + "fr": "Rallumer la chronique" + } + }, + "runtime": { + "authority": "local-fixture", + "engine": "godot", + "entry_scene": "res://Main.tscn", + "minimum_version": "4.2" + }, + "schema_version": "0.1", + "world": { + "biome": "arcane_orchard", + "id": "ember_orchard", + "title": { + "en": "The Ember Orchard", + "fr": "Le Verger des braises" + } + } +} diff --git a/package-lock.json b/package-lock.json index fb0a1c93..b9851027 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "storycore-engine", "version": "1.0.0", - "license": "ISC", + "license": "MIT", "dependencies": { "@types/react": "^19.2.8", "@types/react-dom": "^19.2.3", @@ -17,7 +17,7 @@ "lucide-react": "^0.562.0", "react": "^19.2.3", "react-dom": "^19.2.3", - "sharp": "^0.35.0", + "sharp": "^0.35.4", "typescript": "^5.9.3", "uuid": "^14.0.0" }, @@ -200,13 +200,13 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", - "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "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.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -215,9 +215,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", - "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "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": { @@ -225,21 +225,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", - "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "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.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6", + "@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", @@ -256,14 +256,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", - "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -273,14 +273,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "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.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@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" @@ -290,9 +290,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "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": { @@ -300,29 +300,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "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.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "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.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -342,9 +342,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "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": { @@ -352,9 +352,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "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": { @@ -362,9 +362,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "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": { @@ -372,27 +372,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "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.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", - "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.6" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -650,33 +650,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "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.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@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.28.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", - "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/generator": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.6", - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -684,14 +684,14 @@ } }, "node_modules/@babel/types": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", - "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1684,10 +1684,20 @@ "license": "Python-2.0" }, "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -1826,9 +1836,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.0.tgz", - "integrity": "sha512-ZgaYEwaj+lx/5n4W8GmZ2IYz0PQHjN5eqRcfijWGB+2Aq7ZInZGa0qJyAn6DEtyLuWHRSrmWOqT9q3qqTBvmUQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", "cpu": [ "arm64" ], @@ -1844,13 +1854,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.0" + "@img/sharp-libvips-darwin-arm64": "1.3.3" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.0.tgz", - "integrity": "sha512-c1z9LFpKB0slQW3RchwBE8iSVzGp70TNjUUO9k4BZwwW4HH7JBGHeIy4b+kk4n/kcBASb9evKCE3/7Slmslgiw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", "cpu": [ "x64" ], @@ -1866,20 +1876,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.0" + "@img/sharp-libvips-darwin-x64": "1.3.3" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.0.tgz", - "integrity": "sha512-Li2KTev0H90kEtnJHkI9xQojXt1AqWmFBMXiPw5kqd1jQgP7gi5HVK/qC5Rmh/59NuAwUuPzzPITmX22NomYYQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.0" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -1889,9 +1899,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.0.tgz", - "integrity": "sha512-EKbmBKtyTH+GPFDRw2TgK2oV6hyxxlJVIar4hoTYSNmIwipgMFdxPQqR392GmfdsPGWga0mCFN1cCKjRb9cljw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", "cpu": [ "arm64" ], @@ -1905,9 +1915,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.0.tgz", - "integrity": "sha512-Pl2OmOvrJ42adUllESxBsG54PfXLo1OYg9i3c5/5Ln/qJ0gZuTM9YMhQJPIbXqwidLRc/c2zuHt4RsrymmNv7A==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", "cpu": [ "x64" ], @@ -1921,15 +1931,12 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.0.tgz", - "integrity": "sha512-A8UpHoUDW4DwnXoV6+q3C1s7QLRAHtPDEjWuNZjwHMyoCNZnm0GeNN8ls9f/bsEYTRQRW96C/n34XJQHJ2fT7A==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1940,15 +1947,12 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.0.tgz", - "integrity": "sha512-C0SqjoFKnszqa44EQ7xoaT48nnO0lOyXEULfXMWi8krrjOPGYkeK30Okzla6ATbBYsyZ0ySinK0FVkpv3DwzfQ==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1959,15 +1963,12 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.0.tgz", - "integrity": "sha512-WOpkVxAjFd369iaIzEgNRreFD+gWdUMIGD5zplhNKNeqS6mm5dac3q2AFyCBmzYoAdouzZvRBgxy4z8QHZb4/A==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1978,15 +1979,12 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.0.tgz", - "integrity": "sha512-DRWw0mOHusrCCuw2rqP87oLg6PGlkomVDFqw2hIwsSfwWpu4k3XLcBPaKKl6ct/GtL/cwNkgwjV/tc0Mqht3VA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1997,15 +1995,12 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.0.tgz", - "integrity": "sha512-9APy+nFWhHS+kzLgWZfLcyrUd7YqnAQVa4BPOo4xkoHpdoktOAPG4cEr9+Jpl0TtqfVmcMJimNL5qNTyyOHZNA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2016,15 +2011,12 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.0.tgz", - "integrity": "sha512-y9RNUYDe2A1UAdhLyfeOodGRszQdaEoe4nfOpp/sNVPl2CWIcUyFaDoCh4vPLPxu19803j2naLqZup2WxDXCLA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2035,15 +2027,12 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.0.tgz", - "integrity": "sha512-cC1wkC0Mlucd0KSiGrLkJnB/ZqPvZCntc/Lk7ZnYO5ZSbF2euNek4Xvxafojq+wN1q/W0eprdpUIjUr/EV2PBg==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2054,15 +2043,12 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.0.tgz", - "integrity": "sha512-LiYMhUZicB1QG//+RvmYZpXJO8fYRENfp+MZUCnG9aw+AKvGAy9gPaCnuwsPcBFs8EV66M0NNxj9VHcNklE8zw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2073,15 +2059,12 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.0.tgz", - "integrity": "sha512-VVlpEWwizEFIOom0zdoeKuO5nuTswzVE5uHcBNvHzmeHUpNFajY3HFfbQ+zIH4E2kVaZ/yVxmsShW56TtEy4uA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2094,19 +2077,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.0" + "@img/sharp-libvips-linux-arm": "1.3.3" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.0.tgz", - "integrity": "sha512-4+4XHLNT5wDT0roYlHTEmH9lDKt0acf9Tv+3hM3iceOirkxrR404/3WjAYZ9F9CkHrxeRcGLJXbi4vluMZ9O+A==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2119,19 +2099,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.0" + "@img/sharp-libvips-linux-arm64": "1.3.3" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.0.tgz", - "integrity": "sha512-N3hzbEpUTJC8pWpPVJvgzGxM+so/MAXc8O2s/53B0LL9ZGpfXpME7Wizkc5d/8fRBlBtkDjzoZGDCqqNDHqLEw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2144,19 +2121,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.0" + "@img/sharp-libvips-linux-ppc64": "1.3.3" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.0.tgz", - "integrity": "sha512-l6vmKVPnbS0RhVMbyxP5meAARsbhCnBN4fy31qz0+3a6Rv4jEqfzDrT89y6ZPkCi0AJGnwp2En528yXo401Hpw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2169,19 +2143,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.0" + "@img/sharp-libvips-linux-riscv64": "1.3.3" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.0.tgz", - "integrity": "sha512-MYlMiPFiv/EKPAHnp3yNZ9AAWFsxga9c5Bkc6wkar6bqzHLlkGVJHRm0u1ei+VXnZxp3Mz9MG9ZIsI8vSOf3sQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2194,19 +2165,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.0" + "@img/sharp-libvips-linux-s390x": "1.3.3" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.0.tgz", - "integrity": "sha512-TYaItB5oj1ioXjhyn2xrR208vf+YuIIcHptQWRRaBmFhvIvL9D72DXN8w75xup0KXA8UdEAhQ9Qb2S49FD/9Cw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2219,19 +2187,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.0" + "@img/sharp-libvips-linux-x64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.0.tgz", - "integrity": "sha512-DSTb6ijQzqe6DdAaOBVqJ/SYf1vO8EW5bK6X6LRXufEBebf2722VCdvBUtZ3rtV0x2ApfPNDy/p7LrrjaWjiyQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2244,19 +2209,16 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.0" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.0.tgz", - "integrity": "sha512-K7ykQ+26Rt6+4BTU80AuGgTPIYX86UxiAKT4rcXX/WNTo7k1ZxpKz+TguHnwVpCqQK3B5PK0vZ0ZBe6nz/ib1w==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2269,17 +2231,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.0" + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.0.tgz", - "integrity": "sha512-9woLIFORERCr+6cWu87dQ22J34EExkhc73U1kZW0c+RclQqWetoodByp4dWZ/hN8/KVmTRAx2HOnUwib8AwZdA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.11.0" + "@emnapi/runtime": "^1.11.3" }, "engines": { "node": ">=20.9.0" @@ -2289,16 +2251,16 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.0.tgz", - "integrity": "sha512-t+kie1TOyaDM6Dho+f+y0VqIUNhYQaKCUahuZVi0E0frgdiaOaPsDxDW3wfKacUdaNBCnK/ZDBMg33ydvHj8uA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.0" + "@img/sharp-wasm32": "0.35.4" }, "engines": { "node": ">=20.9.0" @@ -2308,9 +2270,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.0.tgz", - "integrity": "sha512-M5eKxug0dabbaWgFKvPa3odNs2OpaP+81NASfGKkt4GcYXpNhSu7CaeYxWkLNV6vHmUp4hnCxnxrUyhUJhXbKA==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", "cpu": [ "arm64" ], @@ -2327,9 +2289,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.0.tgz", - "integrity": "sha512-z0+pZ03QCDvdVN0Ez9IX/yjWC19ikMlXrmdYMwYNLTh2BLPx3hXWPvyqWfquZ0BTO9O6GVOjIVoTcyyacMnWlQ==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", "cpu": [ "ia32" ], @@ -2346,9 +2308,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.0.tgz", - "integrity": "sha512-feNnlz5ZHKr0MY1LPHvZQyJeBkbo4ctsn0D8FvA53VTw5TC63rfEL2UrWbkSBR19htSE7Mw78xYVwdJqoMWVHw==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", "cpu": [ "x64" ], @@ -4294,9 +4256,9 @@ "license": "ISC" }, "node_modules/@xmldom/xmldom": { - "version": "0.8.14", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.14.tgz", - "integrity": "sha512-T4EDRUBVZYRldYApjEJiU0e1stYWaRAX7CuSnKzrpwdZKo53zGV8/pqfzV6FfwNl9YThD2OumQYvqtvjvgG7aQ==", + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", "dev": true, "license": "MIT", "engines": { @@ -4661,9 +4623,9 @@ } }, "node_modules/app-builder-lib/node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -5013,13 +4975,16 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.14", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", - "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==", + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/bidi-js": { @@ -5073,9 +5038,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "dev": true, "funding": [ { @@ -5093,11 +5058,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -5184,9 +5149,9 @@ "license": "Python-2.0" }, "node_modules/builder-util/node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -5306,9 +5271,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001764", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", - "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -5894,9 +5859,9 @@ "license": "Python-2.0" }, "node_modules/dmg-builder/node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -6099,9 +6064,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "version": "1.5.423", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.423.tgz", + "integrity": "sha512-rRZfTSY8ptHYMQxa+uIycJMFKmY1T0GIApNMXJYGehguTZa56TEEl19pKPCoBqk5Gpf7QizZn/jt7xur+DYxag==", "dev": true, "license": "ISC" }, @@ -6130,10 +6095,20 @@ "license": "Python-2.0" }, "node_modules/electron-updater/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -6555,10 +6530,20 @@ } }, "node_modules/eslint/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -6779,9 +6764,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "dev": true, "funding": [ { @@ -8374,9 +8359,9 @@ } }, "node_modules/joi": { - "version": "18.0.2", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.2.tgz", - "integrity": "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.9.tgz", + "integrity": "sha512-2mD929bUVKUhOLQQEVhlf6EZ0Mlo0DeRb5MO7cViR9AXLtBauuccEtB1py9Ocxpo/P7ucnh442iY/iOwrh3IQw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -8386,7 +8371,7 @@ "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", - "@standard-schema/spec": "^1.0.0" + "@standard-schema/spec": "^1.1.0" }, "engines": { "node": ">= 20" @@ -8400,9 +8385,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", "dev": true, "license": "MIT", "dependencies": { @@ -9084,11 +9069,14 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/nopt": { "version": "9.0.0", @@ -10054,14 +10042,14 @@ } }, "node_modules/sharp": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.0.tgz", - "integrity": "sha512-BqvG5XbwPZ4NV0DK90d86leEECMsoa8bO0nqnKWlBDYxri4GJ7c4EDInaF6q20lTh/mATmnDIKWJFfXnoVfH5g==", + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "license": "Apache-2.0", "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.8.4" + "semver": "^7.8.5" }, "engines": { "node": ">=20.9.0" @@ -10070,31 +10058,36 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.0", - "@img/sharp-darwin-x64": "0.35.0", - "@img/sharp-freebsd-wasm32": "0.35.0", - "@img/sharp-libvips-darwin-arm64": "1.3.0", - "@img/sharp-libvips-darwin-x64": "1.3.0", - "@img/sharp-libvips-linux-arm": "1.3.0", - "@img/sharp-libvips-linux-arm64": "1.3.0", - "@img/sharp-libvips-linux-ppc64": "1.3.0", - "@img/sharp-libvips-linux-riscv64": "1.3.0", - "@img/sharp-libvips-linux-s390x": "1.3.0", - "@img/sharp-libvips-linux-x64": "1.3.0", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.0", - "@img/sharp-libvips-linuxmusl-x64": "1.3.0", - "@img/sharp-linux-arm": "0.35.0", - "@img/sharp-linux-arm64": "0.35.0", - "@img/sharp-linux-ppc64": "0.35.0", - "@img/sharp-linux-riscv64": "0.35.0", - "@img/sharp-linux-s390x": "0.35.0", - "@img/sharp-linux-x64": "0.35.0", - "@img/sharp-linuxmusl-arm64": "0.35.0", - "@img/sharp-linuxmusl-x64": "0.35.0", - "@img/sharp-webcontainers-wasm32": "0.35.0", - "@img/sharp-win32-arm64": "0.35.0", - "@img/sharp-win32-ia32": "0.35.0", - "@img/sharp-win32-x64": "0.35.0" + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/sharp/node_modules/semver": { @@ -10814,9 +10807,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { @@ -10870,9 +10863,9 @@ } }, "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==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index a113840b..9e3f27ee 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "electron" ], "author": "StoryCore Team", - "license": "ISC", + "license": "MIT", "type": "commonjs", "bugs": { "url": "https://github.com/zedarvates/StoryCore-Engine/issues" @@ -57,7 +57,7 @@ "lucide-react": "^0.562.0", "react": "^19.2.3", "react-dom": "^19.2.3", - "sharp": "^0.35.0", + "sharp": "^0.35.4", "typescript": "^5.9.3", "uuid": "^14.0.0" }, diff --git a/schemas/music-plan-v1.schema.json b/schemas/music-plan-v1.schema.json new file mode 100644 index 00000000..e73d000d --- /dev/null +++ b/schemas/music-plan-v1.schema.json @@ -0,0 +1,121 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://storycore.local/schemas/music-plan-v1.schema.json", + "title": "StoryCore MusicPlan v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "plan_id", + "mode", + "duration_seconds", + "sections", + "sync_cues", + "provenance" + ], + "properties": { + "schema_version": {"const": 1}, + "plan_id": {"type": "string", "minLength": 1, "maxLength": 128}, + "mode": {"enum": ["full", "guided", "free"]}, + "title": {"type": "string", "maxLength": 256}, + "narrative_intent": {"type": "string", "maxLength": 4000}, + "duration_seconds": {"type": "number", "exclusiveMinimum": 0, "maximum": 7200}, + "tempo_bpm": {"type": ["number", "null"], "minimum": 20, "maximum": 300}, + "meter": {"type": ["string", "null"], "maxLength": 32}, + "tonal_center": {"type": ["string", "null"], "maxLength": 64}, + "sections": { + "type": "array", + "minItems": 1, + "maxItems": 128, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "start_seconds", "end_seconds", "purpose"], + "properties": { + "id": {"type": "string", "minLength": 1, "maxLength": 128}, + "start_seconds": {"type": "number", "minimum": 0}, + "end_seconds": {"type": "number", "exclusiveMinimum": 0}, + "purpose": {"type": "string", "minLength": 1, "maxLength": 1000}, + "energy": {"type": ["number", "null"], "minimum": 0, "maximum": 1}, + "tension": {"type": ["number", "null"], "minimum": 0, "maximum": 1}, + "dialogue_safe": {"type": "boolean", "default": false}, + "motif_refs": { + "type": "array", + "items": {"type": "string", "minLength": 1, "maxLength": 128}, + "uniqueItems": true, + "default": [] + }, + "instrument_roles": { + "type": "array", + "items": {"type": "string", "minLength": 1, "maxLength": 128}, + "uniqueItems": true, + "default": [] + } + } + } + }, + "motifs": { + "type": "array", + "maxItems": 64, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "intent"], + "properties": { + "id": {"type": "string", "minLength": 1, "maxLength": 128}, + "intent": {"type": "string", "minLength": 1, "maxLength": 1000}, + "symbolic_hint": {"type": ["string", "null"], "maxLength": 2000} + } + }, + "default": [] + }, + "sync_cues": { + "type": "array", + "maxItems": 256, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "time_seconds", "intent"], + "properties": { + "id": {"type": "string", "minLength": 1, "maxLength": 128}, + "time_seconds": {"type": "number", "minimum": 0}, + "intent": {"type": "string", "minLength": 1, "maxLength": 1000}, + "story_ref": {"type": ["string", "null"], "maxLength": 512} + } + } + }, + "lyrics": { + "type": ["object", "null"], + "additionalProperties": false, + "required": ["language", "text"], + "properties": { + "language": {"type": "string", "minLength": 2, "maxLength": 35}, + "text": {"type": "string", "maxLength": 20000} + } + }, + "provenance": { + "type": "object", + "additionalProperties": false, + "required": ["commercial_target", "dependencies"], + "properties": { + "commercial_target": {"type": "boolean"}, + "dependencies": { + "type": "array", + "maxItems": 128, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "kind", "license"], + "properties": { + "name": {"type": "string", "minLength": 1, "maxLength": 256}, + "kind": {"enum": ["code", "model-weights", "dataset", "asset", "other"]}, + "license": {"type": "string", "minLength": 1, "maxLength": 256}, + "version": {"type": ["string", "null"], "maxLength": 128}, + "source": {"type": ["string", "null"], "maxLength": 1000} + } + } + } + } + } + } +} diff --git a/scripts/music_plan_holdout_evaluator.py b/scripts/music_plan_holdout_evaluator.py new file mode 100644 index 00000000..3526cc6b --- /dev/null +++ b/scripts/music_plan_holdout_evaluator.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""JSON-stdin evaluator for private MusicPlan holdouts. + +This file contains no holdout cases. It is designed to be invoked by Botte +Secrete's private holdout runner. The private payload supplies a MusicPlan and a +hidden expected-validity bit; stdout returns only {"passed": bool}. +""" + +from __future__ import annotations + +import json +import sys +from typing import Any + +from src.music_plan import validate_music_plan +from src.music_provider import MockMusicProvider, MusicProviderContractError + + +def evaluate(payload: dict[str, Any]) -> bool: + wrapper = payload.get("input") + if not isinstance(wrapper, dict): + return False + plan = wrapper.get("plan") + expected_valid = wrapper.get("expected_valid") + if not isinstance(plan, dict) or not isinstance(expected_valid, bool): + return False + + validation = validate_music_plan(plan) + validator_valid = validation.valid + + provider_valid = True + try: + MockMusicProvider().prepare(plan) + except MusicProviderContractError: + provider_valid = False + except Exception: + return False + + observed_valid = validator_valid and provider_valid + return observed_valid is expected_valid + + +def main() -> int: + try: + payload = json.loads(sys.stdin.read()) + except json.JSONDecodeError: + print(json.dumps({"passed": False})) + return 0 + if not isinstance(payload, dict): + print(json.dumps({"passed": False})) + return 0 + print(json.dumps({"passed": evaluate(payload)})) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/music_plan_mutation_probe.py b/scripts/music_plan_mutation_probe.py new file mode 100644 index 00000000..69dc253d --- /dev/null +++ b/scripts/music_plan_mutation_probe.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Negative-control probe for the MusicPlan contract. + +This is deliberately separate from the ordinary unit suite. It starts from the +known-good full fixture, injects bounded invalid mutations, and succeeds only if +the public validation/provider boundary rejects every mutant. +""" + +from __future__ import annotations + +import copy +import json +from pathlib import Path + +from src.music_plan import validate_music_plan +from src.music_provider import MockMusicProvider, MusicProviderContractError + +FIXTURE = Path(__file__).resolve().parents[1] / "tests" / "fixtures" / "music_plan" / "full.json" + + +def _base() -> dict: + return json.loads(FIXTURE.read_text(encoding="utf-8")) + + +def _mutants() -> list[tuple[str, dict]]: + cases: list[tuple[str, dict]] = [] + + missing_id = copy.deepcopy(_base()) + missing_id.pop("plan_id", None) + cases.append(("missing-plan-id", missing_id)) + + invalid_energy = copy.deepcopy(_base()) + invalid_energy["sections"][0]["energy"] = 2 + cases.append(("energy-out-of-range", invalid_energy)) + + unknown_license = copy.deepcopy(_base()) + unknown_license["provenance"]["dependencies"].append({ + "name": "mutation-unknown-weights", + "kind": "model-weights", + "license": "unknown", + }) + cases.append(("unknown-commercial-model-license", unknown_license)) + + unresolved_motif = copy.deepcopy(_base()) + unresolved_motif["sections"][0]["motif_refs"] = ["mutation-missing-motif"] + cases.append(("unresolved-motif", unresolved_motif)) + + overlapping = copy.deepcopy(_base()) + overlapping["sections"][1]["start_seconds"] = overlapping["sections"][0]["start_seconds"] + cases.append(("overlapping-sections", overlapping)) + + return cases + + +def main() -> int: + failures: list[str] = [] + provider = MockMusicProvider() + + base_validation = validate_music_plan(_base()) + if not base_validation.valid: + print("FAIL positive control: reference fixture no longer validates") + return 1 + print("PASS positive control: reference fixture validates") + + for name, mutant in _mutants(): + validation = validate_music_plan(mutant) + validator_rejected = not validation.valid + provider_rejected = False + try: + provider.prepare(mutant) + except MusicProviderContractError: + provider_rejected = True + except Exception as exc: # fail closed, but distinguish contract leakage + failures.append(f"{name}: leaked {type(exc).__name__} instead of MusicProviderContractError") + print(f"FAIL {name}: unexpected exception {type(exc).__name__}") + continue + + if validator_rejected and provider_rejected: + print(f"PASS mutation rejected: {name}") + else: + failures.append( + f"{name}: validator_rejected={validator_rejected}, provider_rejected={provider_rejected}" + ) + print(f"FAIL mutation survived: {name}") + + if failures: + print("\nNEGATIVE CONTROL FAILED") + for failure in failures: + print(f"- {failure}") + return 1 + + print("\nNEGATIVE CONTROL PASSED: every controlled mutant was rejected") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/game_bridge/__init__.py b/src/game_bridge/__init__.py new file mode 100644 index 00000000..f77b0a2d --- /dev/null +++ b/src/game_bridge/__init__.py @@ -0,0 +1,17 @@ +"""Public, engine-neutral contracts for StoryCore game exports.""" + +from .contract import ( + CONTRACT_VERSION, + ContractError, + compile_spec, + validate_and_normalize, + verify_manifest, +) + +__all__ = [ + "CONTRACT_VERSION", + "ContractError", + "compile_spec", + "validate_and_normalize", + "verify_manifest", +] diff --git a/src/game_bridge/__main__.py b/src/game_bridge/__main__.py new file mode 100644 index 00000000..a7d2abaa --- /dev/null +++ b/src/game_bridge/__main__.py @@ -0,0 +1,7 @@ +"""Command-line entry point for ``python -m src.game_bridge``.""" + +from .contract import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/game_bridge/contract.py b/src/game_bridge/contract.py new file mode 100644 index 00000000..6300452b --- /dev/null +++ b/src/game_bridge/contract.py @@ -0,0 +1,602 @@ +"""Deterministic StoryCore-to-game manifest compiler. + +This module intentionally contains no Ultimate Odycer server implementation, +private reasoning logic, model credentials, or provider-specific code. It is a +small public interchange contract that a Godot fixture or an external +authoritative backend can consume. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +from pathlib import Path +from typing import Any, Mapping, Sequence + +CONTRACT_VERSION = "0.1" +COMPILER_ID = "storycore-game-bridge/0.1" + +_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]*$") +_LOCALE_PATTERN = re.compile(r"^[a-z]{2}(?:-[A-Z]{2})?$") +_GODOT_VERSION_PATTERN = re.compile(r"^4\.\d+(?:\.\d+)?$") +_CLI_RELATIVE_PATH_PATTERN = re.compile(r"^[A-Za-z0-9._/-]+$") +_ACTORS_PATH = "spec.actors" +_ITEMS_PATH = "spec.items" +_OBJECTIVES_PATH = "spec.quest.objectives" +_ENTRY_SCENE_PATH = "spec.runtime.entry_scene" + + +class ContractError(ValueError): + """Raised when a StoryCore game specification fails closed.""" + + +def _fail(path: str, message: str) -> None: + raise ContractError(f"{path}: {message}") + + +def _require_mapping(value: Any, path: str) -> dict[str, Any]: + if not isinstance(value, Mapping): + _fail(path, "must be an object") + return dict(value) + + +def _require_sequence(value: Any, path: str) -> list[Any]: + if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)): + _fail(path, "must be an array") + return list(value) + + +def _require_exact_keys( + value: Mapping[str, Any], required: set[str], path: str +) -> None: + keys = set(value) + missing = sorted(required - keys) + unknown = sorted(keys - required) + if missing: + _fail(path, f"missing required fields: {', '.join(missing)}") + if unknown: + _fail(path, f"unknown fields: {', '.join(unknown)}") + + +def _require_id(value: Any, path: str) -> str: + if not isinstance(value, str) or not _ID_PATTERN.fullmatch(value): + _fail(path, "must match ^[a-z0-9][a-z0-9_-]*$") + return value + + +def _require_non_empty_string(value: Any, path: str) -> str: + if not isinstance(value, str) or not value.strip(): + _fail(path, "must be a non-empty string") + return value.strip() + + +def _require_positive_int(value: Any, path: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + _fail(path, "must be a positive integer") + return value + + +def _require_non_negative_int(value: Any, path: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + _fail(path, "must be a non-negative integer") + return value + + +def _localized_text(value: Any, locales: list[str], path: str) -> dict[str, str]: + data = _require_mapping(value, path) + expected = set(locales) + actual = set(data) + if actual != expected: + missing = sorted(expected - actual) + unknown = sorted(actual - expected) + details: list[str] = [] + if missing: + details.append(f"missing locales: {', '.join(missing)}") + if unknown: + details.append(f"unknown locales: {', '.join(unknown)}") + _fail(path, "; ".join(details)) + return { + locale: _require_non_empty_string(data[locale], f"{path}.{locale}") + for locale in locales + } + + +def _localized_dialogue( + value: Any, locales: list[str], path: str +) -> dict[str, list[str]]: + data = _require_mapping(value, path) + expected = set(locales) + actual = set(data) + if actual != expected: + _fail(path, "must contain exactly the declared locales") + + normalized: dict[str, list[str]] = {} + for locale in locales: + lines = _require_sequence(data[locale], f"{path}.{locale}") + if not lines: + _fail(f"{path}.{locale}", "must contain at least one line") + normalized[locale] = [ + _require_non_empty_string(line, f"{path}.{locale}[{index}]") + for index, line in enumerate(lines) + ] + return normalized + + +def _unique_ids(entries: list[dict[str, Any]], path: str) -> set[str]: + seen: set[str] = set() + for index, entry in enumerate(entries): + entry_id = entry["id"] + if entry_id in seen: + _fail(f"{path}[{index}].id", f"duplicate id: {entry_id}") + seen.add(entry_id) + return seen + + +def _canonical_bytes(value: Any) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + + +def _sha256(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def _normalize_locales(value: Any) -> list[str]: + path = "spec.locales" + raw_locales = _require_sequence(value, path) + if not raw_locales: + _fail(path, "must contain at least one locale") + locales: list[str] = [] + for index, raw_locale in enumerate(raw_locales): + entry_path = f"{path}[{index}]" + if not isinstance(raw_locale, str) or not _LOCALE_PATTERN.fullmatch(raw_locale): + _fail(entry_path, "must be a supported locale code") + if raw_locale in locales: + _fail(entry_path, f"duplicate locale: {raw_locale}") + locales.append(raw_locale) + return locales + + +def _normalize_project(value: Any, locales: list[str]) -> dict[str, Any]: + path = "spec.project" + project = _require_mapping(value, path) + _require_exact_keys(project, {"id", "title", "summary", "license"}, path) + if project["license"] != "MIT": + _fail(f"{path}.license", "public StoryCore fixtures must use MIT") + return { + "id": _require_id(project["id"], f"{path}.id"), + "title": _localized_text(project["title"], locales, f"{path}.title"), + "summary": _localized_text(project["summary"], locales, f"{path}.summary"), + "license": "MIT", + } + + +def _normalize_world(value: Any, locales: list[str]) -> dict[str, Any]: + path = "spec.world" + world = _require_mapping(value, path) + _require_exact_keys(world, {"id", "title", "biome"}, path) + return { + "id": _require_id(world["id"], f"{path}.id"), + "title": _localized_text(world["title"], locales, f"{path}.title"), + "biome": _require_id(world["biome"], f"{path}.biome"), + } + + +def _normalize_actor(value: Any, index: int, locales: list[str]) -> dict[str, Any]: + path = f"{_ACTORS_PATH}[{index}]" + actor = _require_mapping(value, path) + _require_exact_keys(actor, {"id", "name", "role", "dialogue"}, path) + return { + "id": _require_id(actor["id"], f"{path}.id"), + "name": _localized_text(actor["name"], locales, f"{path}.name"), + "role": _require_id(actor["role"], f"{path}.role"), + "dialogue": _localized_dialogue( + actor["dialogue"], locales, f"{path}.dialogue" + ), + } + + +def _normalize_actors(value: Any, locales: list[str]) -> list[dict[str, Any]]: + raw_actors = _require_sequence(value, _ACTORS_PATH) + if not raw_actors: + _fail(_ACTORS_PATH, "must contain at least one actor") + actors = [ + _normalize_actor(raw_actor, index, locales) + for index, raw_actor in enumerate(raw_actors) + ] + _unique_ids(actors, _ACTORS_PATH) + return actors + + +def _normalize_item(value: Any, index: int, locales: list[str]) -> dict[str, Any]: + path = f"{_ITEMS_PATH}[{index}]" + item = _require_mapping(value, path) + _require_exact_keys(item, {"id", "name", "score"}, path) + return { + "id": _require_id(item["id"], f"{path}.id"), + "name": _localized_text(item["name"], locales, f"{path}.name"), + "score": _require_non_negative_int(item["score"], f"{path}.score"), + } + + +def _normalize_items( + value: Any, locales: list[str] +) -> tuple[list[dict[str, Any]], set[str]]: + raw_items = _require_sequence(value, _ITEMS_PATH) + if not raw_items: + _fail(_ITEMS_PATH, "must contain at least one item") + items = [ + _normalize_item(raw_item, index, locales) + for index, raw_item in enumerate(raw_items) + ] + return items, _unique_ids(items, _ITEMS_PATH) + + +def _normalize_objective( + value: Any, index: int, item_ids: set[str] +) -> dict[str, Any]: + path = f"{_OBJECTIVES_PATH}[{index}]" + objective = _require_mapping(value, path) + _require_exact_keys( + objective, {"id", "kind", "target_id", "required_count"}, path + ) + if objective["kind"] != "collect": + _fail(f"{path}.kind", "v0.1 supports only the collect objective") + target_id = _require_id(objective["target_id"], f"{path}.target_id") + if target_id not in item_ids: + _fail(f"{path}.target_id", f"unresolved item reference: {target_id}") + return { + "id": _require_id(objective["id"], f"{path}.id"), + "kind": "collect", + "target_id": target_id, + "required_count": _require_positive_int( + objective["required_count"], f"{path}.required_count" + ), + } + + +def _normalize_reward(value: Any, index: int, item_ids: set[str]) -> dict[str, Any]: + path = f"spec.quest.rewards[{index}]" + reward = _require_mapping(value, path) + _require_exact_keys(reward, {"item_id", "count"}, path) + item_id = _require_id(reward["item_id"], f"{path}.item_id") + if item_id not in item_ids: + _fail(f"{path}.item_id", f"unresolved item reference: {item_id}") + return { + "item_id": item_id, + "count": _require_positive_int(reward["count"], f"{path}.count"), + } + + +def _normalize_quest( + value: Any, locales: list[str], item_ids: set[str] +) -> dict[str, Any]: + path = "spec.quest" + quest = _require_mapping(value, path) + _require_exact_keys(quest, {"id", "title", "objectives", "rewards"}, path) + raw_objectives = _require_sequence(quest["objectives"], _OBJECTIVES_PATH) + if not raw_objectives: + _fail(_OBJECTIVES_PATH, "must contain at least one objective") + objectives = [ + _normalize_objective(raw_objective, index, item_ids) + for index, raw_objective in enumerate(raw_objectives) + ] + _unique_ids(objectives, _OBJECTIVES_PATH) + raw_rewards = _require_sequence(quest["rewards"], f"{path}.rewards") + rewards = [ + _normalize_reward(raw_reward, index, item_ids) + for index, raw_reward in enumerate(raw_rewards) + ] + return { + "id": _require_id(quest["id"], f"{path}.id"), + "title": _localized_text(quest["title"], locales, f"{path}.title"), + "objectives": objectives, + "rewards": rewards, + } + + +def _normalize_gameplay(value: Any) -> dict[str, Any]: + path = "spec.gameplay" + bins_path = f"{path}.score_bins" + gameplay = _require_mapping(value, path) + _require_exact_keys( + gameplay, {"drop_limit", "score_target", "board_seed", "score_bins"}, path + ) + raw_bins = _require_sequence(gameplay["score_bins"], bins_path) + if len(raw_bins) < 3: + _fail(bins_path, "must contain at least three bins") + score_bins = [ + _require_positive_int(bin_score, f"{bins_path}[{index}]") + for index, bin_score in enumerate(raw_bins) + ] + return { + "drop_limit": _require_positive_int( + gameplay["drop_limit"], f"{path}.drop_limit" + ), + "score_target": _require_positive_int( + gameplay["score_target"], f"{path}.score_target" + ), + "board_seed": _require_non_negative_int( + gameplay["board_seed"], f"{path}.board_seed" + ), + "score_bins": score_bins, + } + + +def _normalize_runtime(value: Any) -> dict[str, Any]: + path = "spec.runtime" + runtime = _require_mapping(value, path) + _require_exact_keys( + runtime, {"engine", "minimum_version", "entry_scene", "authority"}, path + ) + if runtime["engine"] != "godot": + _fail(f"{path}.engine", "v0.1 supports only godot") + if runtime["authority"] not in {"local-fixture", "external-contract"}: + _fail(f"{path}.authority", "must be local-fixture or external-contract") + entry_scene = _require_non_empty_string(runtime["entry_scene"], _ENTRY_SCENE_PATH) + if not entry_scene.startswith("res://"): + _fail(_ENTRY_SCENE_PATH, "must be a res:// path") + if ".." in Path(entry_scene.removeprefix("res://")).parts: + _fail(_ENTRY_SCENE_PATH, "must not traverse outside res://") + if not entry_scene.endswith(".tscn"): + _fail(_ENTRY_SCENE_PATH, "must reference a .tscn scene") + version_path = f"{path}.minimum_version" + minimum_version = _require_non_empty_string( + runtime["minimum_version"], version_path + ) + if not _GODOT_VERSION_PATTERN.fullmatch(minimum_version): + _fail(version_path, "must be a Godot 4 version such as 4.2 or 4.2.1") + return { + "engine": "godot", + "minimum_version": minimum_version, + "entry_scene": entry_scene, + "authority": runtime["authority"], + } + + +def _normalize_boundary(value: Any, runtime_authority: str) -> dict[str, Any]: + path = "spec.boundary" + backend_path = f"{path}.authoritative_backend" + boundary = _require_mapping(value, path) + _require_exact_keys( + boundary, + { + "public_contract_only", + "private_components_included", + "authoritative_backend", + }, + path, + ) + if boundary["public_contract_only"] is not True: + _fail(f"{path}.public_contract_only", "must be true") + if boundary["private_components_included"] is not False: + _fail(f"{path}.private_components_included", "must be false") + backend = boundary["authoritative_backend"] + if backend not in {"none-fixture", "external"}: + _fail(backend_path, "must be none-fixture or external") + if (runtime_authority, backend) not in { + ("local-fixture", "none-fixture"), + ("external-contract", "external"), + }: + _fail( + backend_path, + "must match runtime authority (local-fixture/none-fixture or " + "external-contract/external)", + ) + return { + "public_contract_only": True, + "private_components_included": False, + "authoritative_backend": backend, + } + + +def validate_and_normalize(spec: Mapping[str, Any]) -> dict[str, Any]: + """Validate an interchange specification and return normalized data. + + Validation is deliberately strict. Unknown fields, unresolved references, + partial localizations, and attempts to include private components are + rejected rather than guessed or silently discarded. + """ + + root = _require_mapping(spec, "spec") + _require_exact_keys( + root, + { + "schema_version", + "project", + "locales", + "world", + "actors", + "items", + "quest", + "gameplay", + "runtime", + "boundary", + }, + "spec", + ) + if root["schema_version"] != CONTRACT_VERSION: + _fail( + "spec.schema_version", + f"expected {CONTRACT_VERSION!r}, got {root['schema_version']!r}", + ) + + locales = _normalize_locales(root["locales"]) + items, item_ids = _normalize_items(root["items"], locales) + runtime = _normalize_runtime(root["runtime"]) + return { + "schema_version": CONTRACT_VERSION, + "project": _normalize_project(root["project"], locales), + "locales": locales, + "world": _normalize_world(root["world"], locales), + "actors": _normalize_actors(root["actors"], locales), + "items": items, + "quest": _normalize_quest(root["quest"], locales, item_ids), + "gameplay": _normalize_gameplay(root["gameplay"]), + "runtime": runtime, + "boundary": _normalize_boundary(root["boundary"], runtime["authority"]), + } + + +def compile_spec(spec: Mapping[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + """Compile a validated spec into a manifest and reproducible evidence.""" + + normalized = validate_and_normalize(spec) + input_sha256 = _sha256(_canonical_bytes(normalized)) + manifest_core = { + "contract_version": CONTRACT_VERSION, + "compiled_by": COMPILER_ID, + **normalized, + } + content_sha256 = _sha256(_canonical_bytes(manifest_core)) + manifest = {**manifest_core, "content_sha256": content_sha256} + manifest_bytes = ( + json.dumps(manifest, ensure_ascii=False, sort_keys=True, indent=2) + "\n" + ).encode("utf-8") + evidence = { + "contract_version": CONTRACT_VERSION, + "status": "pass", + "input_sha256": input_sha256, + "content_sha256": content_sha256, + "manifest_sha256": _sha256(manifest_bytes), + "checks": [ + "strict-schema", + "localized-text-complete", + "unique-identifiers", + "references-resolved", + "public-private-boundary", + "deterministic-content-hash", + ], + } + return manifest, evidence + + +def verify_manifest(manifest: Mapping[str, Any]) -> bool: + """Return whether a manifest's embedded content hash is valid.""" + + candidate = _require_mapping(manifest, "manifest") + embedded = candidate.pop("content_sha256", None) + return isinstance(embedded, str) and embedded == _sha256( + _canonical_bytes(candidate) + ) + + +def _require_relative_cli_path(value: str, label: str) -> Path: + """Accept only an allowlisted workspace-relative CLI path.""" + + if not value or not _CLI_RELATIVE_PATH_PATTERN.fullmatch(value): + _fail(label, "must be an allowlisted workspace-relative path") + candidate = Path(value) + if candidate.is_absolute() or ".." in candidate.parts: + _fail(label, "must be an allowlisted workspace-relative path") + return candidate + + +def _resolve_workspace_path( + candidate: Path, + workspace_root: Path, + label: str, + *, + must_be_file: bool = False, + must_be_dir: bool = False, +) -> Path: + """Resolve a validated relative path without allowing symlink escape.""" + + try: + root = workspace_root.resolve(strict=True) + resolved = (root / candidate).resolve(strict=True) + relative = resolved.relative_to(root) + except (OSError, RuntimeError, ValueError) as exc: + raise ContractError( + f"{label}: must stay within workspace root {workspace_root}" + ) from exc + if not root.is_dir(): + _fail("workspace", "root must be a directory") + if must_be_file and not resolved.is_file(): + _fail(label, "must reference an existing regular file") + if must_be_dir and not resolved.is_dir(): + _fail(label, "must reference an existing directory") + return root.joinpath(relative) + + +def _load_json(path: Path) -> dict[str, Any]: + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ContractError(f"unable to read {path}: {exc}") from exc + return _require_mapping(loaded, str(path)) + + +def _write_json(path: Path, value: Mapping[str, Any]) -> None: + """Write a fixed-name output without following a final-component symlink.""" + + # The explicit check covers platforms without O_NOFOLLOW; the flag closes + # the check/open race on platforms that provide it. + if path.is_symlink(): + _fail(str(path), "refuses to replace a symbolic link") + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor: int | None = None + try: + descriptor = os.open(path, flags, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + descriptor = None + handle.write( + json.dumps(value, ensure_ascii=False, sort_keys=True, indent=2) + + "\n" + ) + except OSError as exc: + raise ContractError(f"unable to write {path}: {exc}") from exc + finally: + if descriptor is not None: + os.close(descriptor) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Compile a public StoryCore game specification" + ) + parser.add_argument("--input", required=True) + parser.add_argument("--output-dir", required=True) + args = parser.parse_args(argv) + + try: + workspace_root = Path.cwd().resolve(strict=True) + input_relative = _require_relative_cli_path(args.input, "--input") + output_relative = _require_relative_cli_path( + args.output_dir, "--output-dir" + ) + input_path = _resolve_workspace_path( + input_relative, workspace_root, "--input", must_be_file=True + ) + output_dir = _resolve_workspace_path( + output_relative, + workspace_root, + "--output-dir", + must_be_dir=True, + ) + # Output filenames are constants, never user-controlled components. + manifest_path = output_dir / "storycore_game_manifest.json" + evidence_path = output_dir / "storycore_game_evidence.json" + manifest, evidence = compile_spec(_load_json(input_path)) + _write_json(manifest_path, manifest) + _write_json(evidence_path, evidence) + except ContractError as exc: + parser.error(str(exc)) + + print(f"manifest={manifest_path}") + print(f"evidence={evidence_path}") + print(f"content_sha256={manifest['content_sha256']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/music_plan.py b/src/music_plan.py new file mode 100644 index 00000000..33e2c6bf --- /dev/null +++ b/src/music_plan.py @@ -0,0 +1,216 @@ +"""Provider-neutral MusicPlan v1 validation helpers. + +Validation is model-free but uses the repository's existing ``jsonschema`` +dependency for Draft 2020-12 structural checks, then applies StoryCore-specific +cross-field and commercial-use invariants. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from jsonschema import Draft202012Validator + +NON_COMMERCIAL_MARKERS = ("CC BY-NC", "BY-NC", "NON-COMMERCIAL", "NONCOMMERCIAL") +UNKNOWN_LICENSE_MARKERS = frozenset({"unknown", "unspecified", "unqualified", "tbd", "n/a", "none"}) +DEFAULT_SCHEMA_PATH = Path(__file__).resolve().parents[1] / "schemas" / "music-plan-v1.schema.json" + + +@dataclass(frozen=True) +class MusicPlanValidation: + valid: bool + errors: tuple[str, ...] + warnings: tuple[str, ...] = () + + +def _is_non_commercial(license_name: str) -> bool: + upper = license_name.upper().replace("_", "-") + return any(marker in upper for marker in NON_COMMERCIAL_MARKERS) + + +def _is_unknown_license(license_name: str) -> bool: + return license_name.strip().lower() in UNKNOWN_LICENSE_MARKERS + + +def _schema_errors(plan: dict[str, Any], schema_path: Path = DEFAULT_SCHEMA_PATH) -> list[str]: + schema = json.loads(schema_path.read_text(encoding="utf-8")) + Draft202012Validator.check_schema(schema) + validator = Draft202012Validator(schema) + errors: list[str] = [] + for issue in sorted(validator.iter_errors(plan), key=lambda item: list(item.absolute_path)): + path = ".".join(str(part) for part in issue.absolute_path) or "$" + errors.append(f"schema:{path}: {issue.message}") + return errors + + +def validate_music_plan(plan: dict[str, Any]) -> MusicPlanValidation: + """Validate MusicPlan structure and deterministic invariants without a model.""" + errors: list[str] = _schema_errors(plan) + warnings: list[str] = [] + + if plan.get("schema_version") != 1: + errors.append("schema_version must be 1") + if plan.get("mode") not in {"full", "guided", "free"}: + errors.append("mode must be full, guided, or free") + + duration = plan.get("duration_seconds") + if not isinstance(duration, (int, float)) or isinstance(duration, bool) or duration <= 0: + errors.append("duration_seconds must be a positive number") + duration = None + + sections = plan.get("sections") + if not isinstance(sections, list) or not sections: + errors.append("sections must be a non-empty list") + sections = [] + + section_ids: set[str] = set() + motif_refs: set[str] = set() + previous_end = 0.0 + for index, section in enumerate(sections): + if not isinstance(section, dict): + errors.append(f"sections[{index}] must be an object") + continue + sid = section.get("id") + if not isinstance(sid, str) or not sid: + errors.append(f"sections[{index}].id must be non-empty") + elif sid in section_ids: + errors.append(f"duplicate section id: {sid}") + else: + section_ids.add(sid) + start = section.get("start_seconds") + end = section.get("end_seconds") + if not isinstance(start, (int, float)) or isinstance(start, bool): + errors.append(f"sections[{index}].start_seconds must be numeric") + continue + if not isinstance(end, (int, float)) or isinstance(end, bool): + errors.append(f"sections[{index}].end_seconds must be numeric") + continue + if start < 0 or end <= start: + errors.append(f"sections[{index}] has invalid time bounds") + if start < previous_end: + errors.append(f"sections[{index}] overlaps the preceding section") + previous_end = max(previous_end, float(end)) + if duration is not None and end > duration: + errors.append(f"sections[{index}] exceeds duration_seconds") + refs = section.get("motif_refs", []) + if isinstance(refs, list): + motif_refs.update(ref for ref in refs if isinstance(ref, str)) + + motifs = plan.get("motifs", []) + motif_ids: set[str] = set() + if isinstance(motifs, list): + for index, motif in enumerate(motifs): + if not isinstance(motif, dict): + errors.append(f"motifs[{index}] must be an object") + continue + mid = motif.get("id") + if not isinstance(mid, str) or not mid: + errors.append(f"motifs[{index}].id must be non-empty") + elif mid in motif_ids: + errors.append(f"duplicate motif id: {mid}") + else: + motif_ids.add(mid) + unresolved = sorted(motif_refs - motif_ids) + if unresolved: + errors.append("unresolved motif_refs: " + ", ".join(unresolved)) + + cues = plan.get("sync_cues") + if not isinstance(cues, list): + errors.append("sync_cues must be a list") + cues = [] + cue_ids: set[str] = set() + for index, cue in enumerate(cues): + if not isinstance(cue, dict): + errors.append(f"sync_cues[{index}] must be an object") + continue + cid = cue.get("id") + if not isinstance(cid, str) or not cid: + errors.append(f"sync_cues[{index}].id must be non-empty") + elif cid in cue_ids: + errors.append(f"duplicate sync cue id: {cid}") + else: + cue_ids.add(cid) + time_seconds = cue.get("time_seconds") + if not isinstance(time_seconds, (int, float)) or isinstance(time_seconds, bool) or time_seconds < 0: + errors.append(f"sync_cues[{index}].time_seconds must be non-negative") + elif duration is not None and time_seconds > duration: + errors.append(f"sync_cues[{index}] exceeds duration_seconds") + + provenance = plan.get("provenance") + if not isinstance(provenance, dict): + errors.append("provenance must be an object") + else: + commercial = provenance.get("commercial_target") + if not isinstance(commercial, bool): + errors.append("provenance.commercial_target must be boolean") + dependencies = provenance.get("dependencies") + if not isinstance(dependencies, list): + errors.append("provenance.dependencies must be a list") + else: + for index, dep in enumerate(dependencies): + if not isinstance(dep, dict): + errors.append(f"provenance.dependencies[{index}] must be an object") + continue + license_name = dep.get("license") + if not isinstance(license_name, str) or not license_name.strip(): + errors.append(f"provenance.dependencies[{index}].license is required") + continue + if commercial and dep.get("kind") == "model-weights": + name = dep.get("name", index) + if _is_unknown_license(license_name): + errors.append( + f"commercial target cannot use model weights with unknown licence: {name}" + ) + elif _is_non_commercial(license_name): + errors.append( + f"commercial target cannot use non-commercial model weights: {name}" + ) + + mode = plan.get("mode") + if mode == "full" and not motifs: + warnings.append("full mode has no symbolic motifs") + if mode == "free" and motifs: + warnings.append("free mode carries motifs that a provider may intentionally ignore") + + return MusicPlanValidation(not errors, tuple(errors), tuple(warnings)) + + +def _diff_values(requested: Any, executed: Any, path: str, deltas: list[str]) -> None: + if isinstance(requested, dict) and isinstance(executed, dict): + requested_keys = set(requested) + executed_keys = set(executed) + for key in sorted(requested_keys - executed_keys): + child = f"{path}.{key}" if path else key + if child not in {"schema_version", "plan_id", "provenance"}: + deltas.append(f"dropped:{child}") + for key in sorted(executed_keys - requested_keys): + child = f"{path}.{key}" if path else key + deltas.append(f"added:{child}") + for key in sorted(requested_keys & executed_keys): + child = f"{path}.{key}" if path else key + if child == "mode": + continue + _diff_values(requested[key], executed[key], child, deltas) + return + + if isinstance(requested, list) and isinstance(executed, list): + if len(requested) != len(executed): + deltas.append(f"changed:{path}.length") + for index, (left, right) in enumerate(zip(requested, executed)): + _diff_values(left, right, f"{path}[{index}]", deltas) + return + + if requested != executed: + deltas.append(f"changed:{path}") + + +def execution_delta(requested: dict[str, Any], executed: dict[str, Any]) -> tuple[str, ...]: + """Return material provider changes, including nested and value-level deltas.""" + deltas: list[str] = [] + if requested.get("mode") != executed.get("mode"): + deltas.append(f"mode:{requested.get('mode')}->{executed.get('mode')}") + _diff_values(requested, executed, "", deltas) + return tuple(dict.fromkeys(deltas)) diff --git a/src/music_provider.py b/src/music_provider.py new file mode 100644 index 00000000..cf623780 --- /dev/null +++ b/src/music_provider.py @@ -0,0 +1,183 @@ +"""Deterministic provider contract for MusicPlan v1. + +The provider adapter is deliberately model-free: it proves how a future music +backend must report requested versus executed state without silently degrading a +plan. It never promotes artifacts, downloads weights, or performs network I/O. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Iterable + +from src.music_plan import execution_delta, validate_music_plan + + +class MusicProviderContractError(ValueError): + """Raised when a provider response violates the explicit-degradation contract.""" + + +@dataclass(frozen=True) +class ProviderResult: + provider_id: str + requested_mode: str + executed_mode: str + requested_plan_id: str + unsupported_fields: tuple[str, ...] + deltas: tuple[str, ...] + reason: str + acknowledged: bool + artifact_id: str + artifact_status: str = "candidate" + activation_allowed: bool = False + promoted: bool = False + executed_external_model: bool = False + + def as_dict(self) -> dict[str, Any]: + return { + "provider_id": self.provider_id, + "requested_mode": self.requested_mode, + "executed_mode": self.executed_mode, + "requested_plan_id": self.requested_plan_id, + "unsupported_fields": list(self.unsupported_fields), + "deltas": list(self.deltas), + "reason": self.reason, + "acknowledged": self.acknowledged, + "artifact_id": self.artifact_id, + "artifact_status": self.artifact_status, + "activation_allowed": self.activation_allowed, + "promoted": self.promoted, + "executed_external_model": self.executed_external_model, + } + + +def build_provider_handoff( + result: ProviderResult, + *, + provider_version: str = "", + model: str = "", + harness: str = "music-plan-v1", + hardware: str = "", + input_digest: str = "", + candidate_digest: str = "", + evidence_refs: Iterable[str] = (), + verification_state: str = "unverified", +) -> dict[str, Any]: + """Build a data-only interchange envelope for a later validation harness. + + This function does not import Botte Secrète, execute a provider, promote an + artifact, or write memory. It merely carries the bounded provider outcome + and provenance dimensions that another harness may verify independently. + """ + allowed_states = {"unverified", "partially_verified", "verified", "failed"} + if verification_state not in allowed_states: + raise MusicProviderContractError("unsupported verification_state") + refs = tuple(str(ref) for ref in evidence_refs if str(ref).strip()) + if verification_state == "verified" and not refs: + raise MusicProviderContractError("verified handoff requires evidence references") + if result.artifact_status != "candidate": + raise MusicProviderContractError("provider handoff accepts candidate artifacts only") + if result.activation_allowed or result.promoted: + raise MusicProviderContractError("provider result cannot carry activation or promotion authority") + + return { + "schema_version": "storycore.music-provider-handoff/v1", + "plan_id": result.requested_plan_id, + "provider": { + "id": result.provider_id, + "version": provider_version, + "model": model, + "harness": harness, + "hardware": hardware, + }, + "execution": { + "requested_mode": result.requested_mode, + "executed_mode": result.executed_mode, + "unsupported_fields": list(result.unsupported_fields), + "deltas": list(result.deltas), + "reason": result.reason, + "acknowledged": result.acknowledged, + "executed_external_model": result.executed_external_model, + }, + "artifact": { + "id": result.artifact_id, + "status": result.artifact_status, + "input_digest": input_digest, + "candidate_digest": candidate_digest, + }, + "verification": { + "state": verification_state, + "evidence_refs": list(refs), + }, + "activation_allowed": False, + "promoted": False, + "memory_write_performed": False, + } + + +class MockMusicProvider: + """Small deterministic provider used to prove the adapter contract.""" + + def __init__( + self, + provider_id: str = "mock-music-provider", + *, + supported_modes: tuple[str, ...] = ("full", "guided", "free"), + unsupported_fields: tuple[str, ...] = (), + fallback_mode: str | None = None, + degradation_reason: str = "", + ) -> None: + self.provider_id = provider_id + self.supported_modes = supported_modes + self.unsupported_fields = unsupported_fields + self.fallback_mode = fallback_mode + self.degradation_reason = degradation_reason + + def prepare(self, plan: dict[str, Any]) -> ProviderResult: + validation = validate_music_plan(plan) + if not validation.valid: + raise MusicProviderContractError( + "invalid MusicPlan: " + "; ".join(validation.errors) + ) + + requested_mode = str(plan["mode"]) + executed = dict(plan) + if requested_mode not in self.supported_modes: + if not self.fallback_mode or self.fallback_mode not in self.supported_modes: + raise MusicProviderContractError( + f"provider does not support requested mode {requested_mode!r} and has no valid fallback" + ) + executed["mode"] = self.fallback_mode + + for field in self.unsupported_fields: + executed.pop(field, None) + + deltas = execution_delta(plan, executed) + acknowledged = bool(deltas) + reason = self.degradation_reason.strip() if deltas else "" + if deltas and not reason: + raise MusicProviderContractError( + "provider changed requested state without an explicit degradation reason" + ) + + return ProviderResult( + provider_id=self.provider_id, + requested_mode=requested_mode, + executed_mode=str(executed["mode"]), + requested_plan_id=str(plan["plan_id"]), + unsupported_fields=tuple( + field for field in self.unsupported_fields if field in plan + ), + deltas=deltas, + reason=reason, + acknowledged=acknowledged, + artifact_id=f"candidate:{self.provider_id}:{plan['plan_id']}", + ) + + +__all__ = [ + "MockMusicProvider", + "MusicProviderContractError", + "ProviderResult", + "build_provider_handoff", +] diff --git a/src/video_validation/__init__.py b/src/video_validation/__init__.py new file mode 100644 index 00000000..8c7be3e6 --- /dev/null +++ b/src/video_validation/__init__.py @@ -0,0 +1,28 @@ +"""Video validation and generation-routing primitives for StoryCore.""" + +from .multisubject_router import ( + GenerationStrategy, + MultiSubjectShot, + RoutingDecision, + route_multi_subject_shot, +) +from .semantic_prevalidator import ( + SemanticPrevalidator, + SemanticValidationResult, + SemanticVerdict, + VideoTextScorer, +) +from .shot_spec_adapter import extract_multi_subject_shot, route_shot_spec + +__all__ = [ + "GenerationStrategy", + "MultiSubjectShot", + "RoutingDecision", + "route_multi_subject_shot", + "extract_multi_subject_shot", + "route_shot_spec", + "SemanticPrevalidator", + "SemanticValidationResult", + "SemanticVerdict", + "VideoTextScorer", +] diff --git a/src/video_validation/multisubject_router.py b/src/video_validation/multisubject_router.py new file mode 100644 index 00000000..2878c396 --- /dev/null +++ b/src/video_validation/multisubject_router.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + + +class GenerationStrategy(str, Enum): + DIRECT = "DIRECT" + PARALLEL = "PARALLEL" + SEQUENTIAL = "SEQUENTIAL" + + +@dataclass(frozen=True) +class MultiSubjectShot: + subject_count: int + interaction_strength: float = 0.0 + contact_required: bool = False + occlusion_level: float = 0.0 + identity_criticality: float = 0.5 + camera_motion: float = 0.0 + temporal_dependency: float = 0.0 + compute_budget: float = 0.5 + + def __post_init__(self) -> None: + if self.subject_count < 0: + raise ValueError("subject_count must be non-negative") + for name in ( + "interaction_strength", + "occlusion_level", + "identity_criticality", + "camera_motion", + "temporal_dependency", + "compute_budget", + ): + value = getattr(self, name) + if not 0.0 <= value <= 1.0: + raise ValueError(f"{name} must be in [0, 1]") + + +@dataclass(frozen=True) +class RoutingDecision: + strategy: GenerationStrategy + confidence: float + reasons: tuple[str, ...] + requires_intermediate_validation: bool + + +def route_multi_subject_shot(shot: MultiSubjectShot) -> RoutingDecision: + """Choose a generation strategy using deterministic, explainable rules. + + The router does not call a model and does not generate media. It only + chooses the safest/cheapest generation topology for the supplied shot + complexity. Downstream validators still decide whether the result passes. + """ + + reasons: list[str] = [] + + if shot.subject_count <= 1: + return RoutingDecision( + strategy=GenerationStrategy.DIRECT, + confidence=0.98, + reasons=("single_or_no_subject",), + requires_intermediate_validation=False, + ) + + # Strong physical/spatial coupling is the clearest case for staged + # generation because independent branches cannot reliably preserve contact. + if shot.contact_required: + reasons.append("contact_required") + if shot.interaction_strength >= 0.7: + reasons.append("strong_subject_interaction") + if shot.occlusion_level >= 0.75: + reasons.append("heavy_occlusion") + if shot.temporal_dependency >= 0.8: + reasons.append("strong_temporal_dependency") + + if reasons: + confidence = min(0.98, 0.80 + 0.04 * len(reasons)) + return RoutingDecision( + strategy=GenerationStrategy.SEQUENTIAL, + confidence=confidence, + reasons=tuple(reasons), + requires_intermediate_validation=True, + ) + + # Independent subjects with high identity requirements benefit from + # separate generation branches followed by composition/reconciliation. + parallel_score = 0.0 + if shot.identity_criticality >= 0.7: + parallel_score += 0.45 + reasons.append("identity_critical") + if shot.subject_count >= 3: + parallel_score += 0.25 + reasons.append("many_subjects") + if shot.interaction_strength <= 0.35: + parallel_score += 0.20 + reasons.append("weak_subject_interaction") + if shot.occlusion_level <= 0.35: + parallel_score += 0.10 + reasons.append("low_occlusion") + + if parallel_score >= 0.65: + return RoutingDecision( + strategy=GenerationStrategy.PARALLEL, + confidence=min(0.95, 0.70 + parallel_score * 0.25), + reasons=tuple(reasons), + requires_intermediate_validation=True, + ) + + # Complex camera motion and previous-shot continuity can still favor a + # staged build even without direct contact between subjects. + if shot.camera_motion >= 0.75 and shot.temporal_dependency >= 0.55: + return RoutingDecision( + strategy=GenerationStrategy.SEQUENTIAL, + confidence=0.78, + reasons=("camera_motion_with_temporal_dependency",), + requires_intermediate_validation=True, + ) + + # When compute is severely constrained, use the single-pass baseline and + # rely on validators rather than multiplying generation branches. + if shot.compute_budget <= 0.2: + return RoutingDecision( + strategy=GenerationStrategy.DIRECT, + confidence=0.72, + reasons=("compute_budget_constrained",), + requires_intermediate_validation=False, + ) + + return RoutingDecision( + strategy=GenerationStrategy.DIRECT, + confidence=0.70, + reasons=("low_interaction_complexity",), + requires_intermediate_validation=False, + ) diff --git a/src/video_validation/semantic_prevalidator.py b/src/video_validation/semantic_prevalidator.py new file mode 100644 index 00000000..4209e583 --- /dev/null +++ b/src/video_validation/semantic_prevalidator.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import hashlib +import math +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Protocol, Sequence + + +class SemanticVerdict(str, Enum): + PASS = "PASS" + ESCALATE = "ESCALATE" + UNCERTAIN = "UNCERTAIN" + + +class VideoTextScorer(Protocol): + """Provider interface for ViCLIP-like video/text similarity backends.""" + + @property + def provider_id(self) -> str: ... + + def score(self, text: str, clip_path: Path) -> float: + """Return a normalized semantic-alignment score in [0, 1].""" + + +@dataclass(frozen=True) +class SemanticValidationResult: + verdict: SemanticVerdict + score: float | None + provider_id: str + cache_key: str + reasons: tuple[str, ...] = () + defect_signals: tuple[str, ...] = () + metadata: dict[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True) +class _SemanticScore: + score: float | None + reasons: tuple[str, ...] + metadata: dict[str, object] + + +class SemanticPrevalidator: + """Cheap semantic gate before expensive VLM/temporal validation. + + A semantic PASS only means the clip is text-aligned enough to avoid an + immediate semantic escalation. It never overrides downstream structural, + identity, safety, or temporal validators. + + Semantic scoring is cached independently from external defect signals. This + means a newly reported identity/camera/temporal defect can force escalation + without re-running a potentially expensive local video encoder. + """ + + def __init__( + self, + scorer: VideoTextScorer, + *, + pass_threshold: float = 0.78, + uncertain_threshold: float = 0.55, + ) -> None: + if not 0 <= uncertain_threshold <= pass_threshold <= 1: + raise ValueError("expected 0 <= uncertain_threshold <= pass_threshold <= 1") + self.scorer = scorer + self.pass_threshold = pass_threshold + self.uncertain_threshold = uncertain_threshold + self._score_cache: dict[str, _SemanticScore] = {} + + def validate( + self, + shot_spec: str, + clip_path: str | Path, + *, + defect_signals: Sequence[str] = (), + force_refresh: bool = False, + ) -> SemanticValidationResult: + path = Path(clip_path) + defects = tuple(sorted({signal.strip() for signal in defect_signals if signal.strip()})) + + if not shot_spec.strip(): + return SemanticValidationResult( + verdict=SemanticVerdict.UNCERTAIN, + score=None, + provider_id=self.scorer.provider_id, + cache_key="", + reasons=("empty_shot_spec",), + defect_signals=defects, + ) + if not path.is_file(): + return SemanticValidationResult( + verdict=SemanticVerdict.UNCERTAIN, + score=None, + provider_id=self.scorer.provider_id, + cache_key="", + reasons=("clip_missing",), + defect_signals=defects, + ) + + cache_key = self._make_cache_key(shot_spec, path) + semantic = None if force_refresh else self._score_cache.get(cache_key) + cache_hit = semantic is not None + if semantic is None: + semantic = self._score(shot_spec, path) + self._score_cache[cache_key] = semantic + + if semantic.score is None: + return SemanticValidationResult( + verdict=SemanticVerdict.UNCERTAIN, + score=None, + provider_id=self.scorer.provider_id, + cache_key=cache_key, + reasons=semantic.reasons, + defect_signals=defects, + metadata={**semantic.metadata, "semantic_cache_hit": cache_hit}, + ) + + score = semantic.score + if defects: + verdict = SemanticVerdict.ESCALATE + reasons = ("external_defect_signal",) + elif score >= self.pass_threshold: + verdict = SemanticVerdict.PASS + reasons = ("semantic_alignment_high",) + elif score >= self.uncertain_threshold: + verdict = SemanticVerdict.ESCALATE + reasons = ("semantic_alignment_ambiguous",) + else: + verdict = SemanticVerdict.ESCALATE + reasons = ("semantic_alignment_low",) + + return SemanticValidationResult( + verdict=verdict, + score=score, + provider_id=self.scorer.provider_id, + cache_key=cache_key, + reasons=reasons, + defect_signals=defects, + metadata={ + "pass_threshold": self.pass_threshold, + "uncertain_threshold": self.uncertain_threshold, + "semantic_cache_hit": cache_hit, + }, + ) + + def _score(self, shot_spec: str, path: Path) -> _SemanticScore: + try: + score = float(self.scorer.score(shot_spec, path)) + except Exception as exc: # provider failure must fail safe + return _SemanticScore( + score=None, + reasons=("provider_error",), + metadata={"error_type": type(exc).__name__}, + ) + + if not math.isfinite(score) or not 0 <= score <= 1: + return _SemanticScore( + score=None, + reasons=("score_out_of_range",), + metadata={"raw_score": score}, + ) + return _SemanticScore(score=score, reasons=(), metadata={}) + + @staticmethod + def _make_cache_key(shot_spec: str, clip_path: Path) -> str: + digest = hashlib.sha256() + digest.update(shot_spec.strip().encode("utf-8")) + digest.update(b"\0") + with clip_path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() diff --git a/src/video_validation/shot_spec_adapter.py b/src/video_validation/shot_spec_adapter.py new file mode 100644 index 00000000..354a3afd --- /dev/null +++ b/src/video_validation/shot_spec_adapter.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import math +from collections.abc import Mapping, Sequence +from typing import Any + +from .multisubject_router import MultiSubjectShot, RoutingDecision, route_multi_subject_shot + + +_CAMERA_MOTION_INTENSITY = { + "static": 0.0, + "locked": 0.0, + "none": 0.0, + "pan": 0.35, + "tilt": 0.35, + "zoom": 0.45, + "dolly": 0.55, + "tracking": 0.6, + "truck": 0.6, + "pedestal": 0.55, + "orbit": 0.75, + "crane": 0.75, + "handheld": 0.8, + "whip": 0.9, +} + + +def _read(source: Any, key: str, default: Any = None) -> Any: + if isinstance(source, Mapping): + return source.get(key, default) + return getattr(source, key, default) + + +def _routing_metadata(shot_spec: Any) -> Mapping[str, Any]: + metadata = _read(shot_spec, "metadata", {}) + if not isinstance(metadata, Mapping): + return {} + routing = metadata.get("multi_subject_routing", {}) + return routing if isinstance(routing, Mapping) else {} + + +def _normalize_score(value: Any, *, name: str, default: float) -> float: + if value is None: + return default + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{name} must be numeric") + score = float(value) + if not math.isfinite(score) or not 0.0 <= score <= 1.0: + raise ValueError(f"{name} must be finite and in [0, 1]") + return score + + +def _normalize_bool(value: Any, *, name: str, default: bool) -> bool: + if value is None: + return default + if not isinstance(value, bool): + raise ValueError(f"{name} must be boolean") + return value + + +def _camera_motion_score(value: Any) -> float: + if value is None: + return 0.0 + if isinstance(value, (int, float)) and not isinstance(value, bool): + return _normalize_score(value, name="camera_motion", default=0.0) + if not isinstance(value, str): + raise ValueError("camera_motion must be numeric or text") + + normalized = value.lower().replace("-", " ").replace("_", " ") + matched = [score for token, score in _CAMERA_MOTION_INTENSITY.items() if token in normalized] + return max(matched, default=0.25 if normalized.strip() else 0.0) + + +def _sequence_count(value: Any) -> int | None: + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return len(value) + return None + + +def _subject_count(shot_spec: Any, *, subjects: Sequence[Any] | None, routing: Mapping[str, Any]) -> int: + candidates: list[tuple[str, int]] = [] + + explicit = routing.get("subject_count", _read(shot_spec, "subject_count", None)) + if explicit is not None: + if isinstance(explicit, bool) or not isinstance(explicit, int) or explicit < 0: + raise ValueError("subject_count must be a non-negative integer") + candidates.append(("subject_count", explicit)) + + for key in ("characters_present", "subjects", "characters"): + count = _sequence_count(_read(shot_spec, key, None)) + if count is not None: + candidates.append((key, count)) + + if subjects is not None: + candidates.append(("subjects_argument", len(subjects))) + + if not candidates: + raise ValueError("subject_count is required when the shot spec has no subject list") + + distinct = {count for _, count in candidates} + if len(distinct) != 1: + detail = ", ".join(f"{name}={count}" for name, count in candidates) + raise ValueError(f"contradictory subject counts: {detail}") + return candidates[0][1] + + +def extract_multi_subject_shot( + shot_spec: Any, + *, + subjects: Sequence[Any] | None = None, + overrides: Mapping[str, Any] | None = None, +) -> MultiSubjectShot: + """Adapt an existing StoryCore shot/dict into the deterministic routing contract. + + Rich routing metadata can live under ``metadata.multi_subject_routing``. + ``overrides`` is intended for the orchestration layer when scene context knows + more than the shot object itself. No NLP guessing is performed here. + """ + + routing = dict(_routing_metadata(shot_spec)) + if overrides: + routing.update(overrides) + + camera_value = routing.get( + "camera_motion", + _read(shot_spec, "camera_movement", _read(shot_spec, "camera_motion", None)), + ) + + return MultiSubjectShot( + subject_count=_subject_count(shot_spec, subjects=subjects, routing=routing), + interaction_strength=_normalize_score( + routing.get("interaction_strength"), name="interaction_strength", default=0.0 + ), + contact_required=_normalize_bool( + routing.get("contact_required"), name="contact_required", default=False + ), + occlusion_level=_normalize_score( + routing.get("occlusion_level"), name="occlusion_level", default=0.0 + ), + identity_criticality=_normalize_score( + routing.get("identity_criticality"), name="identity_criticality", default=0.5 + ), + camera_motion=_camera_motion_score(camera_value), + temporal_dependency=_normalize_score( + routing.get("temporal_dependency"), name="temporal_dependency", default=0.0 + ), + compute_budget=_normalize_score( + routing.get("compute_budget"), name="compute_budget", default=0.5 + ), + ) + + +def route_shot_spec( + shot_spec: Any, + *, + subjects: Sequence[Any] | None = None, + overrides: Mapping[str, Any] | None = None, +) -> RoutingDecision: + """Extract a routing input from a StoryCore shot and choose its topology.""" + + return route_multi_subject_shot( + extract_multi_subject_shot(shot_spec, subjects=subjects, overrides=overrides) + ) diff --git a/tests/asset_creator/README.md b/tests/asset_creator/README.md new file mode 100644 index 00000000..99ce11ac --- /dev/null +++ b/tests/asset_creator/README.md @@ -0,0 +1,39 @@ +# Asset Creator: local preflight regressions + +Run from the repository root with Python 3.11+: + +```bash +python -m unittest discover -s tests/asset_creator -v +``` + +The suite uses the standard library, temporary files, synthetic editor templates +and a fake ComfyUI client. Socket creation is forbidden during each test. The fake +download returns paths without writing GLBs: successful ordering is not evidence +of generation, artifact integrity, or compatibility with the ComfyUI API. + +The nine tests cover: + +- missing recipe and malformed JSON before any client call; +- an unknown preset with an available `lowvram` recipe, without silent fallback; +- a missing source image before any client call; +- invalid editor structure and missing/unpatchable input-image nodes; +- one preparation reused after the on-disk recipe changes, preserving the + requested parameters and the server-returned image name; +- all four declared presets preparing without changing their source files; +- an unavailable server causing no upload or submission. + +On the base `5b6c83bd26f60f7eb5e4da53f958f2d3b06edb4a`, these tests expose the +upload-before-preparation ordering, silent preset fallback and template-validation +gaps. The suite reports multiple failures within parameterized subtests; these +must not be represented as distinct end-to-end generation attempts. + +Local validation on 2026-09-13 (Linux, Python 3.12.14): the unmodified base with +the regression suite reports 8 failures and 5 errors across its subtests; with +the correction all 9 test methods pass. `git diff --check` also passes. This is +a focused sparse-checkout proof, not a repository-wide test run. Ruff was not +available in this environment and no Ruff result is claimed. + +The addon still patches editor JSON rather than constructing a proven API prompt. +Real PixelArtistry/Trellis2 JSON, node/model versions, API-format conversion, +timeouts, job recovery, real outputs and the full Asset Factory integration remain +separate work. No workflow recipe or model is downloaded by these tests. diff --git a/tests/asset_creator/test_comfyui_client.py b/tests/asset_creator/test_comfyui_client.py new file mode 100644 index 00000000..56954fe6 --- /dev/null +++ b/tests/asset_creator/test_comfyui_client.py @@ -0,0 +1,342 @@ +"""HTTP/status regressions without a ComfyUI server, Blender or GPU.""" + +import importlib.util +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import requests + +SOURCE = ( + Path(__file__).resolve().parents[2] + / "addons/official/storycore_asset_creator/src/comfyui_client.py" +) +SPEC = importlib.util.spec_from_file_location("asset_creator_client", SOURCE) +client_module = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(client_module) +ComfyUIClient = client_module.ComfyUIClient +PROMPT_ID = "accepted-prompt-id" +OUTPUTS = {"7": {"images": [{"filename": "partial.png"}]}} + + +def history(outputs=None, *, status="success", completed=True): + return { + "outputs": OUTPUTS if outputs is None else outputs, + "status": { + "status_str": status, + "completed": completed, + "messages": [["execution_error", {"exception_message": "out of memory"}]] + if status == "error" + else [], + }, + } + + +def response(payload=None): + result = MagicMock() + result.json.return_value = payload + result.status_code = 200 + result.__enter__.return_value = result + return result + + +class Clock: + def __init__(self): + self.now = 0.0 + self.sleeps = [] + + def monotonic(self): + return self.now + + def sleep(self, seconds): + self.sleeps.append(seconds) + self.now += seconds + + +class ComfyUIClientTests(unittest.TestCase): + def setUp(self): + sockets = patch( + "socket.socket", side_effect=AssertionError("network forbidden") + ) + sockets.start() + self.addCleanup(sockets.stop) + self.clock = Clock() + clock_patch = patch.object(client_module, "time", self.clock) + clock_patch.start() + self.addCleanup(clock_patch.stop) + self.client = ComfyUIClient(port=8188, request_timeout=7.0) + + def test_error_status_wins_over_partial_outputs(self): + for completed in (False, True): + with ( + self.subTest(completed=completed), + patch.object( + self.client, + "get_history", + return_value=history(status="error", completed=completed), + ), + ): + with self.assertRaisesRegex(RuntimeError, "out of memory") as caught: + self.client.wait_for_result(PROMPT_ID) + self.assertIn(PROMPT_ID, str(caught.exception)) + self.assertEqual([], self.clock.sleeps) + + def test_legacy_top_level_error_still_fails(self): + with ( + patch.object( + self.client, + "get_history", + return_value={"error": "failed", "outputs": OUTPUTS}, + ), + self.assertRaisesRegex(RuntimeError, "failed"), + ): + self.client.wait_for_result(PROMPT_ID) + + def test_only_explicit_completed_success_returns_outputs(self): + for outputs in ({}, OUTPUTS): + with ( + self.subTest(outputs=outputs), + patch.object(self.client, "get_history", return_value=history(outputs)), + ): + self.assertEqual(outputs, self.client.wait_for_result(PROMPT_ID)) + self.assertEqual([], self.clock.sleeps) + + def test_unknown_or_incomplete_status_does_not_return_partial_outputs(self): + cases = [ + {"outputs": OUTPUTS}, + {"outputs": OUTPUTS, "status": None}, + {"outputs": OUTPUTS, "status": "success"}, + history(completed=False), + history(completed="true"), + history(status="unknown"), + ] + for item in cases: + with ( + self.subTest(item=item), + patch.object(self.client, "get_history", return_value=item), + self.assertRaises(TimeoutError), + ): + self.client.wait_for_result(PROMPT_ID, timeout=1) + + def test_success_with_malformed_outputs_fails(self): + with ( + patch.object(self.client, "get_history", return_value=history([])), + self.assertRaisesRegex(RuntimeError, "outputs invalides"), + ): + self.client.wait_for_result(PROMPT_ID) + + def test_sleep_is_clipped_and_no_request_starts_after_deadline(self): + with ( + patch.object( + client_module.requests, "get", return_value=response({}) + ) as get, + self.assertRaises(client_module.ComfyUIWaitTimeout) as caught, + ): + self.client.wait_for_result(PROMPT_ID, timeout=5, poll_interval=3) + self.assertEqual([3, 2], self.clock.sleeps) + self.assertEqual( + [5, 2], [call.kwargs["timeout"] for call in get.call_args_list] + ) + self.assertEqual(PROMPT_ID, caught.exception.prompt_id) + self.assertEqual("wait_deadline", caught.exception.reason) + + def test_late_history_response_does_not_start_queue_poll(self): + def late_history(*args, **kwargs): + self.clock.now += 6 + return response({PROMPT_ID: history()}) + + callback = Mock() + with ( + patch.object( + client_module.requests, "get", side_effect=late_history + ) as get, + self.assertRaises(client_module.ComfyUIWaitTimeout), + ): + self.client.wait_for_result( + PROMPT_ID, timeout=5, progress_callback=callback + ) + self.assertEqual(1, get.call_count) + callback.assert_not_called() + self.assertEqual([], self.clock.sleeps) + + def test_queue_poll_uses_remaining_budget(self): + budgets = [] + + def get(url, **kwargs): + budgets.append(kwargs["timeout"]) + if "/history/" in url: + self.clock.now += 3 + return response({}) + self.clock.now += 2 + return response({"queue_running": [], "queue_pending": []}) + + callback = Mock() + with ( + patch.object(client_module.requests, "get", side_effect=get), + self.assertRaises(client_module.ComfyUIWaitTimeout), + ): + self.client.wait_for_result( + PROMPT_ID, timeout=5, progress_callback=callback + ) + self.assertEqual([5, 2], budgets) + callback.assert_not_called() + + def test_callback_time_counts_towards_deadline(self): + def slow_callback(status): + self.clock.now += 5 + + with ( + patch.object( + client_module.requests, + "get", + side_effect=[ + response({}), + response({"queue_running": [], "queue_pending": []}), + ], + ) as get, + self.assertRaises(client_module.ComfyUIWaitTimeout), + ): + self.client.wait_for_result( + PROMPT_ID, timeout=5, progress_callback=slow_callback + ) + self.assertEqual(2, get.call_count) + self.assertEqual([], self.clock.sleeps) + + def test_http_timeout_preserves_prompt_and_cause_for_both_poll_endpoints(self): + for endpoint in ("history", "queue"): + error = requests.exceptions.ReadTimeout("server silent") + effects = [error] if endpoint == "history" else [response({}), error] + with ( + self.subTest(endpoint=endpoint), + patch.object(client_module.requests, "get", side_effect=effects) as get, + patch.object(client_module.requests, "post") as post, + ): + with self.assertRaises(client_module.ComfyUIWaitTimeout) as caught: + self.client.wait_for_result(PROMPT_ID, progress_callback=Mock()) + self.assertEqual(PROMPT_ID, caught.exception.prompt_id) + self.assertEqual("http_timeout", caught.exception.reason) + self.assertIs(error, caught.exception.__cause__) + self.assertEqual(len(effects), get.call_count) + post.assert_not_called() + + def test_resume_wait_after_timeout_does_not_submit_again(self): + with ( + patch.object( + client_module.requests, + "get", + side_effect=[ + requests.exceptions.ReadTimeout(), + response({PROMPT_ID: history()}), + ], + ) as get, + patch.object(client_module.requests, "post") as post, + ): + with self.assertRaises(client_module.ComfyUIWaitTimeout) as caught: + self.client.wait_for_result(PROMPT_ID) + outputs = self.client.wait_for_result(caught.exception.prompt_id) + self.assertEqual(OUTPUTS, outputs) + self.assertTrue( + all(call.args[0].endswith(PROMPT_ID) for call in get.call_args_list) + ) + post.assert_not_called() + + def test_invalid_durations_fail_before_http(self): + for value in (0, -1, float("nan"), float("inf"), -float("inf")): + with ( + self.subTest(value=value), + patch.object(client_module.requests, "get") as get, + ): + with self.assertRaises(ValueError): + ComfyUIClient(port=8188, request_timeout=value) + for argument in ("timeout", "poll_interval"): + with self.assertRaises(ValueError): + self.client.wait_for_result(PROMPT_ID, **{argument: value}) + get.assert_not_called() + + def test_all_http_operations_receive_timeout(self): + for duration in (1.5, 30.0): + with self.subTest(duration=duration), tempfile.TemporaryDirectory() as temp: + client = ComfyUIClient(port=8188, request_timeout=duration) + source = Path(temp) / "source.png" + source.write_bytes(b"synthetic image") + reply = response({"prompt_id": PROMPT_ID}) + with patch.object( + client_module.requests, "post", return_value=reply + ) as post: + client.upload_image(str(source), subfolder="assets") + self.assertEqual(PROMPT_ID, client.queue_workflow({})) + self.assertEqual( + [duration, duration], + [c.kwargs["timeout"] for c in post.call_args_list], + ) + self.assertTrue( + post.call_args_list[0].kwargs["files"]["image"][1].closed + ) + reply = response({}) + reply.iter_content.return_value = [b"mesh"] + with patch.object( + client_module.requests, "get", return_value=reply + ) as get: + self.assertTrue(client.is_alive()) + client.get_queue_status() + client.get_history(PROMPT_ID) + output = client.download_output("mesh.glb", temp) + self.assertEqual( + [min(5, duration), duration, duration, duration], + [c.kwargs["timeout"] for c in get.call_args_list], + ) + self.assertEqual(b"mesh", Path(output).read_bytes()) + reply.__exit__.assert_called_once() + + def test_get_timeouts_are_capped_by_client_configuration(self): + with patch.object( + client_module.requests, "get", return_value=response({}) + ) as get: + self.client.get_history(PROMPT_ID, timeout=99) + self.client.get_queue_status(timeout=1) + self.assertEqual([7, 1], [c.kwargs["timeout"] for c in get.call_args_list]) + + def test_submission_timeout_is_not_retried(self): + error = requests.exceptions.ReadTimeout("response lost") + with ( + patch.object(client_module.requests, "post", side_effect=error) as post, + self.assertRaises(requests.exceptions.ReadTimeout) as caught, + ): + self.client.queue_workflow({}) + self.assertIs(error, caught.exception) + post.assert_called_once() + + def test_http_error_is_preserved(self): + reply = response() + reply.raise_for_status.side_effect = requests.exceptions.HTTPError("503") + with ( + patch.object(client_module.requests, "get", return_value=reply), + self.assertRaises(requests.exceptions.HTTPError), + ): + self.client.wait_for_result(PROMPT_ID) + + def test_download_closes_response_on_stream_error(self): + reply = response() + reply.iter_content.side_effect = requests.exceptions.ConnectionError( + "lost stream" + ) + with ( + tempfile.TemporaryDirectory() as temp, + patch.object(client_module.requests, "get", return_value=reply), + self.assertRaises(requests.exceptions.ConnectionError), + ): + self.client.download_output("mesh.glb", temp) + reply.__exit__.assert_called_once() + + def test_health_check_handles_missing_dependency_and_network_errors(self): + with patch.object(client_module, "requests", None): + self.assertFalse(self.client.is_alive()) + with patch.object( + client_module.requests, "get", side_effect=requests.exceptions.ReadTimeout() + ): + self.assertFalse(self.client.is_alive()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/asset_creator/test_preflight.py b/tests/asset_creator/test_preflight.py new file mode 100644 index 00000000..2d06be36 --- /dev/null +++ b/tests/asset_creator/test_preflight.py @@ -0,0 +1,188 @@ +"""Local ordering regressions; synthetic templates, no ComfyUI or Blender run.""" + +import copy +import io +import json +from contextlib import redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory +import unittest +from unittest.mock import Mock, patch + +from addons.official.storycore_asset_creator.src import trellis_workflows as workflows +from addons.official.storycore_asset_creator.src.pipeline_image_to_3d import ( + ImageTo3DPipeline, +) + + +def synthetic_template(): + """Exercise existing editor fields; this is not an executable API graph.""" + return { + "nodes": [ + {"type": "Trellis2LoadImageWithTransparency", "widgets_values": ["old.png"]}, + {"type": "PrimitiveString", "widgets_values": ["OldAsset"]}, + { + "type": "Trellis2MeshWithVoxelAdvancedGenerator", + "widgets_values": [0, "fixed", "1024"], + }, + {"type": "Trellis2PreProcessImage", "widgets_values": [25, True]}, + ], + "extra": {"fixture": "synthetic-editor-template"}, + } + + +class PreflightTests(unittest.TestCase): + def setUp(self): + temporary = TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.recipes = self.root / "workflows" + self.recipes.mkdir() + self.image = self.root / "source.png" + # The fake upload never reads this marker as an image. + self.image.write_bytes(b"synthetic input marker, not a PNG") + self.output = self.root / "output" + recipe_patch = patch.object(workflows, "_WORKFLOWS_DIR", self.recipes) + recipe_patch.start() + self.addCleanup(recipe_patch.stop) + # Fail any accidental request, including a future unmocked code path. + network_patch = patch( + "socket.socket", side_effect=AssertionError("network forbidden") + ) + network_patch.start() + self.addCleanup(network_patch.stop) + self.pipeline = ImageTo3DPipeline(comfyui_port=8188) + self.client = Mock(spec=self.pipeline.client) + self.pipeline.client = self.client + self.client.is_alive.return_value = True + self.client.upload_image.return_value = {"name": "server-renamed.png"} + self.client.queue_workflow.return_value = "synthetic-prompt-id" + self.client.wait_for_result.return_value = {"fixture": {}} + self.client.get_output_files.return_value = [] + self.client.download_output.side_effect = ( + lambda filename, dest: str(Path(dest) / filename) + ) + + def write_recipe(self, template=None, preset="lowvram"): + path = self.recipes / workflows.PRESETS[preset] + path.write_text( + json.dumps(synthetic_template() if template is None else template), + encoding="utf-8", + ) + return path + + def run_pipeline(self, **kwargs): + with redirect_stdout(io.StringIO()): + return self.pipeline.run( + image_path=str(self.image), + asset_name="Fixture", + output_dir=str(self.output), + **kwargs, + ) + + def assert_no_client_calls(self): + self.assertEqual(self.client.mock_calls, []) + self.assertFalse(self.output.exists()) + + def test_missing_recipe_fails_before_any_client_call(self): + with self.assertRaises(FileNotFoundError): + self.run_pipeline() + self.assert_no_client_calls() + + def test_invalid_json_fails_before_any_client_call(self): + path = self.write_recipe() + path.write_text("{broken", encoding="utf-8") + with self.assertRaises(json.JSONDecodeError): + self.run_pipeline() + self.assert_no_client_calls() + + def test_unknown_preset_does_not_fall_back_to_lowvram(self): + self.write_recipe() + with self.assertRaisesRegex(ValueError, "Preset Trellis2 inconnu"): + self.run_pipeline(preset="lowvrma") + self.assert_no_client_calls() + + def test_missing_source_fails_before_any_client_call(self): + self.write_recipe() + self.image.unlink() + with self.assertRaises(FileNotFoundError): + self.run_pipeline() + self.assert_no_client_calls() + + def test_invalid_editor_shape_fails_before_any_client_call(self): + for template in ([], {}, {"nodes": {}}, {"nodes": [None]}): + with self.subTest(template=template): + self.write_recipe(template) + with self.assertRaises(ValueError): + self.run_pipeline() + self.assert_no_client_calls() + + def test_missing_or_unpatchable_image_node_fails_before_upload(self): + for widgets in (None, [], "not-a-widget-list"): + with self.subTest(widgets=widgets): + template = synthetic_template() + template["nodes"][0]["widgets_values"] = widgets + self.write_recipe(template) + with self.assertRaises(ValueError): + self.run_pipeline() + self.assert_no_client_calls() + self.write_recipe({"nodes": [{"type": "Unrelated", "widgets_values": []}]}) + with self.assertRaises(ValueError): + self.run_pipeline() + self.assert_no_client_calls() + + def test_prepared_recipe_is_reused_and_upload_name_is_patched(self): + path = self.write_recipe() + original = json.loads(path.read_text(encoding="utf-8")) + + def server_available(): + # A template changed after preflight must not replace the prepared one. + path.write_text("{changed after preflight", encoding="utf-8") + return True + + self.client.is_alive.side_effect = server_available + with patch.object( + workflows, "load_workflow", wraps=workflows.load_workflow + ) as load: + result = self.run_pipeline(seed=73, remove_background=False) + load.assert_called_once_with("lowvram") + self.client.upload_image.assert_called_once_with(str(self.image)) + self.client.queue_workflow.assert_called_once() + submitted = self.client.queue_workflow.call_args.args[0] + expected = copy.deepcopy(original) + expected["nodes"][0]["widgets_values"][0] = "server-renamed.png" + expected["nodes"][1]["widgets_values"][0] = "Fixture" + expected["nodes"][2]["widgets_values"] = [73, "fixed", "512"] + expected["nodes"][3]["widgets_values"] = [25, False] + self.assertEqual(submitted, expected) + self.assertEqual(result["prompt_id"], "synthetic-prompt-id") + self.assertEqual( + [call[0] for call in self.client.mock_calls[:4]], + ["is_alive", "upload_image", "queue_workflow", "wait_for_result"], + ) + + def test_all_declared_presets_can_prepare_without_modifying_source(self): + for preset in workflows.PRESETS: + with self.subTest(preset=preset): + path = self.write_recipe(preset=preset) + before = path.read_bytes() + prepared = workflows.build_workflow( + "replacement.png", "Fixture", preset + ) + self.assertEqual( + prepared["nodes"][0]["widgets_values"], ["replacement.png"] + ) + self.assertEqual(path.read_bytes(), before) + + def test_unavailable_server_never_uploads_or_queues(self): + self.write_recipe() + self.client.is_alive.return_value = False + with self.assertRaises(ConnectionError): + self.run_pipeline() + self.client.is_alive.assert_called_once() + self.client.upload_image.assert_not_called() + self.client.queue_workflow.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/fixtures/music_plan/free.json b/tests/fixtures/music_plan/free.json new file mode 100644 index 00000000..c299b0e1 --- /dev/null +++ b/tests/fixtures/music_plan/free.json @@ -0,0 +1,14 @@ +{ + "schema_version": 1, + "plan_id": "fixture-free-v1", + "mode": "free", + "title": "Ambient interlude", + "duration_seconds": 20, + "sections": [ + {"id": "interlude", "start_seconds": 0, "end_seconds": 20, "purpose": "open ambience", "energy": 0.25, "tension": 0.2, "dialogue_safe": true, "motif_refs": [], "instrument_roles": []} + ], + "motifs": [], + "sync_cues": [], + "lyrics": null, + "provenance": {"commercial_target": false, "dependencies": []} +} diff --git a/tests/fixtures/music_plan/full.json b/tests/fixtures/music_plan/full.json new file mode 100644 index 00000000..497e939d --- /dev/null +++ b/tests/fixtures/music_plan/full.json @@ -0,0 +1,23 @@ +{ + "schema_version": 1, + "plan_id": "fixture-full-v1", + "mode": "full", + "title": "Arrival and reveal", + "narrative_intent": "Build restrained anticipation, then reveal the destination without masking dialogue.", + "duration_seconds": 30, + "tempo_bpm": 92, + "meter": "4/4", + "tonal_center": "D minor", + "sections": [ + {"id": "arrival", "start_seconds": 0, "end_seconds": 18, "purpose": "quiet approach", "energy": 0.35, "tension": 0.55, "dialogue_safe": true, "motif_refs": ["arrival-motif"], "instrument_roles": ["soft strings", "pulse"]}, + {"id": "reveal", "start_seconds": 18, "end_seconds": 30, "purpose": "visual reveal", "energy": 0.72, "tension": 0.3, "dialogue_safe": false, "motif_refs": ["arrival-motif"], "instrument_roles": ["strings", "low brass"]} + ], + "motifs": [ + {"id": "arrival-motif", "intent": "recognizable four-note identity", "symbolic_hint": "short rising phrase, sparse rhythm"} + ], + "sync_cues": [ + {"id": "door-open", "time_seconds": 18, "intent": "accent the reveal", "story_ref": "scene-01/shot-04"} + ], + "lyrics": null, + "provenance": {"commercial_target": true, "dependencies": []} +} diff --git a/tests/fixtures/music_plan/guided.json b/tests/fixtures/music_plan/guided.json new file mode 100644 index 00000000..aa6f9ba3 --- /dev/null +++ b/tests/fixtures/music_plan/guided.json @@ -0,0 +1,17 @@ +{ + "schema_version": 1, + "plan_id": "fixture-guided-v1", + "mode": "guided", + "title": "Chase transition", + "duration_seconds": 24, + "sections": [ + {"id": "setup", "start_seconds": 0, "end_seconds": 8, "purpose": "establish momentum", "energy": 0.45, "tension": 0.5, "dialogue_safe": true, "motif_refs": [], "instrument_roles": ["percussion"]}, + {"id": "chase", "start_seconds": 8, "end_seconds": 24, "purpose": "accelerate chase", "energy": 0.9, "tension": 0.85, "dialogue_safe": false, "motif_refs": [], "instrument_roles": ["percussion", "bass"]} + ], + "motifs": [], + "sync_cues": [ + {"id": "cut-chase", "time_seconds": 8, "intent": "mark transition to chase", "story_ref": "scene-02/shot-01"} + ], + "lyrics": null, + "provenance": {"commercial_target": true, "dependencies": []} +} diff --git a/tests/game_bridge/test_contract.py b/tests/game_bridge/test_contract.py new file mode 100644 index 00000000..2183b755 --- /dev/null +++ b/tests/game_bridge/test_contract.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import hashlib +import io +import json +import os +import tempfile +import unittest +from contextlib import redirect_stderr +from pathlib import Path + +from src.game_bridge.contract import ( + ContractError, + compile_spec, + main, + verify_manifest, +) + + +ROOT = Path(__file__).resolve().parents[2] +FIXTURE = ( + ROOT + / "fixtures" + / "storycore-game" + / "coinfall-chronicle" + / "storycore_game_spec.json" +) +GODOT_FIXTURE = FIXTURE.parent / "godot" + + +def load_fixture() -> dict[str, object]: + return json.loads(FIXTURE.read_text(encoding="utf-8")) + + +class ContractTests(unittest.TestCase): + def test_fixture_compiles_and_verifies(self) -> None: + manifest, evidence = compile_spec(load_fixture()) + + self.assertTrue(verify_manifest(manifest)) + self.assertEqual(evidence["status"], "pass") + self.assertFalse(manifest["boundary"]["private_components_included"]) + self.assertEqual(manifest["runtime"]["authority"], "local-fixture") + + def test_compilation_is_deterministic(self) -> None: + original = load_fixture() + reversed_keys = dict(reversed(list(original.items()))) + + first = compile_spec(original) + second = compile_spec(reversed_keys) + + self.assertEqual(first, second) + + def test_tampered_manifest_fails_verification(self) -> None: + manifest, _ = compile_spec(load_fixture()) + manifest["gameplay"]["score_target"] = 999 + + self.assertFalse(verify_manifest(manifest)) + + def test_unknown_field_is_rejected(self) -> None: + spec = load_fixture() + spec["private_agent_graph"] = {"enabled": True} + + with self.assertRaisesRegex(ContractError, "unknown fields"): + compile_spec(spec) + + def test_private_components_are_rejected(self) -> None: + spec = load_fixture() + spec["boundary"]["private_components_included"] = True + + with self.assertRaisesRegex(ContractError, "must be false"): + compile_spec(spec) + + def test_unresolved_item_reference_is_rejected(self) -> None: + spec = load_fixture() + spec["quest"]["objectives"][0]["target_id"] = "missing_rune" + + with self.assertRaisesRegex(ContractError, "unresolved item reference"): + compile_spec(spec) + + def test_partial_localization_is_rejected(self) -> None: + spec = load_fixture() + del spec["project"]["title"]["fr"] + + with self.assertRaisesRegex(ContractError, "missing locales: fr"): + compile_spec(spec) + + def test_runtime_and_backend_authority_must_match(self) -> None: + spec = load_fixture() + spec["boundary"]["authoritative_backend"] = "external" + + with self.assertRaisesRegex(ContractError, "must match runtime authority"): + compile_spec(spec) + + def test_cli_writes_manifest_and_evidence(self) -> None: + with tempfile.TemporaryDirectory(dir=ROOT) as directory: + output = Path(directory) + + result = main( + [ + "--input", + str(FIXTURE.relative_to(ROOT)), + "--output-dir", + str(output.relative_to(ROOT)), + ] + ) + + self.assertEqual(result, 0) + manifest = json.loads( + (output / "storycore_game_manifest.json").read_text(encoding="utf-8") + ) + evidence = json.loads( + (output / "storycore_game_evidence.json").read_text(encoding="utf-8") + ) + self.assertTrue(verify_manifest(manifest)) + self.assertEqual(manifest["content_sha256"], evidence["content_sha256"]) + + def test_cli_rejects_absolute_paths(self) -> None: + errors = io.StringIO() + argv = ["--input", str(FIXTURE), "--output-dir", "fixtures"] + + with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: + main(argv) + + self.assertEqual(raised.exception.code, 2) + self.assertIn("allowlisted workspace-relative path", errors.getvalue()) + + def test_cli_rejects_parent_traversal(self) -> None: + errors = io.StringIO() + argv = [ + "--input", + str(FIXTURE.relative_to(ROOT)), + "--output-dir", + "../storycore-game-escape", + ] + + with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: + main(argv) + + self.assertEqual(raised.exception.code, 2) + self.assertIn("allowlisted workspace-relative path", errors.getvalue()) + + def test_cli_rejects_non_allowlisted_path_characters(self) -> None: + errors = io.StringIO() + argv = [ + "--input", + str(FIXTURE.relative_to(ROOT)), + "--output-dir", + "fixtures/storycore-game/$escape", + ] + + with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: + main(argv) + + self.assertEqual(raised.exception.code, 2) + self.assertIn("allowlisted workspace-relative path", errors.getvalue()) + + @unittest.skipUnless(hasattr(os, "O_NOFOLLOW"), "requires O_NOFOLLOW") + def test_cli_refuses_final_output_symlinks(self) -> None: + with tempfile.TemporaryDirectory(dir=ROOT) as directory: + output = Path(directory) + target = output / "target.json" + target.write_text("unchanged", encoding="utf-8") + (output / "storycore_game_manifest.json").symlink_to(target) + errors = io.StringIO() + argv = [ + "--input", + str(FIXTURE.relative_to(ROOT)), + "--output-dir", + str(output.relative_to(ROOT)), + ] + + with redirect_stderr(errors), self.assertRaises(SystemExit) as raised: + main(argv) + + self.assertEqual(raised.exception.code, 2) + self.assertEqual(target.read_text(encoding="utf-8"), "unchanged") + + def test_checked_in_godot_inputs_match_compiler(self) -> None: + expected_manifest, expected_evidence = compile_spec(load_fixture()) + checked_manifest = json.loads( + (GODOT_FIXTURE / "storycore_game_manifest.json").read_text( + encoding="utf-8" + ) + ) + checked_evidence = json.loads( + (GODOT_FIXTURE / "storycore_game_evidence.json").read_text( + encoding="utf-8" + ) + ) + + self.assertEqual(checked_manifest, expected_manifest) + self.assertEqual(checked_evidence, expected_evidence) + + def test_godot_smoke_evidence_matches_fixture_sources(self) -> None: + evidence = json.loads( + (GODOT_FIXTURE / "godot_smoke_evidence.json").read_text( + encoding="utf-8" + ) + ) + expected_hashes = { + "main_script_sha256": "Main.gd", + "manifest_file_sha256": "storycore_game_manifest.json", + "project_file_sha256": "project.godot", + "smoke_script_sha256": "SmokeTest.gd", + } + + for evidence_key, file_name in expected_hashes.items(): + content = (GODOT_FIXTURE / file_name).read_bytes() + self.assertEqual( + evidence["fixture"][evidence_key], hashlib.sha256(content).hexdigest() + ) + self.assertEqual(evidence["status"], "pass") + self.assertEqual(evidence["result"]["marker"], "STORYCORE_GAME_SMOKE_PASS") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_multi_subject_router.py b/tests/test_multi_subject_router.py new file mode 100644 index 00000000..dc2ce46d --- /dev/null +++ b/tests/test_multi_subject_router.py @@ -0,0 +1,93 @@ +import pytest + +from src.video_validation.multisubject_router import ( + GenerationStrategy, + MultiSubjectShot, + route_multi_subject_shot, +) + + +def test_single_subject_uses_direct(): + decision = route_multi_subject_shot(MultiSubjectShot(subject_count=1)) + assert decision.strategy == GenerationStrategy.DIRECT + assert decision.requires_intermediate_validation is False + + +def test_contact_required_uses_sequential(): + decision = route_multi_subject_shot( + MultiSubjectShot(subject_count=2, contact_required=True) + ) + assert decision.strategy == GenerationStrategy.SEQUENTIAL + assert "contact_required" in decision.reasons + assert decision.requires_intermediate_validation is True + + +def test_strong_interaction_uses_sequential(): + decision = route_multi_subject_shot( + MultiSubjectShot(subject_count=2, interaction_strength=0.85) + ) + assert decision.strategy == GenerationStrategy.SEQUENTIAL + assert "strong_subject_interaction" in decision.reasons + + +def test_identity_critical_independent_subjects_use_parallel(): + decision = route_multi_subject_shot( + MultiSubjectShot( + subject_count=3, + identity_criticality=0.95, + interaction_strength=0.1, + occlusion_level=0.1, + ) + ) + assert decision.strategy == GenerationStrategy.PARALLEL + assert decision.requires_intermediate_validation is True + assert "identity_critical" in decision.reasons + + +def test_camera_motion_plus_continuity_uses_sequential(): + decision = route_multi_subject_shot( + MultiSubjectShot( + subject_count=2, + interaction_strength=0.5, + occlusion_level=0.5, + identity_criticality=0.5, + camera_motion=0.9, + temporal_dependency=0.6, + ) + ) + assert decision.strategy == GenerationStrategy.SEQUENTIAL + assert decision.reasons == ("camera_motion_with_temporal_dependency",) + + +def test_simple_two_subject_scene_defaults_direct(): + decision = route_multi_subject_shot( + MultiSubjectShot( + subject_count=2, + interaction_strength=0.45, + occlusion_level=0.2, + identity_criticality=0.45, + ) + ) + assert decision.strategy == GenerationStrategy.DIRECT + assert decision.reasons == ("low_interaction_complexity",) + + +def test_compute_constrained_scene_can_stay_direct(): + decision = route_multi_subject_shot( + MultiSubjectShot( + subject_count=2, + interaction_strength=0.4, + occlusion_level=0.4, + identity_criticality=0.5, + compute_budget=0.1, + ) + ) + assert decision.strategy == GenerationStrategy.DIRECT + assert decision.reasons == ("compute_budget_constrained",) + + +def test_invalid_inputs_fail_closed(): + with pytest.raises(ValueError): + MultiSubjectShot(subject_count=-1) + with pytest.raises(ValueError): + MultiSubjectShot(subject_count=2, interaction_strength=1.1) diff --git a/tests/test_music_plan.py b/tests/test_music_plan.py new file mode 100644 index 00000000..7c6ffe03 --- /dev/null +++ b/tests/test_music_plan.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import copy +import json +from pathlib import Path + +import pytest + +from src.music_plan import execution_delta, validate_music_plan +from src.music_provider import ( + MockMusicProvider, + MusicProviderContractError, + build_provider_handoff, +) + +FIXTURES = Path(__file__).parent / "fixtures" / "music_plan" + + +def _load(name: str) -> dict: + return json.loads((FIXTURES / name).read_text(encoding="utf-8")) + + +def test_three_reference_modes_validate_without_model() -> None: + for name in ("full.json", "guided.json", "free.json"): + result = validate_music_plan(_load(name)) + assert result.valid, (name, result.errors) + + +def test_required_plan_id_is_enforced_by_schema() -> None: + plan = _load("full.json") + plan.pop("plan_id") + result = validate_music_plan(plan) + assert not result.valid + assert any("schema:$" in error and "plan_id" in error for error in result.errors) + + +def test_energy_range_is_enforced_by_schema() -> None: + plan = _load("full.json") + plan["sections"][0]["energy"] = 2 + result = validate_music_plan(plan) + assert not result.valid + assert any("energy" in error and "greater than the maximum" in error for error in result.errors) + + +def test_overlapping_sections_fail_closed() -> None: + plan = _load("guided.json") + plan["sections"][1]["start_seconds"] = 7 + result = validate_music_plan(plan) + assert not result.valid + assert any("overlaps" in error for error in result.errors) + + +def test_unresolved_motif_reference_fails_closed() -> None: + plan = _load("full.json") + plan["sections"][0]["motif_refs"] = ["missing-motif"] + result = validate_music_plan(plan) + assert not result.valid + assert any("unresolved motif_refs" in error for error in result.errors) + + +def test_commercial_target_blocks_noncommercial_model_weights() -> None: + plan = _load("full.json") + plan["provenance"]["dependencies"].append({ + "name": "example-nc-weights", + "kind": "model-weights", + "license": "CC BY-NC 4.0", + }) + result = validate_music_plan(plan) + assert not result.valid + assert any("non-commercial model weights" in error for error in result.errors) + + +def test_commercial_target_blocks_unknown_model_weight_licence() -> None: + plan = _load("full.json") + plan["provenance"]["dependencies"].append({ + "name": "unqualified-weights", + "kind": "model-weights", + "license": "unknown", + }) + result = validate_music_plan(plan) + assert not result.valid + assert any("unknown licence" in error for error in result.errors) + + +def test_noncommercial_research_fixture_can_remain_noncommercial() -> None: + plan = _load("free.json") + plan["provenance"]["dependencies"].append({ + "name": "example-nc-weights", + "kind": "model-weights", + "license": "CC BY-NC 4.0", + }) + result = validate_music_plan(plan) + assert result.valid + + +def test_provider_degradation_is_explicit() -> None: + requested = _load("full.json") + executed = copy.deepcopy(requested) + executed["mode"] = "free" + executed.pop("motifs") + delta = execution_delta(requested, executed) + assert "mode:full->free" in delta + assert "dropped:motifs" in delta + + +def test_duration_shortening_is_reported() -> None: + requested = _load("full.json") + executed = copy.deepcopy(requested) + executed["duration_seconds"] = requested["duration_seconds"] / 2 + for section in executed["sections"]: + section["start_seconds"] /= 2 + section["end_seconds"] /= 2 + for cue in executed["sync_cues"]: + cue["time_seconds"] /= 2 + assert validate_music_plan(executed).valid + delta = execution_delta(requested, executed) + assert "changed:duration_seconds" in delta + assert "changed:sections[0].end_seconds" in delta + assert any(item.startswith("changed:sync_cues[0].time_seconds") for item in delta) + + +def test_emptied_sync_cues_are_reported() -> None: + requested = _load("full.json") + executed = copy.deepcopy(requested) + executed["sync_cues"] = [] + assert validate_music_plan(executed).valid + delta = execution_delta(requested, executed) + assert "changed:sync_cues.length" in delta + + +def test_nested_cue_change_is_reported() -> None: + requested = _load("full.json") + executed = copy.deepcopy(requested) + executed["sync_cues"][0]["time_seconds"] += 1 + assert validate_music_plan(executed).valid + delta = execution_delta(requested, executed) + assert "changed:sync_cues[0].time_seconds" in delta + + +def test_full_capability_mock_keeps_requested_state() -> None: + result = MockMusicProvider().prepare(_load("full.json")) + assert result.requested_mode == "full" + assert result.executed_mode == "full" + assert result.deltas == () + assert result.unsupported_fields == () + assert result.acknowledged is False + assert result.reason == "" + assert result.artifact_status == "candidate" + assert result.activation_allowed is False + assert result.promoted is False + assert result.executed_external_model is False + + +def test_limited_mock_reports_mode_and_field_degradation() -> None: + provider = MockMusicProvider( + provider_id="mock-limited", + supported_modes=("guided", "free"), + fallback_mode="guided", + unsupported_fields=("motifs",), + degradation_reason="mock provider lacks full symbolic motif control", + ) + result = provider.prepare(_load("full.json")) + assert result.executed_mode == "guided" + assert "mode:full->guided" in result.deltas + assert "dropped:motifs" in result.deltas + assert result.unsupported_fields == ("motifs",) + assert result.acknowledged is True + assert result.reason + assert result.artifact_status == "candidate" + assert result.activation_allowed is False + assert result.promoted is False + assert result.executed_external_model is False + + +def test_provider_handoff_is_data_only_and_non_activating() -> None: + provider = MockMusicProvider( + provider_id="mock-limited", + supported_modes=("guided",), + fallback_mode="guided", + unsupported_fields=("motifs",), + degradation_reason="fixture capability boundary", + ) + result = provider.prepare(_load("full.json")) + handoff = build_provider_handoff( + result, + provider_version="test-v1", + model="none", + harness="music-plan-contract-tests", + hardware="github-hosted-cpu", + input_digest="sha256:input", + candidate_digest="sha256:candidate", + evidence_refs=("ci:fixture",), + verification_state="partially_verified", + ) + assert handoff["provider"]["id"] == "mock-limited" + assert handoff["provider"]["harness"] == "music-plan-contract-tests" + assert handoff["execution"]["deltas"] == list(result.deltas) + assert handoff["artifact"]["status"] == "candidate" + assert handoff["verification"]["evidence_refs"] == ["ci:fixture"] + assert handoff["activation_allowed"] is False + assert handoff["promoted"] is False + assert handoff["memory_write_performed"] is False + + +def test_verified_handoff_requires_evidence() -> None: + result = MockMusicProvider().prepare(_load("full.json")) + with pytest.raises(MusicProviderContractError, match="requires evidence"): + build_provider_handoff(result, verification_state="verified") + + +def test_silent_provider_degradation_is_rejected() -> None: + provider = MockMusicProvider( + provider_id="mock-bad", + supported_modes=("guided",), + fallback_mode="guided", + ) + with pytest.raises(MusicProviderContractError, match="explicit degradation reason"): + provider.prepare(_load("full.json")) + + +def test_provider_without_valid_fallback_fails_closed() -> None: + provider = MockMusicProvider( + provider_id="mock-no-fallback", + supported_modes=("guided",), + ) + with pytest.raises(MusicProviderContractError, match="no valid fallback"): + provider.prepare(_load("full.json")) diff --git a/tests/test_semantic_prevalidator.py b/tests/test_semantic_prevalidator.py new file mode 100644 index 00000000..e54a652e --- /dev/null +++ b/tests/test_semantic_prevalidator.py @@ -0,0 +1,123 @@ +from pathlib import Path + +from src.video_validation.semantic_prevalidator import ( + SemanticPrevalidator, + SemanticVerdict, +) + + +class FakeScorer: + provider_id = "fake-viclip" + + def __init__(self, score: float = 0.9, *, fail: bool = False): + self.value = score + self.fail = fail + self.calls = 0 + + def score(self, text: str, clip_path: Path) -> float: + self.calls += 1 + if self.fail: + raise RuntimeError("provider unavailable") + return self.value + + +def _clip(tmp_path, payload=b"fake-video"): + path = tmp_path / "clip.mp4" + path.write_bytes(payload) + return path + + +def test_high_score_passes_without_defect_signal(tmp_path): + scorer = FakeScorer(0.92) + gate = SemanticPrevalidator(scorer) + result = gate.validate("A knight enters the room", _clip(tmp_path)) + assert result.verdict == SemanticVerdict.PASS + assert result.score == 0.92 + assert result.provider_id == "fake-viclip" + assert result.metadata["semantic_cache_hit"] is False + + +def test_defect_signal_forces_escalation_even_with_high_score(tmp_path): + scorer = FakeScorer(0.99) + gate = SemanticPrevalidator(scorer) + result = gate.validate( + "Two characters shake hands", + _clip(tmp_path), + defect_signals=["identity_drift"], + ) + assert result.verdict == SemanticVerdict.ESCALATE + assert "external_defect_signal" in result.reasons + + +def test_low_and_ambiguous_scores_escalate(tmp_path): + clip = _clip(tmp_path) + low = SemanticPrevalidator(FakeScorer(0.2)).validate("A red car", clip) + mid = SemanticPrevalidator(FakeScorer(0.65)).validate("A red car", clip) + assert low.verdict == SemanticVerdict.ESCALATE + assert mid.verdict == SemanticVerdict.ESCALATE + + +def test_missing_clip_or_empty_spec_is_uncertain(tmp_path): + gate = SemanticPrevalidator(FakeScorer()) + missing = gate.validate("A scene", tmp_path / "missing.mp4") + empty = gate.validate(" ", _clip(tmp_path)) + assert missing.verdict == SemanticVerdict.UNCERTAIN + assert empty.verdict == SemanticVerdict.UNCERTAIN + + +def test_provider_failure_fails_safe(tmp_path): + gate = SemanticPrevalidator(FakeScorer(fail=True)) + result = gate.validate("A scene", _clip(tmp_path)) + assert result.verdict == SemanticVerdict.UNCERTAIN + assert result.score is None + assert result.metadata["error_type"] == "RuntimeError" + + +def test_cache_avoids_duplicate_provider_call(tmp_path): + scorer = FakeScorer(0.9) + gate = SemanticPrevalidator(scorer) + clip = _clip(tmp_path) + first = gate.validate("A scene", clip) + second = gate.validate("A scene", clip) + assert first.verdict == second.verdict + assert first.score == second.score + assert second.metadata["semantic_cache_hit"] is True + assert scorer.calls == 1 + + +def test_changed_defect_signals_reuse_semantic_score_and_recompute_verdict(tmp_path): + scorer = FakeScorer(0.9) + gate = SemanticPrevalidator(scorer) + clip = _clip(tmp_path) + assert gate.validate("A scene", clip).verdict == SemanticVerdict.PASS + escalated = gate.validate("A scene", clip, defect_signals=["camera_mismatch"]) + assert escalated.verdict == SemanticVerdict.ESCALATE + assert escalated.metadata["semantic_cache_hit"] is True + assert scorer.calls == 1 + + +def test_force_refresh_calls_provider_again(tmp_path): + scorer = FakeScorer(0.9) + gate = SemanticPrevalidator(scorer) + clip = _clip(tmp_path) + gate.validate("A scene", clip) + refreshed = gate.validate("A scene", clip, force_refresh=True) + assert scorer.calls == 2 + assert refreshed.metadata["semantic_cache_hit"] is False + + +def test_score_out_of_range_is_uncertain(tmp_path): + gate = SemanticPrevalidator(FakeScorer(1.5)) + result = gate.validate("A scene", _clip(tmp_path)) + assert result.verdict == SemanticVerdict.UNCERTAIN + assert result.score is None + assert "score_out_of_range" in result.reasons + + +def test_non_finite_scores_are_uncertain(tmp_path): + clip = _clip(tmp_path) + for value in (float("nan"), float("inf"), float("-inf")): + result = SemanticPrevalidator(FakeScorer(value)).validate("A scene", clip) + assert result.verdict == SemanticVerdict.UNCERTAIN + assert result.score is None + assert "score_out_of_range" in result.reasons diff --git a/tests/test_shot_spec_router_adapter.py b/tests/test_shot_spec_router_adapter.py new file mode 100644 index 00000000..d482cc9a --- /dev/null +++ b/tests/test_shot_spec_router_adapter.py @@ -0,0 +1,131 @@ +from dataclasses import dataclass, field + +import pytest + +from src.video_validation.multisubject_router import GenerationStrategy +from src.video_validation.shot_spec_adapter import ( + extract_multi_subject_shot, + route_shot_spec, +) + + +@dataclass +class ShotLike: + camera_movement: str = "static" + metadata: dict = field(default_factory=dict) + + +def test_single_subject_existing_shot_routes_direct(): + decision = route_shot_spec(ShotLike(camera_movement="static"), subjects=["hero"]) + assert decision.strategy is GenerationStrategy.DIRECT + + +def test_scene_characters_present_can_supply_subject_count(): + spec = { + "characters_present": ["hero", "rival"], + "camera_movement": "static", + "metadata": { + "multi_subject_routing": { + "contact_required": True, + "interaction_strength": 0.9, + } + }, + } + decision = route_shot_spec(spec) + assert decision.strategy is GenerationStrategy.SEQUENTIAL + assert decision.requires_intermediate_validation is True + + +def test_identity_critical_independent_subjects_route_parallel(): + shot = ShotLike(camera_movement="tracking") + decision = route_shot_spec( + shot, + subjects=["a", "b", "c"], + overrides={ + "identity_criticality": 0.95, + "interaction_strength": 0.1, + "occlusion_level": 0.1, + }, + ) + assert decision.strategy is GenerationStrategy.PARALLEL + + +def test_camera_motion_text_is_normalized_deterministically(): + routing_input = extract_multi_subject_shot( + ShotLike(camera_movement="fast orbit camera"), + subjects=["a", "b"], + overrides={"temporal_dependency": 0.7}, + ) + assert routing_input.camera_motion == 0.75 + decision = route_shot_spec( + ShotLike(camera_movement="fast orbit camera"), + subjects=["a", "b"], + overrides={"temporal_dependency": 0.7}, + ) + assert decision.strategy is GenerationStrategy.SEQUENTIAL + + +def test_unknown_subject_count_fails_closed(): + with pytest.raises(ValueError, match="subject_count is required"): + route_shot_spec(ShotLike()) + + +def test_routing_metadata_can_override_subject_count_and_budget(): + spec = { + "camera_movement": "pan", + "metadata": { + "multi_subject_routing": { + "subject_count": 2, + "compute_budget": 0.1, + "interaction_strength": 0.4, + "occlusion_level": 0.4, + } + }, + } + decision = route_shot_spec(spec) + assert decision.strategy is GenerationStrategy.DIRECT + assert "compute_budget_constrained" in decision.reasons + + +def test_contradictory_subject_counts_fail_closed(): + spec = { + "subject_count": 1, + "characters_present": ["hero", "rival", "witness"], + "camera_movement": "static", + } + with pytest.raises(ValueError, match="contradictory subject counts"): + route_shot_spec(spec) + + +def test_explicit_subject_argument_must_match_shot_metadata(): + spec = {"characters_present": ["hero", "rival"], "camera_movement": "static"} + with pytest.raises(ValueError, match="contradictory subject counts"): + route_shot_spec(spec, subjects=["hero"]) + + +def test_invalid_score_fails_closed(): + with pytest.raises(ValueError, match="interaction_strength"): + extract_multi_subject_shot( + ShotLike(), + subjects=["a", "b"], + overrides={"interaction_strength": 1.2}, + ) + + +def test_non_finite_score_fails_closed(): + for value in (float("nan"), float("inf"), float("-inf")): + with pytest.raises(ValueError, match="interaction_strength"): + extract_multi_subject_shot( + ShotLike(), + subjects=["a", "b"], + overrides={"interaction_strength": value}, + ) + + +def test_non_boolean_contact_required_fails_closed(): + with pytest.raises(ValueError, match="contact_required"): + extract_multi_subject_shot( + ShotLike(), + subjects=["a", "b"], + overrides={"contact_required": "yes"}, + )