Enrichit les declencheurs/actions de scene (clic/survol/affichage, surbrillance/video/son/visibilite/indication/attendre) et fiabilise la pose d'un fond/decor importe
- 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:
co-authored by
Claude Sonnet 5
parent
b07b231a61
commit
559331f9cf
+9
-1
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user