test974 commited on
Commit
87464ca
·
verified ·
1 Parent(s): ab6d3ca

Upload 4 files

Browse files
Files changed (4) hide show
  1. README.md +61 -8
  2. app.py +637 -0
  3. requirements.txt +4 -0
  4. templates_exemple.csv +15 -0
README.md CHANGED
@@ -1,14 +1,67 @@
1
  ---
2
- title: IA L2 2D
3
- emoji: 👁
4
- colorFrom: gray
5
- colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.28.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
11
- license: apache-2.0
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"