Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions addons/official/storycore_asset_creator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,16 @@ 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).

Un [diagnostic local de workflows](WORKFLOW_INSPECTION.md) inventorie désormais
les fichiers, leur empreinte, le format et les nœuds déclarés. Depuis la racine :

```bash
python -m addons.official.storycore_asset_creator.src.workflow_inspection
```

Il signale aussi les presets absents. Son résultat JSON n'atteste aucune
compatibilité d'exécution ; il ne télécharge aucun modèle et ne lance aucun job.

### 3. Installation de l'addon

```
Expand Down
95 changes: 95 additions & 0 deletions addons/official/storycore_asset_creator/WORKFLOW_INSPECTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Inspecter les recettes avant leur adaptation

Ce diagnostic lit des fichiers locaux et produit un inventaire JSON. La fonction
Python `inspect_workflow_file` et la CLI partagent la même logique ; un futur
adaptateur MCP peut appeler cette fonction. Aucun outil MCP n'est enregistré par
cette tranche.

Depuis la racine de StoryCore, inventorier les quatre presets attendus :

```bash
python -m addons.official.storycore_asset_creator.src.workflow_inspection
```

Inspecter les recettes d'un dossier local explicite, ou des fichiers individuels :

```bash
python -m addons.official.storycore_asset_creator.src.workflow_inspection --presets-dir ./mes-recettes
python -m addons.official.storycore_asset_creator.src.workflow_inspection ./workflow.json ./workflow-api.json
```

Le diagnostic ne recherche pas récursivement d'autres fichiers. `--presets-dir`
cherche les quatre noms de `PRESETS` ; un fichier absent reste signalé comme
absent, sans choix d'une autre recette. Sans argument, le dossier de l'addon est
utilisé. `--help` affiche une aide textuelle ; les autres résultats et erreurs
d'arguments sont rendus en JSON sur stdout. Le chemin lu est une suite de noms
simples : une remontée `..`, un chemin relatif à un lecteur, un joker ou un caractère
de contrôle est refusé avant toute ouverture, et le rapport porte alors `path_refused`.

| Champ | Sens |
|---|---|
| `schema` | Contrat `storycore.workflow-inspection/v1` de la CLI |
| `status` | `inspected`, `missing` ou `invalid` pour chaque fichier |
| `sha256`, `bytes` | Empreinte et taille des octets lus, sans réécriture |
| `format` | `editor`, `api` ou `unknown` |
| `node_count`, `node_types` | Nœuds déclarés dans le document, y compris ceux désactivés |
| `declared_extensions` | Couples identifiant/version déclarés par `cnr_id` ou `aux_id` et `ver` |
| `api_export_required` | `true` pour un document d'éditeur, `false` pour une structure API reconnue, sinon `null` |
| `execution_verified` | Toujours `false` : aucune exécution n'a lieu |

Le code de sortie **0 signifie que l'inventaire a abouti**, même pour un document
d'éditeur nécessitant un export. Le code 2 signale un fichier absent/invalide ou
une erreur d'arguments. Aucun de ces résultats ne donne une autorisation de
génération. Un consommateur MCP doit conserver cette distinction.

La reconnaissance du format vérifie une structure minimale : liste de nœuds avec
identifiants/types pour l'éditeur ; table de nœuds avec `class_type` et `inputs`
pour l'API. Elle ne valide ni les liens, ni les types/valeurs d'entrées des nœuds,
ni les sorties, ni les modèles ou extensions installés. Les doublons de clés JSON
et les identifiants de nœuds ambigus dans un document d'éditeur sont refusés.

## Observation sur des sources publiques

Deux fichiers officiels de `visualbruno/ComfyUI-Trellis2`, figés à
`14597418bbe33a440ead4667e2966408f0524a21`, ont été lus sans les exécuter :

| Exemple amont | Nœuds déclarés | Format observé |
|---|---:|---|
| [MeshWithTexturing.json](https://github.com/visualbruno/ComfyUI-Trellis2/blob/14597418bbe33a440ead4667e2966408f0524a21/example_workflows/MeshWithTexturing.json) | 24 | Éditeur |
| [MeshWithTexturing_LowPoly.json](https://github.com/visualbruno/ComfyUI-Trellis2/blob/14597418bbe33a440ead4667e2966408f0524a21/example_workflows/MeshWithTexturing_LowPoly.json) | 27 | Éditeur |

Les octets reçus ont été vérifiés contre leurs identifiants de blobs Git, puis
le diagnostic a été exécuté avec la création de sockets interdite. Le
[relevé JSON](../../../tests/asset_creator/evidence/upstream-workflow-inspection.json)
conserve les empreintes SHA-256, listes de nœuds et versions déclarées. Ces
fichiers publics ne sont pas présentés comme les recettes PixelArtistry locales
de l'utilisateur et ne sont pas copiés dans les presets de l'addon.

Ces exemples déclarent notamment `Trellis2SparseGenerator` et
`Trellis2ShapeGenerator`, alors que les fonctions de modification existantes de
l'addon ciblent d'autres générateurs. Une correspondance par simple nom de
preset serait donc insuffisante pour garantir l'application des paramètres.

L'[exemple API officiel de ComfyUI](https://github.com/Comfy-Org/ComfyUI/blob/master/script_examples/basic_api_example.py)
(blob lu : `7e20cc2c18795c79559c9d3f52355d4cad93c70e`) montre une table
`class_type` / `inputs` et indique l'export « File → Export (API) ». Le diagnostic
ne tente pas de reconstruire ce format depuis les positions de widgets.

Le [code des nœuds Trellis2](https://github.com/visualbruno/ComfyUI-Trellis2/blob/14597418bbe33a440ead4667e2966408f0524a21/nodes.py)
(blob `a0c9e65453509cc31712a05d344f3c3efbc37156`) confirme les entrées nommées
`image` et `remove_background`. Il distingue aussi `pipeline_type` de
`sparse_structure_resolution` ; leur sens doit être préservé lors du prochain
adaptateur. Ce code a été lu, pas importé ou exécuté.

## Suite bornée

Le [relevé des presets du checkout](../../../tests/asset_creator/evidence/checkout-presets-inspection.json)
signale les quatre fichiers attendus comme absents. Les recherches dans les arbres
StoryCore, tools-suite et ultod-json-template-registry n'ont pas retrouvé ces
recettes. Cela ne décrit pas le contenu des machines de l'utilisateur.

La suite consiste à obtenir les JSON réellement employés dans Asset Factory et
leurs exports API issus de la même installation, relever les versions de nœuds et
de modèles, puis définir les champs modifiables explicitement. Les délais HTTP,
la reprise des tâches, les preuves de génération GPU et la validation des GLB
restent des travaux distincts.
239 changes: 239 additions & 0 deletions addons/official/storycore_asset_creator/src/workflow_inspection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
"""Read-only ComfyUI workflow inventory shared by Python callers and the CLI.

Recognition of an editor document or API prompt is not runtime validation.
This module never imports custom nodes, converts graphs or contacts a server.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import re
from pathlib import Path
from typing import Any

from .trellis_workflows import _WORKFLOWS_DIR, PRESETS

SCHEMA = "storycore.workflow-inspection/v1"

# A file this module is willing to open: an optional anchor, then one or more plain
# names. Traversal, drive-relative syntax, wildcards, redirection characters and
# control characters are refused by the same rule, before anything is opened.
_PLAIN_SEGMENT = r"(?:[\w][\w ._()+\-,@%\[\]']*|\.[\w][\w ._()+\-,@%\[\]']*)"
_PLAIN_PATH_RE = re.compile(
r"\A(?:[A-Za-z]:[\\/]|[\\/])?(?:" + _PLAIN_SEGMENT + r"[\\/])*" + _PLAIN_SEGMENT + r"\Z"
)


class WorkflowPathRefused(ValueError):
"""The requested path is not a plain local path this module will open."""


def _source_path(raw: str | Path) -> Path:
"""The file to read, once its name chain has been checked.

The inventory exists to open a local JSON file named by its caller, so the name is
taken as a plain chain of segments and refused otherwise. That is not a permission
system: it only keeps the read on the file that was actually named.
"""

text = str(raw).strip()
if not text or not _PLAIN_PATH_RE.match(text):
raise WorkflowPathRefused("refused path: %s" % (raw,))
return Path(text).resolve()


def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
result = {}
for key, value in pairs:
if key in result:
raise ValueError("duplicate_json_key")
result[key] = value
return result


def _reject_constant(value: str) -> None:
raise ValueError("nonfinite_json_number")


def _blank_report(path: Path) -> dict[str, Any]:
"""The report of a file that has not been read yet."""
return {
"path": str(path),
"status": "invalid",
"format": "unknown",
"sha256": None,
"bytes": None,
"node_count": 0,
"node_types": [],
"declared_extensions": [],
"api_export_required": None,
"execution_verified": False,
"errors": [],
}


def _editor_errors(nodes: list) -> str | None:
"""Why the declared editor nodes are unusable, or nothing."""
if not nodes:
return "invalid_editor_nodes"
for node in nodes:
if not isinstance(node, dict):
return "invalid_editor_nodes"
if type(node.get("id")) not in (str, int) or not str(node["id"]):
return "invalid_editor_nodes"
if not isinstance(node.get("type"), str) or not node["type"].strip():
return "invalid_editor_nodes"
if len({str(node["id"]) for node in nodes}) != len(nodes):
return "duplicate_node_id"
return None


def _api_errors(document: dict) -> str | None:
"""Why the declared API nodes are unusable, or nothing."""
if any(not key.strip() for key in document):
return "invalid_api_nodes"
for node in document.values():
if not isinstance(node, dict) or not isinstance(node.get("class_type"), str):
return "invalid_api_nodes"
if not node["class_type"].strip() or not isinstance(node.get("inputs"), dict):
return "invalid_api_nodes"
return None


def _declare(document: dict) -> tuple[str | None, str, list, set]:
"""Declared nodes, their types and the shape they were declared in.

Returns the reason the document cannot be read as a workflow, or the format, the
node list and the node types. An editor document carries its nodes under "nodes";
an API prompt is a mapping of node identifiers to node objects.
"""
if isinstance(document.get("nodes"), list):
nodes = document["nodes"]
error = _editor_errors(nodes)
if error:
return error, "editor", [], set()
return None, "editor", nodes, {node["type"] for node in nodes}
nodes = list(document.values())
error = _api_errors(document)
if error:
return error, "api", [], set()
return None, "api", nodes, {node["class_type"] for node in nodes}


def _declared_extensions(nodes: list) -> list[dict[str, Any]]:
"""The extension identifiers the nodes declare, each next to its version."""
extensions = set()
for node in nodes:
properties = node.get("properties", {})
if not isinstance(properties, dict):
continue
version = properties.get("ver")
for field in ("cnr_id", "aux_id"):
extension = properties.get(field)
if isinstance(extension, str) and isinstance(version, str):
extensions.add((field, extension, version))
return [
{"id_source": field, "id": extension, "version": version}
for field, extension, version in sorted(extensions)
]


def inspect_workflow_file(path: str | Path) -> dict[str, Any]:
"""Read one JSON file and inventory declared nodes without changing it.

The SHA-256 identifies the original bytes, including invalid JSON. Node
versions are declarations from the file, not installed-version evidence.
"""
report = _blank_report(Path(path))
try:
source = _source_path(path)
except WorkflowPathRefused:
report["errors"] = ["path_refused"]
return report
try:
raw = source.read_bytes()
except FileNotFoundError:
report.update(status="missing", errors=["file_missing"])
return report
except OSError:
report["errors"] = ["file_unreadable"]
return report
report.update(sha256=hashlib.sha256(raw).hexdigest(), bytes=len(raw))
try:
document = json.loads(
raw.decode("utf-8-sig"),
object_pairs_hook=_unique_object,
parse_constant=_reject_constant,
)
except (ValueError, RecursionError): # UnicodeError already is a ValueError
report["errors"] = ["invalid_json"]
return report

if not isinstance(document, dict) or not document:
report["errors"] = ["invalid_structure"]
return report

error, document_format, nodes, node_types = _declare(document)
if error:
report["errors"] = [error]
return report

report.update(
format=document_format,
api_export_required=document_format == "editor",
status="inspected",
node_count=len(nodes),
node_types=sorted(node_types),
declared_extensions=_declared_extensions(nodes),
)
return report


def inspect_presets(directory: str | Path = _WORKFLOWS_DIR) -> list[dict[str, Any]]:
"""Inventory exactly the four declared presets, including missing files."""
return [
{"preset": preset, **inspect_workflow_file(Path(directory) / filename)}
for preset, filename in PRESETS.items()
]


class _Parser(argparse.ArgumentParser):
def error(self, message: str) -> None:
raise ValueError(message)


def main(argv: list[str] | None = None) -> int:
"""Print one JSON result; 0 means inspected, not approved for execution."""
parser = _Parser(description=__doc__)
parser.add_argument("files", nargs="*", help="Local JSON files to inspect")
parser.add_argument(
"--presets-dir", type=Path, help="Directory of the four presets"
)
result: dict[str, Any] = {
"schema": SCHEMA,
"execution_verified": False,
"reports": [],
"errors": [],
}
try:
args = parser.parse_args(argv)
if args.files and args.presets_dir is not None:
raise ValueError("Choose files or --presets-dir, not both")
except ValueError as error:
result["errors"] = [{"code": "usage_error", "message": str(error)}]
print(json.dumps(result, ensure_ascii=False, indent=2))
return 2
if args.files:
result["reports"] = [inspect_workflow_file(path) for path in args.files]
else:
result["reports"] = inspect_presets(
args.presets_dir if args.presets_dir is not None else _WORKFLOWS_DIR
)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if all(item["status"] == "inspected" for item in result["reports"]) else 2


if __name__ == "__main__":
raise SystemExit(main())
25 changes: 25 additions & 0 deletions tests/asset_creator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,28 @@ 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.

## Workflow inventory follow-up

`test_workflow_inspection.py` adds 10 tests covering editor/API recognition,
declared extension versions, byte fingerprints and unchanged source files,
missing/unreadable inputs, malformed/ambiguous JSON, missing presets without
fallback, and JSON CLI results/errors. Socket creation is forbidden in each test.
An API-shaped graph with an unresolved link remains only an inventory result;
the test explicitly verifies that it is not reported as executed.

Validation on 2026-09-17, Linux / Python 3.12.14: all 19 focused tests pass
(the original 9 plus these 10). Ruff 0.16.8 check and format check pass for the
new module and its test file using the repository configuration. This does not
extend the earlier lint claim to unrelated files or establish hosted CI evidence.

Two upstream Trellis2 editor documents were also inspected separately with sockets
forbidden, after verifying their Git blob identities. The resulting inventories
are recorded in `evidence/upstream-workflow-inspection.json`; those source JSON
files are not vendored and normal tests do not download them. The local addon
inventory is recorded in `evidence/checkout-presets-inspection.json` and reports
four missing presets. It is not a scan of the user's machines.

See the [inspection guide](../../addons/official/storycore_asset_creator/WORKFLOW_INSPECTION.md)
for source references, commands and interpretation. No new CI workflow, GPU run,
API conversion, submission or MCP registration is part of this follow-up.
Loading