Enrichit les declencheurs/actions de scene (clic/survol/affichage, surbrillance/video/son/visibilite/indication/attendre) et fiabilise la pose d'un fond/decor importe
Build and deploy / test-python (push) Successful in 10m59s
Build and deploy / test-js (push) Successful in 1m27s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

- Ajoute clic/survol/affichage-ecran comme declencheurs, et surbrillance,
  video, son, visibilite, indication, attendre comme actions, utilisables
  aussi bien par l'editeur manuel (menu lateral Objets/Ecran) que par
  Ruby (IA), avec blocs deplacables/supprimables dans une chaine.
- Corrige plusieurs variantes du bug "impossible de poser un objet hors
  du champ de la camera" (troncature du chainage d'actions a 4 maillons,
  fond importe pose a 128x128 au lieu de sa taille reelle, decalage du
  fond au vrai glisser-depose, redimensionnement manuel jamais propage
  au monde).
- Ajoute un vrai glisser-depose depuis la galerie vers la scene, la
  gestion complete de "Mes assets" (sous-sections Fonds/Decors/Sons/
  Videos, suppression, reclassement fond<->decor sans re-upload).
- Ajoute l'upload de son (limite 3 min) et de video (MP4 uniquement,
  limite 5 min), avec validation de la duree reelle du fichier, et une
  replique audio optionnelle dans une bulle de dialogue.
- Fixe la taille de pose d'un objet/decor importe a 200x200 avec une
  boite de collision de 150x150.
- Filtre le selecteur de fichier des actions son/video par type reel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-12 00:28:46 +02:00
co-authored by Claude Sonnet 5
parent b07b231a61
commit 559331f9cf
67 changed files with 4739 additions and 306 deletions
+9 -1
View File
@@ -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",
]
+11 -4
View File
@@ -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()
+11
View File
@@ -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()
+71
View File
@@ -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("<HH", content[6:10])
return width, height
return None
if content[:2] == b"BM":
if len(content) >= 26:
width, height = struct.unpack("<ii", content[18:26])
return abs(width), abs(height)
return None
if content[:4] == b"RIFF" and content[8:12] == b"WEBP":
return _webp_dimensions(content)
if content[:2] == b"\xff\xd8":
return _jpeg_dimensions(content)
return None
def _webp_dimensions(content):
chunk = content[12:16]
if chunk == b"VP8X" and len(content) >= 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("<HH", content[26:30])
return width & 0x3FFF, height & 0x3FFF
if chunk == b"VP8L" and len(content) >= 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
+1 -1
View File
@@ -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()
+2 -2
View File
@@ -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
+29
View File
@@ -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
+25
View File
@@ -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"
+25
View File
@@ -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
+32
View File
@@ -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