Upload 4 files
Browse files- README.md +61 -8
- app.py +637 -0
- requirements.txt +4 -0
- templates_exemple.csv +15 -0
README.md
CHANGED
|
@@ -1,14 +1,67 @@
|
|
| 1 |
---
|
| 2 |
-
title: IA
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
-
license: apache-2.0
|
| 12 |
---
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Atelier IA Animation 2D
|
| 3 |
+
emoji: 🎨
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
colorTo: red
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 5.50.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
|
|
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# Atelier IA — page de classe (fal.ai)
|
| 13 |
+
|
| 14 |
+
Page web pour que les étudiants génèrent des images avec **vos** crédits fal.ai, sans jamais voir la clé API.
|
| 15 |
+
|
| 16 |
+
- **Texte → Image** : Krea 2 Turbo, Z-Image Turbo + templates de prompts (CSV)
|
| 17 |
+
- **Image → Image** : Nano Banana 2 (édition), Seedream 5 Pro (édition), 1 à 4 images de référence
|
| 18 |
+
- **Ma galerie** : images de l'étudiant, prompt, réutilisation comme référence, export .zip (avec `prompts.csv`)
|
| 19 |
+
- **Enseignant** : crédits par étudiant, quota, remise à zéro, import des templates de classe, galeries des étudiants
|
| 20 |
+
|
| 21 |
+
## Connexion
|
| 22 |
+
|
| 23 |
+
- **Étudiant** : identifiant = son prénom (ou pseudo), mot de passe = le code de classe (`CLASS_CODE`)
|
| 24 |
+
- **Enseignant** : identifiant `prof`, mot de passe `ADMIN_CODE` → l'onglet « Enseignant » apparaît
|
| 25 |
+
|
| 26 |
+
Chaque identifiant a sa propre galerie : les étudiants doivent toujours utiliser le même.
|
| 27 |
+
|
| 28 |
+
## Crédits
|
| 29 |
+
|
| 30 |
+
Chaque étudiant a un quota (60 crédits par défaut, modifiable dans l'onglet Enseignant).
|
| 31 |
+
Texte → Image = 1 crédit/image, édition = 4 crédits/image (réglable dans `app.py`, dictionnaires `T2I_MODELS` / `I2I_MODELS`).
|
| 32 |
+
Les images bloquées par le filtre de sécurité ne sont pas décomptées.
|
| 33 |
+
Côté fal.ai, ne rechargez que le budget prévu : le solde ne peut pas passer en négatif.
|
| 34 |
+
|
| 35 |
+
## Option A — Hugging Face Spaces (en ligne, gratuit)
|
| 36 |
+
|
| 37 |
+
1. Sur huggingface.co : **New Space** → SDK **Gradio** → matériel **CPU basic (gratuit)** → visibilité **Public**
|
| 38 |
+
(l'accès reste protégé par le code de classe ; un Space privé n'est accessible qu'aux comptes autorisés).
|
| 39 |
+
2. Onglet **Files** → *Upload files* : `app.py`, `requirements.txt`, `templates_exemple.csv`, `README.md`.
|
| 40 |
+
3. **Settings → Variables and secrets** → *New secret* :
|
| 41 |
+
- `FAL_KEY` = votre clé fal.ai
|
| 42 |
+
- `CLASS_CODE` = code de la classe
|
| 43 |
+
- `ADMIN_CODE` = votre mot de passe enseignant
|
| 44 |
+
- (optionnel) `QUOTA` = 60
|
| 45 |
+
4. **Sauvegarde des galeries (recommandé)** : le disque d'un Space gratuit est effacé à chaque redémarrage.
|
| 46 |
+
Ajoutez aussi les secrets `HF_TOKEN` (jeton *Write* créé dans Settings → Access Tokens de votre compte)
|
| 47 |
+
et `DATA_REPO` = `votre-nom/atelier-ia-data`. Les galeries sont copiées toutes les 2 min dans un dataset
|
| 48 |
+
privé et restaurées au redémarrage.
|
| 49 |
+
5. Donnez aux étudiants l'adresse **directe** du Space : `https://votre-nom-nom-du-space.hf.space`
|
| 50 |
+
(la connexion fonctionne mal dans la page huggingface.co/spaces/… à cause des cookies).
|
| 51 |
+
|
| 52 |
+
Un Space gratuit se met en veille après 48 h sans visite : ouvrez-le quelques minutes avant le cours.
|
| 53 |
+
|
| 54 |
+
## Option B — Sur votre PC pendant le cours
|
| 55 |
+
|
| 56 |
+
1. Installez Python 3.10+ (cochez « Add Python to PATH »).
|
| 57 |
+
2. Ouvrez `lancer_windows.bat` avec le Bloc-notes, remplissez `FAL_KEY`, `CLASS_CODE`, `ADMIN_CODE`, enregistrez.
|
| 58 |
+
3. Double-cliquez dessus. Les étudiants sur le même Wi-Fi ouvrent `http://<IP de votre PC>:7860`
|
| 59 |
+
(IP donnée par la commande `ipconfig`). Autorisez Python dans le pare-feu Windows si demandé.
|
| 60 |
+
4. Les images restent dans le dossier `data/`.
|
| 61 |
+
|
| 62 |
+
## Format des templates CSV
|
| 63 |
+
|
| 64 |
+
Colonnes `categorie`, `titre`, `prompt` — séparateur `;` ou `,`, UTF-8 ou export Excel.
|
| 65 |
+
Voir `templates_exemple.csv` (14 templates animation 2D, chargés automatiquement au premier lancement).
|
| 66 |
+
L'import dans l'onglet Enseignant remplace les templates de classe.
|
| 67 |
+
Les étudiants peuvent importer leurs propres templates (marqués ★, visibles d'eux seuls).
|
app.py
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Atelier IA — page de classe pour générer des images via fal.ai
|
| 3 |
+
La clé API reste côté serveur : les étudiants ne la voient jamais.
|
| 4 |
+
|
| 5 |
+
Variables d'environnement :
|
| 6 |
+
FAL_KEY (obligatoire) clé API fal.ai
|
| 7 |
+
CLASS_CODE mot de passe commun des étudiants (défaut : atelier2d)
|
| 8 |
+
ADMIN_USER identifiant de l'enseignant (défaut : prof)
|
| 9 |
+
ADMIN_CODE mot de passe de l'enseignant (obligatoire pour l'espace prof)
|
| 10 |
+
QUOTA crédits par étudiant (défaut : 60)
|
| 11 |
+
DATA_DIR dossier de stockage (défaut : ./data)
|
| 12 |
+
HF_TOKEN + DATA_REPO (optionnel, Hugging Face) sauvegarde automatique des galeries
|
| 13 |
+
dans un dataset privé, ex. "cedric/atelier-ia-data"
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import csv
|
| 17 |
+
import io
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
import re
|
| 21 |
+
import threading
|
| 22 |
+
import time
|
| 23 |
+
import unicodedata
|
| 24 |
+
import uuid
|
| 25 |
+
import zipfile
|
| 26 |
+
from datetime import datetime
|
| 27 |
+
|
| 28 |
+
import fal_client
|
| 29 |
+
import gradio as gr
|
| 30 |
+
import requests
|
| 31 |
+
|
| 32 |
+
# ---------------------------------------------------------------- configuration
|
| 33 |
+
|
| 34 |
+
DATA_DIR = os.path.abspath(os.environ.get("DATA_DIR", "data"))
|
| 35 |
+
CLASS_CODE = os.environ.get("CLASS_CODE", "atelier2d")
|
| 36 |
+
ADMIN_USER = os.environ.get("ADMIN_USER", "prof").strip().lower()
|
| 37 |
+
ADMIN_CODE = os.environ.get("ADMIN_CODE", "")
|
| 38 |
+
DEFAULT_QUOTA = int(os.environ.get("QUOTA", "60"))
|
| 39 |
+
|
| 40 |
+
# "cout" = crédits décomptés par image générée (à ajuster selon vos tarifs fal.ai)
|
| 41 |
+
T2I_MODELS = {
|
| 42 |
+
"Krea 2 Turbo": {"id": "fal-ai/krea-2/turbo", "cout": 1},
|
| 43 |
+
"Z-Image Turbo": {"id": "fal-ai/z-image/turbo", "cout": 1},
|
| 44 |
+
}
|
| 45 |
+
I2I_MODELS = {
|
| 46 |
+
"Nano Banana 2 (édition)": {"id": "fal-ai/nano-banana-2/edit", "cout": 4},
|
| 47 |
+
"Seedream 5 Pro (édition)": {"id": "bytedance/seedream/v5/pro/edit", "cout": 4},
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
# Format commun -> paramètres propres à chaque modèle
|
| 51 |
+
FORMATS = ["1:1", "4:3", "3:4", "16:9", "9:16"]
|
| 52 |
+
IMAGE_SIZE = {
|
| 53 |
+
"auto": "auto_2K",
|
| 54 |
+
"1:1": "square_hd",
|
| 55 |
+
"4:3": "landscape_4_3",
|
| 56 |
+
"3:4": "portrait_4_3",
|
| 57 |
+
"16:9": "landscape_16_9",
|
| 58 |
+
"9:16": "portrait_16_9",
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
MAX_REF_IMAGES = 4
|
| 62 |
+
|
| 63 |
+
os.makedirs(os.path.join(DATA_DIR, "users"), exist_ok=True)
|
| 64 |
+
EXAMPLE_CSV = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates_exemple.csv")
|
| 65 |
+
|
| 66 |
+
# ---------------------------------------------------------------- sauvegarde HF (optionnelle)
|
| 67 |
+
|
| 68 |
+
_lock = threading.Lock()
|
| 69 |
+
_scheduler = None
|
| 70 |
+
if os.environ.get("HF_TOKEN") and os.environ.get("DATA_REPO"):
|
| 71 |
+
try:
|
| 72 |
+
from huggingface_hub import CommitScheduler, snapshot_download
|
| 73 |
+
|
| 74 |
+
repo = os.environ["DATA_REPO"]
|
| 75 |
+
try:
|
| 76 |
+
snapshot_download(repo_id=repo, repo_type="dataset", local_dir=DATA_DIR,
|
| 77 |
+
token=os.environ["HF_TOKEN"])
|
| 78 |
+
except Exception as e: # premier lancement : le dataset n'existe pas encore
|
| 79 |
+
print("Pas de données à restaurer :", e)
|
| 80 |
+
_scheduler = CommitScheduler(repo_id=repo, repo_type="dataset", folder_path=DATA_DIR,
|
| 81 |
+
every=2, private=True, token=os.environ["HF_TOKEN"])
|
| 82 |
+
_lock = _scheduler.lock
|
| 83 |
+
print("Sauvegarde automatique activée vers", repo)
|
| 84 |
+
except Exception as e:
|
| 85 |
+
print("Sauvegarde HF désactivée :", e)
|
| 86 |
+
|
| 87 |
+
# ---------------------------------------------------------------- utilitaires
|
| 88 |
+
|
| 89 |
+
def slug(name: str) -> str:
|
| 90 |
+
name = unicodedata.normalize("NFKD", name or "").encode("ascii", "ignore").decode()
|
| 91 |
+
name = re.sub(r"[^a-z0-9_-]+", "-", name.strip().lower()).strip("-")
|
| 92 |
+
return name[:30]
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def read_json(path, default):
|
| 96 |
+
try:
|
| 97 |
+
with open(path, encoding="utf-8") as f:
|
| 98 |
+
return json.load(f)
|
| 99 |
+
except (FileNotFoundError, json.JSONDecodeError):
|
| 100 |
+
return default
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def write_json(path, data):
|
| 104 |
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
| 105 |
+
tmp = path + ".tmp"
|
| 106 |
+
with open(tmp, "w", encoding="utf-8") as f:
|
| 107 |
+
json.dump(data, f, ensure_ascii=False, indent=1)
|
| 108 |
+
os.replace(tmp, path)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def settings():
|
| 112 |
+
return read_json(os.path.join(DATA_DIR, "settings.json"), {"quota": DEFAULT_QUOTA})
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def user_dir(user):
|
| 116 |
+
return os.path.join(DATA_DIR, "users", user)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def load_user(user):
|
| 120 |
+
return read_json(os.path.join(user_dir(user), "gallery.json"), {"used": 0, "items": []})
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def save_user(user, data):
|
| 124 |
+
write_json(os.path.join(user_dir(user), "gallery.json"), data)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def current_user(request: gr.Request):
|
| 128 |
+
user = slug(getattr(request, "username", "") or "")
|
| 129 |
+
if not user:
|
| 130 |
+
raise gr.Error("Session expirée : rechargez la page et reconnectez-vous.")
|
| 131 |
+
return user
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def remaining(user):
|
| 135 |
+
return settings()["quota"] - load_user(user)["used"]
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def status_line(user):
|
| 139 |
+
if user == ADMIN_USER:
|
| 140 |
+
return f"**Connecté : {user}** (enseignant)"
|
| 141 |
+
q = settings()["quota"]
|
| 142 |
+
used = load_user(user)["used"]
|
| 143 |
+
return f"**Connecté : {user}** · crédits restants : **{max(q - used, 0)} / {q}**"
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
# ---------------------------------------------------------------- templates CSV
|
| 147 |
+
|
| 148 |
+
def parse_templates_csv(path):
|
| 149 |
+
"""Lit un CSV (virgule ou point-virgule, UTF-8 ou Excel) avec au minimum les colonnes titre et prompt."""
|
| 150 |
+
raw = open(path, "rb").read()
|
| 151 |
+
for enc in ("utf-8-sig", "cp1252", "latin-1"):
|
| 152 |
+
try:
|
| 153 |
+
text = raw.decode(enc)
|
| 154 |
+
break
|
| 155 |
+
except UnicodeDecodeError:
|
| 156 |
+
continue
|
| 157 |
+
first = text.splitlines()[0] if text else ""
|
| 158 |
+
delim = ";" if first.count(";") > first.count(",") else ","
|
| 159 |
+
rows = list(csv.DictReader(io.StringIO(text), delimiter=delim))
|
| 160 |
+
out = []
|
| 161 |
+
for r in rows:
|
| 162 |
+
r = {(k or "").strip().lower(): (v or "").strip() for k, v in r.items()}
|
| 163 |
+
prompt = r.get("prompt") or r.get("texte") or ""
|
| 164 |
+
title = r.get("titre") or r.get("title") or r.get("nom") or prompt[:40]
|
| 165 |
+
cat = r.get("categorie") or r.get("catégorie") or r.get("category") or ""
|
| 166 |
+
if prompt:
|
| 167 |
+
out.append({"categorie": cat, "titre": title, "prompt": prompt})
|
| 168 |
+
if not out:
|
| 169 |
+
raise gr.Error("Aucun template trouvé. Le CSV doit contenir au moins les colonnes « titre » et « prompt ».")
|
| 170 |
+
return out
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def all_templates(user):
|
| 174 |
+
cls = read_json(os.path.join(DATA_DIR, "templates.json"), [])
|
| 175 |
+
perso = read_json(os.path.join(user_dir(user), "templates.json"), []) if user else []
|
| 176 |
+
return [dict(t, source="classe") for t in cls] + [dict(t, source="perso") for t in perso]
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def template_label(t):
|
| 180 |
+
tag = "★ " if t["source"] == "perso" else ""
|
| 181 |
+
return f"{tag}{t['categorie'] + ' — ' if t['categorie'] else ''}{t['titre']}"
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def template_choices(user):
|
| 185 |
+
return [template_label(t) for t in all_templates(user)]
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def seed_class_templates():
|
| 189 |
+
"""Au premier lancement, charge templates_exemple.csv comme templates de classe."""
|
| 190 |
+
path = os.path.join(DATA_DIR, "templates.json")
|
| 191 |
+
if not os.path.exists(path) and os.path.exists(EXAMPLE_CSV):
|
| 192 |
+
try:
|
| 193 |
+
write_json(path, parse_templates_csv(EXAMPLE_CSV))
|
| 194 |
+
except Exception as e:
|
| 195 |
+
print("Templates d'exemple non chargés :", e)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
seed_class_templates()
|
| 199 |
+
|
| 200 |
+
# ---------------------------------------------------------------- génération
|
| 201 |
+
|
| 202 |
+
def _save_results(user, result, meta, cost_per_image):
|
| 203 |
+
images = result.get("images") or []
|
| 204 |
+
nsfw = result.get("has_nsfw_concepts") or [False] * len(images)
|
| 205 |
+
saved = []
|
| 206 |
+
for i, img in enumerate(images):
|
| 207 |
+
if i < len(nsfw) and nsfw[i]:
|
| 208 |
+
continue
|
| 209 |
+
r = requests.get(img["url"], timeout=120)
|
| 210 |
+
r.raise_for_status()
|
| 211 |
+
ext = "png" if "png" in (img.get("content_type") or "") else (
|
| 212 |
+
"webp" if "webp" in (img.get("content_type") or "") else "jpg")
|
| 213 |
+
iid = datetime.now().strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:6]
|
| 214 |
+
rel = f"images/{iid}.{ext}"
|
| 215 |
+
path = os.path.join(user_dir(user), rel)
|
| 216 |
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
| 217 |
+
with _lock:
|
| 218 |
+
with open(path, "wb") as f:
|
| 219 |
+
f.write(r.content)
|
| 220 |
+
saved.append({"id": iid, "file": rel, "created": time.strftime("%Y-%m-%d %H:%M"),
|
| 221 |
+
"seed": result.get("seed"), **meta})
|
| 222 |
+
with _lock:
|
| 223 |
+
data = load_user(user)
|
| 224 |
+
data["items"].extend(saved)
|
| 225 |
+
data["used"] += cost_per_image * len(saved)
|
| 226 |
+
save_user(user, data)
|
| 227 |
+
blocked = len(images) - len(saved)
|
| 228 |
+
return saved, blocked
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def _check_quota(user, cost):
|
| 232 |
+
if user == ADMIN_USER:
|
| 233 |
+
return
|
| 234 |
+
left = remaining(user)
|
| 235 |
+
if cost > left:
|
| 236 |
+
raise gr.Error(f"Crédits insuffisants : cette génération coûte {cost}, il vous reste {max(left, 0)}.")
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def _call_fal(endpoint, args):
|
| 240 |
+
if not os.environ.get("FAL_KEY"):
|
| 241 |
+
raise gr.Error("La clé FAL_KEY n'est pas configurée sur le serveur. Prévenez l'enseignant.")
|
| 242 |
+
try:
|
| 243 |
+
return fal_client.subscribe(endpoint, arguments=args)
|
| 244 |
+
except Exception as e:
|
| 245 |
+
raise gr.Error(f"Erreur fal.ai : {e}")
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def _result_message(saved, blocked, cost, user):
|
| 249 |
+
msg = f"{len(saved)} image(s) générée(s) · {cost * len(saved)} crédit(s) utilisé(s)."
|
| 250 |
+
if blocked:
|
| 251 |
+
msg += f" {blocked} image(s) bloquée(s) par le filtre de sécurité (non décomptée(s))."
|
| 252 |
+
return msg
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def generate_t2i(prompt, model_label, fmt, n, seed, request: gr.Request):
|
| 256 |
+
user = current_user(request)
|
| 257 |
+
prompt = (prompt or "").strip()
|
| 258 |
+
if not prompt:
|
| 259 |
+
raise gr.Error("Écrivez un prompt (ou choisissez un template).")
|
| 260 |
+
model = T2I_MODELS[model_label]
|
| 261 |
+
n = int(n)
|
| 262 |
+
cost = model["cout"] * n
|
| 263 |
+
_check_quota(user, cost)
|
| 264 |
+
args = {"prompt": prompt, "num_images": n, "image_size": IMAGE_SIZE[fmt],
|
| 265 |
+
"enable_safety_checker": True, "output_format": "png"}
|
| 266 |
+
if seed and int(seed) > 0:
|
| 267 |
+
args["seed"] = int(seed)
|
| 268 |
+
result = _call_fal(model["id"], args)
|
| 269 |
+
saved, blocked = _save_results(user, result, {"mode": "Texte → Image", "model": model_label,
|
| 270 |
+
"prompt": prompt}, model["cout"])
|
| 271 |
+
gal = [(os.path.join(user_dir(user), s["file"]), s["model"]) for s in saved]
|
| 272 |
+
return gal, _result_message(saved, blocked, model["cout"], user), status_line(user)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def generate_i2i(refs, prompt, model_label, fmt, n, request: gr.Request):
|
| 276 |
+
user = current_user(request)
|
| 277 |
+
prompt = (prompt or "").strip()
|
| 278 |
+
paths = [r[0] if isinstance(r, (list, tuple)) else r for r in (refs or [])]
|
| 279 |
+
if not paths:
|
| 280 |
+
raise gr.Error("Ajoutez au moins une image de référence.")
|
| 281 |
+
if len(paths) > MAX_REF_IMAGES:
|
| 282 |
+
raise gr.Error(f"{MAX_REF_IMAGES} images de référence maximum.")
|
| 283 |
+
if not prompt:
|
| 284 |
+
raise gr.Error("Décrivez la modification souhaitée.")
|
| 285 |
+
model = I2I_MODELS[model_label]
|
| 286 |
+
n = int(n)
|
| 287 |
+
cost = model["cout"] * n
|
| 288 |
+
_check_quota(user, cost)
|
| 289 |
+
try:
|
| 290 |
+
urls = [fal_client.upload_file(p) for p in paths]
|
| 291 |
+
except Exception as e:
|
| 292 |
+
raise gr.Error(f"Envoi des images impossible : {e}")
|
| 293 |
+
args = {"prompt": prompt, "image_urls": urls, "num_images": n}
|
| 294 |
+
if "nano-banana" in model["id"]:
|
| 295 |
+
args.update({"aspect_ratio": fmt, "resolution": "1K", "output_format": "png"})
|
| 296 |
+
else:
|
| 297 |
+
args.update({"image_size": IMAGE_SIZE[fmt], "enable_safety_checker": True})
|
| 298 |
+
result = _call_fal(model["id"], args)
|
| 299 |
+
saved, blocked = _save_results(user, result, {"mode": "Image → Image", "model": model_label,
|
| 300 |
+
"prompt": prompt, "refs": len(paths)}, model["cout"])
|
| 301 |
+
gal = [(os.path.join(user_dir(user), s["file"]), s["model"]) for s in saved]
|
| 302 |
+
return gal, _result_message(saved, blocked, model["cout"], user), status_line(user)
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
# ---------------------------------------------------------------- galerie
|
| 306 |
+
|
| 307 |
+
def visible_items(user):
|
| 308 |
+
return [it for it in reversed(load_user(user)["items"]) if not it.get("deleted")]
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def gallery_view(user):
|
| 312 |
+
items = visible_items(user)
|
| 313 |
+
gal = [(os.path.join(user_dir(user), it["file"]), f"{it['model']} · {it['prompt'][:80]}") for it in items]
|
| 314 |
+
return gal, [it["id"] for it in items]
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def refresh_gallery(request: gr.Request):
|
| 318 |
+
user = current_user(request)
|
| 319 |
+
gal, ids = gallery_view(user)
|
| 320 |
+
return gal, ids, None, "Cliquez sur une image pour voir son prompt.", status_line(user)
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def select_item(ids, evt: gr.SelectData, request: gr.Request):
|
| 324 |
+
user = current_user(request)
|
| 325 |
+
if evt.index is None or evt.index >= len(ids):
|
| 326 |
+
return None, ""
|
| 327 |
+
iid = ids[evt.index]
|
| 328 |
+
it = next((x for x in load_user(user)["items"] if x["id"] == iid), None)
|
| 329 |
+
if not it:
|
| 330 |
+
return None, ""
|
| 331 |
+
info = (f"**{it['mode']}** · {it['model']} · {it['created']}"
|
| 332 |
+
+ (f" · seed {it['seed']}" if it.get("seed") else "")
|
| 333 |
+
+ f"\n\n> {it['prompt']}")
|
| 334 |
+
return iid, info
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def _item_path(user, iid):
|
| 338 |
+
it = next((x for x in load_user(user)["items"] if x["id"] == iid), None)
|
| 339 |
+
return os.path.join(user_dir(user), it["file"]) if it else None
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def send_to_i2i(iid, current_refs, request: gr.Request):
|
| 343 |
+
user = current_user(request)
|
| 344 |
+
if not iid:
|
| 345 |
+
raise gr.Error("Sélectionnez d'abord une image dans votre galerie.")
|
| 346 |
+
refs = [r[0] if isinstance(r, (list, tuple)) else r for r in (current_refs or [])]
|
| 347 |
+
if len(refs) >= MAX_REF_IMAGES:
|
| 348 |
+
raise gr.Error(f"{MAX_REF_IMAGES} images de référence maximum.")
|
| 349 |
+
refs.append(_item_path(user, iid))
|
| 350 |
+
gr.Info("Image ajoutée dans l'onglet Image → Image.")
|
| 351 |
+
return refs
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def reuse_prompt(iid, request: gr.Request):
|
| 355 |
+
user = current_user(request)
|
| 356 |
+
it = next((x for x in load_user(user)["items"] if x["id"] == iid), None)
|
| 357 |
+
if not it:
|
| 358 |
+
raise gr.Error("Sélectionnez d'abord une image.")
|
| 359 |
+
gr.Info("Prompt copié dans l'onglet Texte → Image.")
|
| 360 |
+
return it["prompt"]
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def delete_item(iid, request: gr.Request):
|
| 364 |
+
user = current_user(request)
|
| 365 |
+
if not iid:
|
| 366 |
+
raise gr.Error("Sélectionnez d'abord une image.")
|
| 367 |
+
with _lock:
|
| 368 |
+
data = load_user(user)
|
| 369 |
+
for it in data["items"]:
|
| 370 |
+
if it["id"] == iid:
|
| 371 |
+
it["deleted"] = True
|
| 372 |
+
save_user(user, data)
|
| 373 |
+
gal, ids = gallery_view(user)
|
| 374 |
+
return gal, ids, None, "Image retirée de la galerie."
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
def zip_gallery(request: gr.Request):
|
| 378 |
+
user = current_user(request)
|
| 379 |
+
items = visible_items(user)
|
| 380 |
+
if not items:
|
| 381 |
+
raise gr.Error("Votre galerie est vide.")
|
| 382 |
+
zpath = os.path.join(DATA_DIR, "_exports", f"galerie-{user}.zip")
|
| 383 |
+
os.makedirs(os.path.dirname(zpath), exist_ok=True)
|
| 384 |
+
lines = ["fichier;date;mode;modele;prompt"]
|
| 385 |
+
with zipfile.ZipFile(zpath, "w", zipfile.ZIP_DEFLATED) as z:
|
| 386 |
+
for it in items:
|
| 387 |
+
name = os.path.basename(it["file"])
|
| 388 |
+
z.write(os.path.join(user_dir(user), it["file"]), name)
|
| 389 |
+
lines.append(f"{name};{it['created']};{it['mode']};{it['model']};\"{it['prompt'].replace(chr(34), chr(39))}\"")
|
| 390 |
+
z.writestr("prompts.csv", "" + "\n".join(lines))
|
| 391 |
+
return zpath
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
# ---------------------------------------------------------------- templates (UI)
|
| 395 |
+
|
| 396 |
+
def on_template(label, request: gr.Request):
|
| 397 |
+
user = current_user(request)
|
| 398 |
+
for t in all_templates(user):
|
| 399 |
+
if template_label(t) == label:
|
| 400 |
+
return t["prompt"]
|
| 401 |
+
return gr.update()
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def import_perso_templates(file, request: gr.Request):
|
| 405 |
+
user = current_user(request)
|
| 406 |
+
if not file:
|
| 407 |
+
raise gr.Error("Choisissez un fichier CSV.")
|
| 408 |
+
new = parse_templates_csv(file)
|
| 409 |
+
path = os.path.join(user_dir(user), "templates.json")
|
| 410 |
+
with _lock:
|
| 411 |
+
write_json(path, read_json(path, []) + new)
|
| 412 |
+
gr.Info(f"{len(new)} template(s) personnel(s) importé(s).")
|
| 413 |
+
return gr.Dropdown(choices=template_choices(user), value=None), None
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
# ---------------------------------------------------------------- espace enseignant
|
| 417 |
+
|
| 418 |
+
def _require_admin(request):
|
| 419 |
+
user = current_user(request)
|
| 420 |
+
if user != ADMIN_USER:
|
| 421 |
+
raise gr.Error("Réservé à l'enseignant.")
|
| 422 |
+
return user
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
def admin_overview(request: gr.Request):
|
| 426 |
+
_require_admin(request)
|
| 427 |
+
q = settings()["quota"]
|
| 428 |
+
rows, total = [], 0
|
| 429 |
+
base = os.path.join(DATA_DIR, "users")
|
| 430 |
+
for u in sorted(os.listdir(base)):
|
| 431 |
+
if u == ADMIN_USER or not os.path.isdir(os.path.join(base, u)):
|
| 432 |
+
continue
|
| 433 |
+
d = load_user(u)
|
| 434 |
+
n = len([i for i in d["items"] if not i.get("deleted")])
|
| 435 |
+
rows.append([u, n, d["used"], max(q - d["used"], 0)])
|
| 436 |
+
total += d["used"]
|
| 437 |
+
admin = load_user(ADMIN_USER)
|
| 438 |
+
summary = (f"**{len(rows)} étudiant(s)** · quota actuel : **{q} crédits** · "
|
| 439 |
+
f"crédits consommés par les étudiants : **{total}** · par l'enseignant : **{admin['used']}**")
|
| 440 |
+
students = [r[0] for r in rows]
|
| 441 |
+
return rows, summary, gr.Dropdown(choices=students), gr.Dropdown(choices=students)
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
def admin_set_quota(q, request: gr.Request):
|
| 445 |
+
_require_admin(request)
|
| 446 |
+
with _lock:
|
| 447 |
+
s = settings()
|
| 448 |
+
s["quota"] = int(q)
|
| 449 |
+
write_json(os.path.join(DATA_DIR, "settings.json"), s)
|
| 450 |
+
gr.Info(f"Quota fixé à {int(q)} crédits par étudiant.")
|
| 451 |
+
return admin_overview(request)
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
def admin_reset(student, request: gr.Request):
|
| 455 |
+
_require_admin(request)
|
| 456 |
+
if not student:
|
| 457 |
+
raise gr.Error("Choisissez un étudiant.")
|
| 458 |
+
with _lock:
|
| 459 |
+
d = load_user(student)
|
| 460 |
+
d["used"] = 0
|
| 461 |
+
save_user(student, d)
|
| 462 |
+
gr.Info(f"Crédits de {student} remis à zéro.")
|
| 463 |
+
return admin_overview(request)
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
def admin_view_gallery(student, request: gr.Request):
|
| 467 |
+
_require_admin(request)
|
| 468 |
+
if not student:
|
| 469 |
+
return []
|
| 470 |
+
return gallery_view(student)[0]
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def admin_import_templates(file, request: gr.Request):
|
| 474 |
+
_require_admin(request)
|
| 475 |
+
if not file:
|
| 476 |
+
raise gr.Error("Choisissez un fichier CSV.")
|
| 477 |
+
new = parse_templates_csv(file)
|
| 478 |
+
with _lock:
|
| 479 |
+
write_json(os.path.join(DATA_DIR, "templates.json"), new)
|
| 480 |
+
gr.Info(f"{len(new)} template(s) de classe importé(s) (remplacent les précédents).")
|
| 481 |
+
return None
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
# ---------------------------------------------------------------- authentification
|
| 485 |
+
|
| 486 |
+
def check_auth(username, password):
|
| 487 |
+
u = slug(username)
|
| 488 |
+
if not u:
|
| 489 |
+
return False
|
| 490 |
+
if u == ADMIN_USER:
|
| 491 |
+
return bool(ADMIN_CODE) and password == ADMIN_CODE
|
| 492 |
+
return password == CLASS_CODE
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
# ---------------------------------------------------------------- interface
|
| 496 |
+
|
| 497 |
+
CSS = """
|
| 498 |
+
.gradio-container {max-width: 1200px !important}
|
| 499 |
+
#status {text-align:right}
|
| 500 |
+
footer {display:none !important}
|
| 501 |
+
"""
|
| 502 |
+
|
| 503 |
+
with gr.Blocks(title="Atelier IA — Animation 2D", theme=gr.themes.Soft(primary_hue="orange"), css=CSS) as demo:
|
| 504 |
+
with gr.Row():
|
| 505 |
+
gr.Markdown("## Atelier IA générative · Animation 2D")
|
| 506 |
+
status = gr.Markdown("", elem_id="status")
|
| 507 |
+
|
| 508 |
+
with gr.Tabs() as tabs:
|
| 509 |
+
# ----------------------------------------------------- Texte → Image
|
| 510 |
+
with gr.Tab("Texte → Image", id="t2i"):
|
| 511 |
+
with gr.Row():
|
| 512 |
+
with gr.Column(scale=2):
|
| 513 |
+
tpl = gr.Dropdown(label="Template de prompt", choices=[], value=None,
|
| 514 |
+
info="Choisir un modèle de prompt, puis l'adapter (remplacer les [CROCHETS]).")
|
| 515 |
+
t2i_prompt = gr.Textbox(label="Prompt", lines=5,
|
| 516 |
+
placeholder="Décrivez l'image : sujet, style, cadrage, lumière…")
|
| 517 |
+
with gr.Row():
|
| 518 |
+
t2i_model = gr.Dropdown(list(T2I_MODELS), value="Krea 2 Turbo", label="Modèle")
|
| 519 |
+
t2i_fmt = gr.Dropdown(FORMATS, value="1:1", label="Format")
|
| 520 |
+
with gr.Row():
|
| 521 |
+
t2i_n = gr.Slider(1, 4, value=1, step=1, label="Nombre d'images")
|
| 522 |
+
t2i_seed = gr.Number(value=0, precision=0, label="Seed (0 = aléatoire)")
|
| 523 |
+
t2i_btn = gr.Button("Générer", variant="primary")
|
| 524 |
+
with gr.Accordion("Importer mes propres templates (CSV)", open=False):
|
| 525 |
+
gr.Markdown("Colonnes : `categorie`, `titre`, `prompt` (séparateur virgule ou point-virgule). "
|
| 526 |
+
"Vos templates apparaissent avec une ★.")
|
| 527 |
+
perso_csv = gr.File(label="Fichier CSV", file_types=[".csv"], type="filepath")
|
| 528 |
+
perso_btn = gr.Button("Importer")
|
| 529 |
+
with gr.Column(scale=3):
|
| 530 |
+
t2i_out = gr.Gallery(label="Résultat", columns=2, height=520, object_fit="contain")
|
| 531 |
+
t2i_msg = gr.Markdown()
|
| 532 |
+
|
| 533 |
+
# ----------------------------------------------------- Image → Image
|
| 534 |
+
with gr.Tab("Image → Image", id="i2i"):
|
| 535 |
+
with gr.Row():
|
| 536 |
+
with gr.Column(scale=2):
|
| 537 |
+
refs = gr.Gallery(label=f"Images de référence (1 à {MAX_REF_IMAGES}) — croquis, rough, décor…",
|
| 538 |
+
type="filepath", interactive=True, file_types=["image"],
|
| 539 |
+
columns=4, height=220)
|
| 540 |
+
i2i_prompt = gr.Textbox(label="Consigne", lines=4,
|
| 541 |
+
placeholder="Ex. : Colorise ce rough en aplats façon anime, garde le trait original.")
|
| 542 |
+
with gr.Row():
|
| 543 |
+
i2i_model = gr.Dropdown(list(I2I_MODELS), value="Nano Banana 2 (édition)", label="Modèle")
|
| 544 |
+
i2i_fmt = gr.Dropdown(["auto"] + FORMATS, value="auto", label="Format")
|
| 545 |
+
i2i_n = gr.Slider(1, 4, value=1, step=1, label="Nombre d'images")
|
| 546 |
+
i2i_btn = gr.Button("Générer", variant="primary")
|
| 547 |
+
gr.Markdown(f"Les modèles d'édition coûtent plus cher : "
|
| 548 |
+
+ ", ".join(f"{k} = {v['cout']} crédits/image" for k, v in I2I_MODELS.items())
|
| 549 |
+
+ ". Texte → Image = 1 crédit/image.")
|
| 550 |
+
with gr.Column(scale=3):
|
| 551 |
+
i2i_out = gr.Gallery(label="Résultat", columns=2, height=520, object_fit="contain")
|
| 552 |
+
i2i_msg = gr.Markdown()
|
| 553 |
+
|
| 554 |
+
# ----------------------------------------------------- Galerie
|
| 555 |
+
with gr.Tab("Ma galerie", id="gal") as gal_tab:
|
| 556 |
+
gal_ids = gr.State([])
|
| 557 |
+
gal_sel = gr.State(None)
|
| 558 |
+
with gr.Row():
|
| 559 |
+
gal_refresh = gr.Button("Actualiser")
|
| 560 |
+
gal_zip = gr.Button("Tout télécharger (.zip)")
|
| 561 |
+
my_gal = gr.Gallery(label="Mes images (les plus récentes d'abord)", columns=5, height=560,
|
| 562 |
+
object_fit="cover", allow_preview=True)
|
| 563 |
+
gal_info = gr.Markdown("Cliquez sur une image pour voir son prompt.")
|
| 564 |
+
with gr.Row():
|
| 565 |
+
gal_to_i2i = gr.Button("Utiliser comme référence (Image → Image)")
|
| 566 |
+
gal_reuse = gr.Button("Réutiliser le prompt")
|
| 567 |
+
gal_del = gr.Button("Retirer de la galerie", variant="stop")
|
| 568 |
+
gal_file = gr.File(label="Archive", visible=True)
|
| 569 |
+
|
| 570 |
+
# ----------------------------------------------------- Espace enseignant
|
| 571 |
+
with gr.Tab("Enseignant", id="admin", visible=False) as admin_tab:
|
| 572 |
+
adm_summary = gr.Markdown()
|
| 573 |
+
adm_table = gr.Dataframe(headers=["Étudiant", "Images", "Crédits utilisés", "Crédits restants"],
|
| 574 |
+
interactive=False)
|
| 575 |
+
adm_refresh = gr.Button("Actualiser")
|
| 576 |
+
with gr.Row():
|
| 577 |
+
adm_quota = gr.Number(value=DEFAULT_QUOTA, precision=0, label="Quota par étudiant (crédits)")
|
| 578 |
+
adm_quota_btn = gr.Button("Appliquer le quota")
|
| 579 |
+
with gr.Row():
|
| 580 |
+
adm_student = gr.Dropdown(label="Étudiant", choices=[])
|
| 581 |
+
adm_reset_btn = gr.Button("Remettre ses crédits à zéro")
|
| 582 |
+
with gr.Accordion("Templates de la classe (CSV)", open=True):
|
| 583 |
+
gr.Markdown("Colonnes : `categorie`, `titre`, `prompt`. L'import **remplace** les templates de classe.")
|
| 584 |
+
adm_csv = gr.File(label="Fichier CSV", file_types=[".csv"], type="filepath")
|
| 585 |
+
adm_csv_btn = gr.Button("Importer les templates de classe")
|
| 586 |
+
with gr.Accordion("Voir la galerie d'un étudiant", open=False):
|
| 587 |
+
adm_gal_student = gr.Dropdown(label="Étudiant", choices=[])
|
| 588 |
+
adm_gal = gr.Gallery(columns=5, height=480, object_fit="cover")
|
| 589 |
+
|
| 590 |
+
# ----------------------------------------------------- événements
|
| 591 |
+
|
| 592 |
+
def on_load(request: gr.Request):
|
| 593 |
+
user = current_user(request)
|
| 594 |
+
s = settings()
|
| 595 |
+
return (status_line(user), gr.Dropdown(choices=template_choices(user), value=None),
|
| 596 |
+
gr.Tab(visible=(user == ADMIN_USER)), s["quota"])
|
| 597 |
+
|
| 598 |
+
demo.load(on_load, None, [status, tpl, admin_tab, adm_quota])
|
| 599 |
+
|
| 600 |
+
def refresh_templates(request: gr.Request):
|
| 601 |
+
return gr.Dropdown(choices=template_choices(current_user(request)))
|
| 602 |
+
|
| 603 |
+
tpl.focus(refresh_templates, None, tpl)
|
| 604 |
+
tpl.input(on_template, tpl, t2i_prompt)
|
| 605 |
+
perso_btn.click(import_perso_templates, perso_csv, [tpl, perso_csv])
|
| 606 |
+
|
| 607 |
+
t2i_btn.click(generate_t2i, [t2i_prompt, t2i_model, t2i_fmt, t2i_n, t2i_seed],
|
| 608 |
+
[t2i_out, t2i_msg, status], concurrency_limit=10)
|
| 609 |
+
i2i_btn.click(generate_i2i, [refs, i2i_prompt, i2i_model, i2i_fmt, i2i_n],
|
| 610 |
+
[i2i_out, i2i_msg, status], concurrency_limit=10)
|
| 611 |
+
|
| 612 |
+
gal_outputs = [my_gal, gal_ids, gal_sel, gal_info, status]
|
| 613 |
+
gal_tab.select(refresh_gallery, None, gal_outputs)
|
| 614 |
+
gal_refresh.click(refresh_gallery, None, gal_outputs)
|
| 615 |
+
my_gal.select(select_item, gal_ids, [gal_sel, gal_info])
|
| 616 |
+
gal_to_i2i.click(send_to_i2i, [gal_sel, refs], refs)
|
| 617 |
+
gal_reuse.click(reuse_prompt, gal_sel, t2i_prompt)
|
| 618 |
+
gal_del.click(delete_item, gal_sel, [my_gal, gal_ids, gal_sel, gal_info])
|
| 619 |
+
gal_zip.click(zip_gallery, None, gal_file)
|
| 620 |
+
|
| 621 |
+
adm_outputs = [adm_table, adm_summary, adm_student, adm_gal_student]
|
| 622 |
+
admin_tab.select(admin_overview, None, adm_outputs)
|
| 623 |
+
adm_refresh.click(admin_overview, None, adm_outputs)
|
| 624 |
+
adm_quota_btn.click(admin_set_quota, adm_quota, adm_outputs)
|
| 625 |
+
adm_reset_btn.click(admin_reset, adm_student, adm_outputs)
|
| 626 |
+
adm_csv_btn.click(admin_import_templates, adm_csv, adm_csv)
|
| 627 |
+
adm_gal_student.change(admin_view_gallery, adm_gal_student, adm_gal)
|
| 628 |
+
|
| 629 |
+
|
| 630 |
+
if __name__ == "__main__":
|
| 631 |
+
demo.queue(default_concurrency_limit=10).launch(
|
| 632 |
+
auth=check_auth,
|
| 633 |
+
auth_message="Prénom (ou pseudo) + code de la classe",
|
| 634 |
+
server_name="0.0.0.0",
|
| 635 |
+
server_port=int(os.environ.get("PORT", "7860")),
|
| 636 |
+
allowed_paths=[DATA_DIR],
|
| 637 |
+
)
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=5.0,<6
|
| 2 |
+
fal-client
|
| 3 |
+
requests
|
| 4 |
+
huggingface_hub
|
templates_exemple.csv
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
categorie;titre;prompt
|
| 2 |
+
Personnage;Model sheet (turnaround);"Character model sheet of [PERSONNAGE], full body turnaround: front view, 3/4 view, side view and back view, neutral pose, clean line art with flat colors, white background, 2D animation production style, consistent proportions"
|
| 3 |
+
Personnage;Planche d'expressions;"Expression sheet of [PERSONNAGE], 9 facial expressions in a 3x3 grid (happy, sad, angry, surprised, scared, bored, laughing, thinking, crying), clean 2D cartoon line art, flat colors, white background"
|
| 4 |
+
Personnage;Silhouettes (recherche de forme);"Page of 8 black silhouette thumbnails exploring different body shapes for [PERSONNAGE], strong readable shapes, varied proportions, flat black on white, character design exploration"
|
| 5 |
+
Personnage;Design style cartoon rubber hose;"[PERSONNAGE] designed in 1930s rubber hose cartoon style, pie-cut eyes, white gloves, bendy limbs, black and white ink, film grain"
|
| 6 |
+
Décor;Décor peint à la gouache;"2D animation background painting of [LIEU], gouache and watercolor texture, soft natural light, no characters, wide establishing shot, hand-painted feel, rich atmospheric perspective"
|
| 7 |
+
Décor;Décor anime (lumière dramatique);"Anime background art of [LIEU] at [MOMENT DE LA JOURNEE], detailed hand-painted style, dramatic sky, strong light and shadow, no characters, cinematic composition"
|
| 8 |
+
Décor;Décor tropical (La Réunion);"2D animated film background of a village on Reunion Island, sugar cane fields, volcanic mountains, creole houses with colorful shutters, flamboyant trees, painted style, no characters"
|
| 9 |
+
Décor;Layout en perspective;"Pencil layout drawing of [LIEU] for 2D animation, clear two-point perspective, construction lines visible, grayscale values, no characters, production layout"
|
| 10 |
+
Storyboard;Case de storyboard;"Single storyboard panel, rough pencil sketch, [ACTION], [CADRAGE: plan large / plan moyen / gros plan], arrows indicating movement, black and white, animation storyboard style"
|
| 11 |
+
Storyboard;Planche 6 cases;"Animation storyboard page with 6 panels showing [SEQUENCE], rough grey pencil sketches, simple staging, camera directions noted with arrows, white paper"
|
| 12 |
+
Style;Recherche de style (color script);"Color script for an animated short about [HISTOIRE], sequence of 8 small thumbnail paintings showing the mood evolution from beginning to end, limited palettes, painterly"
|
| 13 |
+
Style;Illustration ligne claire;"[SUJET] in franco-belgian ligne claire comic style, uniform clean black outlines, flat vivid colors, no shading, detailed background"
|
| 14 |
+
Style;Estampe japonaise;"[SUJET] in the style of an ukiyo-e woodblock print, flat color areas, bold outlines, textured paper, limited traditional palette"
|
| 15 |
+
Accessoire;Planche d'accessoires (props);"Prop design sheet for a 2D animated series: [OBJETS], each prop shown from 2 angles, clean line art, flat colors, labels, white background"
|