diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 54894c7a..0fe2b0ed 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -68,13 +68,15 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Tests JS (node:test — logique pure de static/js/play/, voir le plan de modularisation) + - name: Tests JS (node:test — logique pure de static/js/play/, et l'assistant déclencheurs de static/js/scenes/+static/js/triggers/, voir le plan de modularisation) run: | docker build -f - -t forge-test-js:${{ gitea.sha }} . <<'DOCKERFILE' FROM node:20-slim WORKDIR /app COPY static/js/play/ static/js/play/ - RUN node --test static/js/play/__tests__/*.test.js + COPY static/js/scenes/ static/js/scenes/ + COPY static/js/triggers/ static/js/triggers/ + RUN node --test static/js/play/__tests__/*.test.js static/js/scenes/__tests__/*.test.js DOCKERFILE build-and-push: diff --git a/.gitignore b/.gitignore index b8d44de5..abbafa1f 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,8 @@ _srv_out.txt _srv_err.txt _diag_out.txt _diag_err.txt +server_out.log +server_err.log # Documents internes/business (cadrage produit...) et état local de # session Claude Code — jamais du code, pas destiné à l'historique partagé. diff --git a/ai/chat.py b/ai/chat.py index 2879d1fc..6b02e01c 100644 --- a/ai/chat.py +++ b/ai/chat.py @@ -37,25 +37,61 @@ _SYSTEM_PROMPT = ( "— jamais pour un personnage ou un sprite animé, la génération " "d'image n'est pas fiable pour ça. " "\n\n" - "CE QUE TU NE PEUX PAS FAIRE : tes outils ne pilotent QUE la logique " - "\"déclencheur de collision\" d'un objet de scène (dialogue, " - "variable, interagir, condition). Forge Engine a un AUTRE système, " - "séparé, de \"flow\" (nœuds/liaisons) pour changer d'écran, les " - "minuteurs, jouer un son, une animation de sprite — tu n'as AUCUN " + "CE QUE TU NE PEUX PAS FAIRE : tes outils pilotent la logique " + "\"déclencheur -> action\" d'un objet de scène OU de l'écran entier " + "(voir DÉCLENCHEURS/ACTIONS ci-dessous). Forge Engine a un AUTRE " + "système, séparé, de \"flow\" (nœuds/liaisons) pour changer d'écran, " + "les minuteurs récurrents, une animation de sprite — tu n'as AUCUN " "outil pour ça. Si une demande a besoin de cette partie-là (\"passer " - "à l'écran suivant\", \"jouer un son\", \"après 5 secondes\"...), fais " - "quand même tout ce que tes outils permettent, puis DIS CLAIREMENT " - "dans ta réponse texte ce que tu n'as pas pu faire et pourquoi " - "(cette partie doit être ajoutée à la main dans l'onglet \"Flow\") — " - "ne t'acharne JAMAIS à répéter des appels d'outils pour une chose " - "qu'aucun outil ne permet, ça n'aboutira jamais." + "à l'écran suivant\", \"après 5 secondes\"...), fais quand même tout " + "ce que tes outils permettent, puis DIS CLAIREMENT dans ta réponse " + "texte ce que tu n'as pas pu faire et pourquoi (cette partie doit " + "être ajoutée à la main dans l'onglet \"Flow\") — ne t'acharne JAMAIS " + "à répéter des appels d'outils pour une chose qu'aucun outil ne " + "permet, ça n'aboutira jamais." + "\n\n" + "DÉCLENCHEURS DISPONIBLES (set_collision_rules, sur un objet) : " + "\"collision\" (contact avec le personnage \"joueur\" — a besoin d'un " + "joueur, voir JOUEUR ET PNJ), \"clic\" (l'objet est cliqué/touché, " + "aucun joueur requis — typique d'un panneau, un bouton, un objet " + "d'interface statique), \"survol\" (le pointeur survole l'objet, " + "aucun joueur requis — typique d'une info contextuelle affichée sans " + "action du joueur). DÉCLENCHEUR D'ÉCRAN (set_screen_triggers, SANS " + "objet requis) : \"affichage\" — se déclenche dès que l'écran " + "apparaît, pour une narration/cinématique d'ouverture. " + "RÈGLE IMPORTANTE : si le créateur ne précise PAS explicitement quel " + "déclencheur utiliser pour un élément donné (ex. \"ajoute un panneau " + "d'information\" sans dire si c'est au clic, au survol, ou dès " + "l'affichage), NE CHOISIS PAS toute seule — pose la question dans ta " + "réponse texte avant d'agir. " + "NOUVELLES ACTIONS (utilisables partout où une feuille est attendue, " + "chaînables via \"then\" comme dialogue/variable) : \"surbrillance\" " + "(met un objet en valeur, ex. pour guider l'attention du joueur vers " + "la suite), \"visibilite\" (affiche/masque un objet, ex. débloquer un " + "élément), \"son\" (effet sonore ponctuel), \"video\" (joue une vidéo " + "de \"Mes assets\", en plein écran ou en incrustation — bloque la " + "suite de la chaîne jusqu'à la fin, comme un dialogue), \"indication\" " + "(bulle de texte courte près d'un objet, pour un conseil ponctuel — " + "PAS pour une réplique de personnage, utilise \"dialogue\" pour ça), " + "\"attendre\" (suspend la chaîne \"then\" pendant data_value SECONDES " + "avant de continuer — utile pour laisser un temps de lecture après " + "une narration à l'affichage de l'écran, avant d'enchaîner sur un " + "dialogue/une autre action)." "\n\n" "ÉTAT DE LA SCÈNE : le message système de chaque tour te donne les " "dimensions de la caméra, la liste des objets déjà posés sur CET " "écran, et les variables globales déjà existantes — relis-les avant " "d'agir plutôt que de deviner (ne recrée jamais un objet ou une " "variable qui existe déjà, corrige/complète l'existant avec " - "set_object_geometry/set_object_name/set_object_role/set_collision_rules)." + "set_object_geometry/set_object_name/set_object_role/set_collision_rules). " + "Pour retirer ou réordonner UNE action précise d'une chaîne déjà posée " + "(sans reconstruire toute la règle), utilise remove_trigger_action/" + "move_trigger_action (objet) ou remove_screen_trigger_action/" + "move_screen_trigger_action (écran) avec le leaf_id concerné. " + "append_action_to_trigger/append_action_to_screen_trigger peuvent " + "INSÉRER une action à N'IMPORTE QUEL niveau d'une chaîne (pas " + "seulement à la toute fin) : after_id désigne le bloc juste AVANT " + "l'endroit où insérer." "\n\n" "CAMÉRA ET POSITIONNEMENT : les dimensions indiquées sont la zone " "VISIBLE (coin haut-gauche à (0,0)) — place tout objet important " @@ -84,7 +120,14 @@ _SYSTEM_PROMPT = ( "vers un PNJ, etc.), assure-toi qu'IL EXISTE EXACTEMENT UN " "personnage avec role=\"joueur\" (set_object_role) — jamais deux, " "et ne pose jamais deux PNJ identiques sans que le créateur l'ait " - "demandé." + "demandé. " + "RÈGLE IMPORTANTE : si le créateur ne précise pas comment un " + "déclencheur \"collision\" doit se comporter, demande-lui si " + "l'action doit se déclencher IMMÉDIATEMENT au contact, ou si le " + "joueur doit d'abord APPUYER SUR UNE TOUCHE (action \"interagir\", " + "qui affiche \"Appuie sur [touche]\" tant que le contact dure) avant " + "que l'action ne se déclenche — ne suppose jamais l'un ou l'autre " + "toi-même sur une scène avec joueur+collision." "\n\n" "QUAND UTILISER QUOI : une VARIABLE globale sert à mémoriser une " "donnée consultée plus tard (progression, un choix du joueur, un " @@ -161,6 +204,16 @@ def _describe_scene_state(slug, screen_id): desc += " [déclencheur déjà configuré]" lines.append(desc) + screen_triggers = screens.resolve_screen_triggers(screen) + if screen_triggers: + lines.append( + f"{len(screen_triggers)} déclencheur(s) D'ÉCRAN déjà configuré(s) sur cet écran " + "(voir set_screen_triggers) — relis-les avant d'en ajouter un nouveau plutôt que " + "de dupliquer une narration d'ouverture déjà posée." + ) + else: + lines.append("Aucun déclencheur d'écran (narration à l'affichage) configuré pour l'instant.") + variables = db.list_global_variables(slug) if variables: lines.append("Variables globales déjà existantes dans ce jeu (jamais en recréer une du même nom) :") diff --git a/ai/tools.py b/ai/tools.py index 407ffaa3..56475e2e 100644 --- a/ai/tools.py +++ b/ai/tools.py @@ -28,8 +28,12 @@ import auth import db import screens from db.dialogue_lines import QUESTION_REWARD_TYPES -from screens.rendering.collision_rules import ACTION_TYPES, LEAF_ACTION_TYPES, CONDITION_OPERATOR_KEYS +from screens.rendering.collision_rules import ACTION_TYPES, LEAF_ACTION_TYPES, CONDITION_OPERATOR_KEYS, TRIGGER_TYPES +from screens.rendering.screen_triggers import TRIGGER_TYPES_SCREEN from screens.labels.data_operations import DATA_OPERATION_LABELS +from screens.labels.element_visibility import ELEMENT_VISIBILITY_LABELS +from screens.labels.surbrillance_values import SURBRILLANCE_LABELS +from screens.labels.video_modes import VIDEO_MODE_LABELS from .scenario_client import generate_image_url _CONDITION_OPERATORS = sorted(CONDITION_OPERATOR_KEYS) @@ -76,7 +80,30 @@ _LEAF_ACTION_SCHEMA = { "mark_completed": {"type": "boolean"}, "target_variable": {"type": "string", "description": "Pour type=variable : nom d'une variable globale existante."}, "data_operation": {"type": "string", "enum": _DATA_OPERATIONS}, - "data_value": {"type": "string", "description": "Absent pour definir_bool_vrai/definir_bool_faux/basculer_bool."}, + "data_value": {"type": "string", "description": "Pour type=variable : absent pour definir_bool_vrai/definir_bool_faux/basculer_bool. Pour type=attendre : nombre de SECONDES à attendre avant \"then\" (ex. \"2\", \"1.5\")."}, + "object_id": { + "type": "integer", + "description": ( + "Pour type=surbrillance/visibilite/indication : id de l'objet CIBLÉ par cette " + "action — n'importe quel objet de l'écran, pas forcément celui qui porte le " + "déclencheur (ex. le joueur touche un interrupteur -> indication près d'une porte)." + ), + }, + "valeur": { + "type": "string", + "description": ( + f"Pour type=surbrillance : une valeur parmi {sorted(SURBRILLANCE_LABELS)!r}. " + f"Pour type=visibilite : une valeur parmi {sorted(ELEMENT_VISIBILITY_LABELS)!r}. " + "Pour type=condition (voir _ACTION_SCHEMA) : la valeur littérale à comparer." + ), + }, + "asset_url": { + "type": "string", + "description": "Pour type=son/video : URL d'un fichier déjà présent dans \"Mes assets\".", + }, + "mode": {"type": "string", "enum": sorted(VIDEO_MODE_LABELS), "description": "Pour type=video."}, + "texte": {"type": "string", "description": "Pour type=indication : le texte affiché dans la bulle."}, + "duree_ms": {"type": "integer", "description": "Pour type=indication : durée d'affichage en ms (optionnel)."}, "then": {"description": "Feuille suivante (même forme), récursif."}, }, "required": ["type"], @@ -93,11 +120,16 @@ _ACTION_SCHEMA = { "target_variable": {"type": "string"}, "data_operation": {"type": "string", "enum": _DATA_OPERATIONS}, "data_value": {"type": "string"}, - "then": {"description": "Feuille suivante (dialogue/variable), récursif."}, + "object_id": _LEAF_ACTION_SCHEMA["properties"]["object_id"], + "valeur": _LEAF_ACTION_SCHEMA["properties"]["valeur"], + "asset_url": _LEAF_ACTION_SCHEMA["properties"]["asset_url"], + "mode": _LEAF_ACTION_SCHEMA["properties"]["mode"], + "texte": _LEAF_ACTION_SCHEMA["properties"]["texte"], + "duree_ms": _LEAF_ACTION_SCHEMA["properties"]["duree_ms"], + "then": {"description": "Feuille suivante (dialogue/variable/surbrillance/visibilite/son/video/indication), récursif."}, "sub_action": {"description": "Pour type=interagir : une action (pas \"interagir\" à nouveau)."}, "variable": {"type": "string", "description": "Pour type=condition."}, "operateur": {"type": "string", "enum": _CONDITION_OPERATORS}, - "valeur": {"type": "string"}, "si_vrai": {"description": "Pour type=condition : null ou une feuille."}, "si_faux": {"description": "Pour type=condition : null ou une feuille."}, }, @@ -196,8 +228,13 @@ TOOLS = [ "name": "set_collision_rules", "description": ( "Remplace TOUTES les règles \"déclencheur -> action\" d'un objet. " - "Sanitizé côté serveur (screens.sanitize_collision_rules) : toute " - "valeur invalide est silencieusement retirée plutôt que rejetée." + "Déclencheurs disponibles : \"collision\" (contact avec le personnage " + "\"joueur\" — voir set_object_role), \"clic\" (l'objet est cliqué/touché, " + "aucun joueur requis), \"survol\" (le pointeur survole l'objet, aucun " + "joueur requis). Sanitizé côté serveur (screens.sanitize_collision_rules) : " + "toute valeur invalide est silencieusement retirée plutôt que rejetée — " + "compare le nombre de `rules` renvoyées dans le résultat à ce que tu as " + "envoyé pour détecter un retrait silencieux." ), "input_schema": { "type": "object", @@ -208,7 +245,7 @@ TOOLS = [ "items": { "type": "object", "properties": { - "trigger": {"type": "string"}, + "trigger": {"type": "string", "enum": list(TRIGGER_TYPES)}, "action": _ACTION_SCHEMA, }, "required": ["trigger", "action"], @@ -218,6 +255,33 @@ TOOLS = [ "required": ["object_id", "rules"], }, }, + { + "name": "set_screen_triggers", + "description": ( + "Remplace TOUTES les règles \"déclencheur d'écran -> action\" de l'écran " + "en cours — un déclencheur d'ÉCRAN se déclenche SANS avoir besoin d'un " + "objet (narration/cinématique dès l'affichage). Seul déclencheur : " + "\"affichage\" (à l'affichage de l'écran). Même sanitisation que " + "set_collision_rules." + ), + "input_schema": { + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "trigger": {"type": "string", "enum": list(TRIGGER_TYPES_SCREEN)}, + "action": _ACTION_SCHEMA, + }, + "required": ["trigger", "action"], + }, + }, + }, + "required": ["rules"], + }, + }, { "name": "add_generated_image", "description": ( @@ -238,9 +302,12 @@ TOOLS = [ { "name": "append_action_to_trigger", "description": ( - "Ajoute une action à la SUITE d'une chaîne déjà posée sur un " - "déclencheur existant, sans reconstruire toute la règle — " - "after_id désigne la dernière feuille de la chaîne." + "Insère une action JUSTE APRÈS un bloc précis d'une chaîne déjà " + "posée sur un déclencheur existant, sans reconstruire toute la " + "règle — after_id peut désigner N'IMPORTE QUEL bloc de la " + "chaîne, pas forcément le dernier : une suite déjà présente " + "après ce bloc passe derrière la nouvelle action plutôt que " + "d'être remplacée." ), "input_schema": { "type": "object", @@ -252,6 +319,81 @@ TOOLS = [ "required": ["object_id", "after_id", "action"], }, }, + { + "name": "append_action_to_screen_trigger", + "description": ( + "Équivalent d'append_action_to_trigger pour un déclencheur D'ÉCRAN " + "(voir set_screen_triggers), sur l'écran en cours — after_id peut " + "désigner N'IMPORTE QUEL bloc de la chaîne, pas forcément le " + "dernier." + ), + "input_schema": { + "type": "object", + "properties": { + "after_id": {"type": "string"}, + "action": _LEAF_ACTION_SCHEMA, + }, + "required": ["after_id", "action"], + }, + }, + { + "name": "remove_trigger_action", + "description": ( + "Retire UNE action précise (leaf_id) d'un déclencheur d'OBJET, où " + "qu'elle soit dans l'arbre (feuille directe, maillon d'une chaîne " + "\"then\", sous-action d'un \"interagir\", ou branche d'une " + "\"condition\"). Si c'était la SEULE action de la règle, la règle " + "entière disparaît ; si elle avait une suite \"then\", celle-ci " + "prend sa place." + ), + "input_schema": { + "type": "object", + "properties": { + "object_id": {"type": "integer"}, + "leaf_id": {"type": "string"}, + }, + "required": ["object_id", "leaf_id"], + }, + }, + { + "name": "remove_screen_trigger_action", + "description": "Équivalent de remove_trigger_action pour un déclencheur D'ÉCRAN.", + "input_schema": { + "type": "object", + "properties": {"leaf_id": {"type": "string"}}, + "required": ["leaf_id"], + }, + }, + { + "name": "move_trigger_action", + "description": ( + "Déplace une action précise (leaf_id) d'un cran vers le haut " + "(\"up\") ou le bas (\"down\") DANS SA PROPRE CHAÎNE \"then\", pour " + "un déclencheur d'OBJET — jamais au-delà de sa branche de " + "condition/sous-action interagir." + ), + "input_schema": { + "type": "object", + "properties": { + "object_id": {"type": "integer"}, + "leaf_id": {"type": "string"}, + "direction": {"type": "string", "enum": ["up", "down"]}, + }, + "required": ["object_id", "leaf_id", "direction"], + }, + }, + { + "name": "move_screen_trigger_action", + "description": "Équivalent de move_trigger_action pour un déclencheur D'ÉCRAN.", + "input_schema": { + "type": "object", + "properties": { + "leaf_id": {"type": "string"}, + "direction": {"type": "string", "enum": ["up", "down"]}, + }, + "required": ["leaf_id", "direction"], + }, + }, ] @@ -376,6 +518,37 @@ def _dispatch_append_action_to_trigger(slug, screen_id, user_id, object_id, afte return {"ok": ok} +def _dispatch_set_screen_triggers(slug, screen_id, user_id, rules): + sanitized = screens.sanitize_screen_triggers(rules) + screens.set_screen_triggers(slug, screen_id, sanitized) + return {"ok": True, "rules": sanitized} + + +def _dispatch_append_action_to_screen_trigger(slug, screen_id, user_id, after_id, action): + ok = screens.append_action_to_screen_trigger(slug, screen_id, after_id, action) + return {"ok": ok} + + +def _dispatch_remove_trigger_action(slug, screen_id, user_id, object_id, leaf_id): + ok = screens.remove_action_from_trigger(slug, object_id, leaf_id) + return {"ok": ok} + + +def _dispatch_remove_screen_trigger_action(slug, screen_id, user_id, leaf_id): + ok = screens.remove_action_from_screen_trigger(slug, screen_id, leaf_id) + return {"ok": ok} + + +def _dispatch_move_trigger_action(slug, screen_id, user_id, object_id, leaf_id, direction): + ok = screens.move_action_in_trigger(slug, object_id, leaf_id, direction) + return {"ok": ok} + + +def _dispatch_move_screen_trigger_action(slug, screen_id, user_id, leaf_id, direction): + ok = screens.move_action_in_screen_trigger(slug, screen_id, leaf_id, direction) + return {"ok": ok} + + def _dispatch_add_generated_image(slug, screen_id, user_id, kind, prompt): """Scenario -> "Mes assets" (auth.save_user_asset, source="ia") -> posée sur l'écran via LE MÊME chemin que Phase 1 @@ -401,6 +574,12 @@ _DISPATCH = { "create_global_variable": _dispatch_create_global_variable, "set_collision_rules": _dispatch_set_collision_rules, "append_action_to_trigger": _dispatch_append_action_to_trigger, + "set_screen_triggers": _dispatch_set_screen_triggers, + "append_action_to_screen_trigger": _dispatch_append_action_to_screen_trigger, + "remove_trigger_action": _dispatch_remove_trigger_action, + "remove_screen_trigger_action": _dispatch_remove_screen_trigger_action, + "move_trigger_action": _dispatch_move_trigger_action, + "move_screen_trigger_action": _dispatch_move_screen_trigger_action, "add_generated_image": _dispatch_add_generated_image, } diff --git a/auth/__init__.py b/auth/__init__.py index 5998cd40..0f34d761 100644 --- a/auth/__init__.py +++ b/auth/__init__.py @@ -37,6 +37,11 @@ from .list_user_assets import list_user_assets from .get_user_asset import get_user_asset from .delete_user_asset import delete_user_asset from .save_user_asset import save_user_asset +from .user_asset_kind import user_asset_kind +from .validate_audio_duration import validate_audio_duration, MAX_AUDIO_SECONDS +from .validate_video_duration import validate_video_duration, MAX_VIDEO_SECONDS +from .image_dimensions import image_dimensions +from .update_user_asset_scene_kind import update_user_asset_scene_kind __all__ = [ "users_db_path", "ensure_users_schema", "is_first_user", @@ -54,5 +59,8 @@ __all__ = [ "update_profile", "count_admins", "delete_user", "update_email", "EmailUpdateError", "ensure_user_assets_schema", "user_assets_dir", "create_user_asset", - "list_user_assets", "get_user_asset", "delete_user_asset", "save_user_asset", + "list_user_assets", "get_user_asset", "delete_user_asset", "save_user_asset", "user_asset_kind", + "validate_audio_duration", "MAX_AUDIO_SECONDS", + "validate_video_duration", "MAX_VIDEO_SECONDS", + "image_dimensions", "update_user_asset_scene_kind", ] diff --git a/auth/create_user_asset.py b/auth/create_user_asset.py index 98cfb208..1b6f2a9d 100644 --- a/auth/create_user_asset.py +++ b/auth/create_user_asset.py @@ -2,17 +2,24 @@ from .connection import connect from .ensure_user_assets_schema import ensure_user_assets_schema -def create_user_asset(user_id, filename, original_name=None, source="upload"): +def create_user_asset(user_id, filename, original_name=None, source="upload", scene_kind=None): """Enregistre une ligne "Mes assets" pour un fichier déjà écrit sur disque (voir user_assets_dir.py) — le fichier lui-même est écrit par l'appelant (route d'upload, ou plus tard la génération IA), cette fonction ne fait que la partie base de données, comme - db.create_global_variable pour le reste du moteur.""" + db.create_global_variable pour le reste du moteur. + + scene_kind ("decor"/"fond", images uniquement — voir + routes/scenes/scene_edit_view.py) : décide comment un CLIC sur la + tuile pose l'objet (screens/scenes/add_scene_object.py), demande + explicite "un bouton d'import séparé pour les images de fond et les + objets" — sans distinction, toute image "Mes assets" ne pouvait être + posée qu'en "decor", jamais comme fond.""" ensure_user_assets_schema() conn = connect() conn.execute( - "INSERT INTO _user_assets (user_id, filename, original_name, source) VALUES (?, ?, ?, ?)", - (user_id, filename, original_name, source), + "INSERT INTO _user_assets (user_id, filename, original_name, source, scene_kind) VALUES (?, ?, ?, ?, ?)", + (user_id, filename, original_name, source, scene_kind), ) asset_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"] conn.commit() diff --git a/auth/ensure_user_assets_schema.py b/auth/ensure_user_assets_schema.py index b3bf759d..70d37a67 100644 --- a/auth/ensure_user_assets_schema.py +++ b/auth/ensure_user_assets_schema.py @@ -19,5 +19,16 @@ def ensure_user_assets_schema(): ); """ ) + asset_cols = {r["name"] for r in conn.execute("PRAGMA table_info(_user_assets)").fetchall()} + if "scene_kind" not in asset_cols: + # Demande explicite : "un bouton d'import séparé pour les images + # de fond et les objets" — une image "Mes assets" est posée sur + # la scène TOUJOURS en kind="decor" jusqu'ici (voir + # screens/scenes/add_scene_object.py), impossible à utiliser comme + # "fond" (position/empilement différents, voir add_scene_object.py). + # NULL pour un son/une vidéo (n'a pas de sens) OU une image + # importée AVANT ce réglage (retombe sur "decor", comportement + # inchangé — voir routes/scenes/scene_edit_view.py). + conn.execute("ALTER TABLE _user_assets ADD COLUMN scene_kind TEXT") conn.commit() conn.close() diff --git a/auth/image_dimensions.py b/auth/image_dimensions.py new file mode 100644 index 00000000..5c5b2c7c --- /dev/null +++ b/auth/image_dimensions.py @@ -0,0 +1,71 @@ +import re +import struct + + +def image_dimensions(content): + """Largeur/hauteur RÉELLES (en pixels) d'une image, lues directement + dans ses en-têtes — SANS dépendance externe (Pillow serait la solution + habituelle, mais un simple parsing des formats courants suffit ici et + évite d'ajouter une dépendance binaire lourde juste pour ça). Renvoie + None si le format n'est pas reconnu (ex. SVG vectoriel sans taille + fixe) — l'appelant retombe alors sur les valeurs par défaut du schéma + (voir screens/scenes/add_scene_object.py).""" + if content[:8] == b"\x89PNG\r\n\x1a\n": + if len(content) >= 24: + width, height = struct.unpack(">II", content[16:24]) + return width, height + return None + if content[:6] in (b"GIF87a", b"GIF89a"): + if len(content) >= 10: + width, height = struct.unpack("= 26: + width, height = struct.unpack("= 30: + width = 1 + (content[24] | (content[25] << 8) | (content[26] << 16)) + height = 1 + (content[27] | (content[28] << 8) | (content[29] << 16)) + return width, height + if chunk == b"VP8 " and len(content) >= 30: + width, height = struct.unpack("= 25: + b = content[21:25] + bits = b[0] | (b[1] << 8) | (b[2] << 16) | (b[3] << 24) + width = (bits & 0x3FFF) + 1 + height = ((bits >> 14) & 0x3FFF) + 1 + return width, height + return None + + +def _jpeg_dimensions(content): + i = 2 + n = len(content) + while i + 9 < n: + if content[i] != 0xFF: + i += 1 + continue + marker = content[i + 1] + if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7: + i += 2 + continue + if marker == 0xD9: + break + seg_len = struct.unpack(">H", content[i + 2:i + 4])[0] + if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC): + height, width = struct.unpack(">HH", content[i + 5:i + 9]) + return width, height + i += 2 + seg_len + return None diff --git a/auth/list_user_assets.py b/auth/list_user_assets.py index af243d6e..8876046f 100644 --- a/auth/list_user_assets.py +++ b/auth/list_user_assets.py @@ -9,7 +9,7 @@ def list_user_assets(user_id): ensure_user_assets_schema() conn = connect() rows = conn.execute( - "SELECT id, user_id, filename, original_name, source, created_at " + "SELECT id, user_id, filename, original_name, source, scene_kind, created_at " "FROM _user_assets WHERE user_id = ? ORDER BY id DESC", (user_id,), ).fetchall() diff --git a/auth/save_user_asset.py b/auth/save_user_asset.py index 9157687c..f2643036 100644 --- a/auth/save_user_asset.py +++ b/auth/save_user_asset.py @@ -5,7 +5,7 @@ from .create_user_asset import create_user_asset from .user_assets_dir import user_assets_dir -def save_user_asset(user_id, content, ext, original_name=None, source="upload"): +def save_user_asset(user_id, content, ext, original_name=None, source="upload", scene_kind=None): """Écrit `content` (bytes) sur disque + crée la ligne "Mes assets" — factorisé pour être partagé par l'upload manuel (routes/assets/upload_user_asset.py) ET la génération IA @@ -16,5 +16,5 @@ def save_user_asset(user_id, content, ext, original_name=None, source="upload"): os.makedirs(asset_dir, exist_ok=True) with open(os.path.join(asset_dir, filename), "wb") as f: f.write(content) - asset_id = create_user_asset(user_id, filename, original_name=original_name, source=source) + asset_id = create_user_asset(user_id, filename, original_name=original_name, source=source, scene_kind=scene_kind) return asset_id, filename diff --git a/auth/update_user_asset_scene_kind.py b/auth/update_user_asset_scene_kind.py new file mode 100644 index 00000000..0ae84492 --- /dev/null +++ b/auth/update_user_asset_scene_kind.py @@ -0,0 +1,29 @@ +from .connection import connect +from .ensure_user_assets_schema import ensure_user_assets_schema +from .get_user_asset import get_user_asset +from .user_asset_kind import user_asset_kind + + +def update_user_asset_scene_kind(asset_id, user_id, scene_kind): + """Reclasse une image déjà importée entre "Fonds" et "Décors/objets" + (voir create_user_asset.py) — SANS ré-upload (demande implicite, + trouvée en diagnostiquant un "décalage" : une image importée AVANT + la séparation fond/décor, ou par le mauvais bouton, restait bloquée + dans la mauvaise section, impossible à poser comme fond sans la + ré-importer en double). Ne touche jamais un objet DÉJÀ posé sur une + scène (kind="decor" dans _scene_objects) — l'auteur doit re-poser + l'image depuis la bonne section après reclassement, même geste + qu'un ajout normal.""" + if scene_kind not in ("decor", "fond"): + return False + ensure_user_assets_schema() + asset = get_user_asset(asset_id) + if not asset or asset["user_id"] != user_id: + return False + if user_asset_kind(asset["filename"]) != "image": + return False + conn = connect() + conn.execute("UPDATE _user_assets SET scene_kind = ? WHERE id = ?", (scene_kind, asset_id)) + conn.commit() + conn.close() + return True diff --git a/auth/user_asset_kind.py b/auth/user_asset_kind.py new file mode 100644 index 00000000..5e2a6b59 --- /dev/null +++ b/auth/user_asset_kind.py @@ -0,0 +1,25 @@ +import os + +# "image"/"audio"/"video"/"other" d'après l'EXTENSION du fichier (voir +# _user_assets.filename, auth/save_user_asset.py) — pas de colonne dédiée +# en base : l'extension suffit, jamais ambiguë ici (tout upload passe par +# routes/assets/upload_user_asset.py, qui valide déjà le contenu pour un +# son, voir MAX_AUDIO_SECONDS). Utilisé pour organiser "Mes assets" en +# sous-sections (demande explicite : "voir, utiliser ou supprimer des +# son, image et vidéo", voir templates/scene_edit.html) — le sélecteur de +# fichier des actions "son"/"vidéo" (user_assets_options_json, +# routes/scenes/scene_edit_view.py), lui, liste TOUT sans distinction. +_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"} +_AUDIO_EXTENSIONS = {".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"} +_VIDEO_EXTENSIONS = {".mp4", ".webm", ".mov", ".avi", ".ogv"} + + +def user_asset_kind(filename): + ext = os.path.splitext(filename)[1].lower() + if ext in _IMAGE_EXTENSIONS: + return "image" + if ext in _AUDIO_EXTENSIONS: + return "audio" + if ext in _VIDEO_EXTENSIONS: + return "video" + return "other" diff --git a/auth/validate_audio_duration.py b/auth/validate_audio_duration.py new file mode 100644 index 00000000..9539e2f9 --- /dev/null +++ b/auth/validate_audio_duration.py @@ -0,0 +1,25 @@ +import io + +from mutagen import File as MutagenFile + +# Demande explicite : un son importé dans "Mes assets" ne doit jamais +# dépasser 3 minutes (durée d'un effet sonore/d'une courte musique +# d'ambiance — pas un morceau entier, voir l'action "son", +# screens/rendering/collision_rules.py). +MAX_AUDIO_SECONDS = 180 + + +def validate_audio_duration(content, max_seconds=MAX_AUDIO_SECONDS): + """Lit la durée d'un fichier audio (mp3/wav/ogg/m4a/... — mutagen + détecte le format lui-même, pas besoin de le lui préciser) et renvoie + un message d'erreur (français, prêt à afficher) si elle dépasse + `max_seconds`, ou si le format n'a pas pu être reconnu du tout (repli + prudent : un fichier illisible n'est jamais accepté silencieusement). + Renvoie None si la durée est correcte.""" + audio = MutagenFile(io.BytesIO(content)) + if audio is None or audio.info is None or not getattr(audio.info, "length", None): + return "Format audio non reconnu." + duration = audio.info.length + if duration > max_seconds: + return f"Ce son dépasse {max_seconds // 60} minutes (durée : {int(duration)} secondes)." + return None diff --git a/auth/validate_video_duration.py b/auth/validate_video_duration.py new file mode 100644 index 00000000..9bf07ca6 --- /dev/null +++ b/auth/validate_video_duration.py @@ -0,0 +1,32 @@ +import io + +from mutagen import File as MutagenFile + +# Demande explicite : une vidéo importée dans "Mes assets" ne doit jamais +# dépasser 5 minutes (voir l'action "vidéo", screens/rendering/ +# collision_rules.py — pensée pour une courte cinématique/cutscene, pas un +# film entier), même esprit que MAX_AUDIO_SECONDS pour un son. +MAX_VIDEO_SECONDS = 300 + + +def validate_video_duration(content, filename="video.mp4", max_seconds=MAX_VIDEO_SECONDS): + """Lit la durée d'un fichier vidéo MP4 (seul format accepté à l'upload, + voir routes/assets/upload_user_asset.py — mutagen ne sait pas lire + fiablement la durée de webm/mov/avi, contrairement au conteneur MP4/ + MOV ISO base media, qu'il décode via mutagen.mp4) et renvoie un + message d'erreur (français, prêt à afficher) si elle dépasse + `max_seconds`, ou si le format n'a pas pu être reconnu du tout (repli + prudent : un fichier illisible n'est jamais accepté silencieusement). + Renvoie None si la durée est correcte. + + filename (juste l'extension importe) : contrairement à un son (voir + validate_audio_duration.py), la détection MP4 de mutagen SANS indice + de nom de fichier échoue silencieusement sur certains fichiers + (moov/ftyp minimaux) — passer ".mp4" explicitement la rend fiable.""" + video = MutagenFile(io.BytesIO(content), filename=filename) + if video is None or video.info is None or not getattr(video.info, "length", None): + return "Format vidéo non reconnu." + duration = video.info.length + if duration > max_seconds: + return f"Cette vidéo dépasse {max_seconds // 60} minutes (durée : {int(duration)} secondes)." + return None diff --git a/db/dialogue_lines.py b/db/dialogue_lines.py index c5a542ef..61cd361a 100644 --- a/db/dialogue_lines.py +++ b/db/dialogue_lines.py @@ -19,7 +19,17 @@ def _sanitize_dialogue_line(raw_line): return None if not isinstance(text, str) or not text.strip(): return None - return {"type": "dialogue", "speaker": speaker.strip()[:_MAX_SPEAKER_LENGTH], "text": text.strip()} + line = {"type": "dialogue", "speaker": speaker.strip()[:_MAX_SPEAKER_LENGTH], "text": text.strip()} + # Réplique audio (demande explicite : "ajouter une réplique audio") + # — une voix off jouée en même temps que la bulle s'affiche, voir + # "Mes assets" (un fichier déjà importé, jamais un chemin arbitraire + # posté à la main : la sélection se fait via un