- PHP 46.7%
- JavaScript 45.8%
- Twig 5.8%
- CSS 1.3%
- HTML 0.2%
|
All checks were successful
CI / PHP - tests + static + lint (push) Successful in 9m37s
CI / Renderer - tests + lint JS (push) Successful in 10m6s
CI / Renderer - audit des dépendances JS (push) Successful in 46s
Release / PHP - tests + static + lint (push) Successful in 7m26s
Release / Renderer - tests + lint JS (push) Successful in 8m37s
Release / Merge develop → main (push) Successful in 31s
CI / Renderer - couverture JS (push) Successful in 21m28s
Reviewed-on: #192 |
||
|---|---|---|
| .claude | ||
| .forgejo/workflows | ||
| .idea | ||
| app | ||
| docker | ||
| docs | ||
| gallery3d | ||
| renderer | ||
| rendu-json | ||
| story-telling | ||
| .dockerignore | ||
| .gitignore | ||
| .mcp.json | ||
| CLAUDE.md | ||
| compose.override.yaml | ||
| compose.prod.yaml | ||
| compose.yaml | ||
| README.md | ||
| release.sh | ||
ArtAutomatic
« L'Art du Jour » (FR) / « Daily Design Art » (EN, dailydesignart.com), studio
Code & Canvas - une œuvre d'art algorithmique générée et publiée chaque
jour, vendue en tirages limités numérotés. La spec (invariants, règles de
gestion, architecture, état du dépôt) vit dans CLAUDE.md ; le
runbook prod dans docs/deploiement-production.md ;
les travaux ouverts dans docs/backlog.md. Ce README couvre
l'exploitation en local.
Démarrer la stack
docker compose up # app, postgres, redis, minio, renderer, mailpit
# app exposé sur http://localhost:8080
⚠️ Lancer
docker composedepuis la racine du dépôt (où vitcompose.yaml), jamais depuisapp/(cela démarre un Postgres en double).
Accélérer la boucle de génération en dev
En production, le pipeline tourne lentement : le buffer s'alimente en continu et une seule œuvre est publiée à minuit, après curation humaine. Pour vérifier le fonctionnement de bout en bout sans attendre, on peut faire battre les deux rouages à la minute et auto-valider les rendus.
1. Régler les cadences dans app/.env.local
.env.local n'est pas commité : ces réglages restent locaux, la prod garde
ses défauts (app/.env : publication à minuit, alimentation toutes les 10 min,
auto-validation désactivée).
# Boucle rapide : alimente ET publie chaque minute.
STUDIO_PUBLISH_CRON='* * * * *'
STUDIO_FEED_CRON='* * * * *'
# Auto-valide chaque œuvre rendue → buffer publiable sans clic back-office.
STUDIO_AUTO_VALIDATE=true
Vérifier que le conteneur voit bien ces valeurs :
docker compose exec app php bin/console debug:scheduler
# → les deux triggers à '* * * * *'
docker compose exec app php bin/console debug:dotenv STUDIO_AUTO_VALIDATE
# → Value: true (depuis .env.local)
2. Lancer la boucle autonome
Un seul worker consomme les deux transports (scheduler_default = les triggers
cron, async = génération + rendu). Le laisser tourner :
docker compose exec app \
php bin/console messenger:consume scheduler_default async -vv
Chaque minute : FeedBufferMessage → GenerateArtworkMessage (async) →
RenderArtwork → auto-validated ; en parallèle PublishNextArtworkMessage
pioche la plus ancienne validated → published. Aucun clic back-office requis.
Le buffer est plafonné par
STUDIO_BUFFER_TARGET(défaut 7) : 1 œuvre publiée par minute ⇒ 1 régénérée par minute. Baisser ce plafond dans.env.localpour un flux plus serré.
Choix de l'algorithme : le feeder tire un algorithme au hasard parmi tous ceux marqués
activeen base (tirage uniforme à chaque génération). Le buffer est donc un mélange des algos actifs. Pour piloter le mix, basculer la colonneactive(UPDATE algorithm SET active = ...) ou ajusterACTIVE_ALGORITHMSdansAlgorithmFixtures. Les fixtures activent tous les algos marquésactive(le catalogue en compte plusieurs dizaines : familles fractales, automates cellulaires, Voronoï, réaction-diffusion…).
3. Vérifier que ça marche
Observer la base bouger (autre terminal) - validated se vide d'une unité par
minute, published grimpe :
watch -n5 "docker compose exec -T app php bin/console dbal:run-sql \
\"SELECT status, count(*) FROM artwork GROUP BY status ORDER BY 1\""
Visuel : ouvrir http://localhost:8080/ et http://localhost:8080/musee - la
dernière œuvre publiée doit apparaître (preuve que le raster et l'URL d'asset
MinIO sont bons). Le generation_number s'incrémente sans trou.
Variante : test de fumée manuel (sans attendre les tops minute)
Pour dérouler la chaîne une fois à la main :
# 1. Alimenter le buffer (dispatche les générations manquantes)
docker compose exec app php bin/console studio:feed-buffer
# 2. Générer + rendre + auto-valider (génération dispatche un rendu : prévoir
# ~2 messages par œuvre ; ajuster --limit)
docker compose exec app php bin/console messenger:consume async --limit=14 --time-limit=240 -v
# 3. Publier une œuvre (attend le prochain top minute, ≤ 60 s)
docker compose exec app php bin/console messenger:consume scheduler_default --limit=1 -v
# 4. Contrôler
docker compose exec -T app php bin/console dbal:run-sql \
"SELECT status, count(*) FROM artwork GROUP BY status ORDER BY 1"
Dépannage
- Une œuvre ne se rend pas → suspecter le
renderer(image figée : un algo inconnu fait crasher le process). Logs :docker compose logs -f renderer. - Voir les messages en échec :
docker compose exec app php bin/console messenger:failed:show. messenger:stats/messenger:failed:*: toujours dans le conteneur (sur l'hôte, l'extension RedisRelaymanque et la commande casse).- Aucune génération → vérifier qu'au moins un algorithme est actif :
dbal:run-sql "SELECT code, active FROM algorithm". - Le
/museeest vide alors que l'accueil affiche une œuvre (typiquement après undoctrine:fixtures:load) → la projection publique est mise en cache (poolcache.gallery_catalog, décorateurCachedPublishedArtworkCatalog). Elle n'est purgée que sur l'événementArtworkPublished; or les fixtures appellentArtwork::publish()directement (sans passer parPublishNextArtwork), donc le listenerInvalidateGalleryCacheOnPublicationne se déclenche pas et le musée garde le résultat vide mémorisé avant le chargement. Vider le pool :docker compose exec app php bin/console cache:pool:clear cache.gallery_catalog(oucache:clear). À refaire à chaque rechargement de fixtures. Could not resolve host: renderer(ex. audoctrine:fixtures:load) → la commande a été lancée depuis l'hôte. Le hostnamerenderern'existe que dans le réseau Docker : toute commande qui appelle renderer/minio/postgres se lance dans le conteneur (docker compose exec app php bin/console …).
Renderer — vidéos deep-zoom GPU (outil dev)
En plus du ken-burns de prod, renderer/gpu/ porte un second moteur vidéo pour
les algos deep-* (Mandelbrot/Burning Ship/Multibrot/Julia) : une vraie plongée
fractale (recompute par frame sur le GPU de la machine de dev), jamais appelée par
PHP ni un worker et exclue de l'image Docker. Voir
renderer/gpu/README.md pour le runbook complet et les
prérequis hôte (GPU discret, pilote NVIDIA, ffmpeg).
Carrousels éditoriaux (Instagram)
Second registre de contenu, indépendant de l'œuvre du jour : des carrousels de storytelling qui racontent la marque (« Qui je suis », « Comment ça marche », le certificat, les coulisses…).
Comment ça marche
- Source de vérité : le fichier
app/config/catalog/editorial-carousels.json(versionné). Il est lu directement au runtime — aucune synchro DB, aucune commande de sync. Un JSON malformé casse le build (un test CI charge le fichier réel à travers son Shape de validation). - Rendu des slides : chaque slide est dessinée par un template SVG-Twig
(
app/templates/social/slides/{cover,text,artwork-quote}.svg.twig) → endpoint renderer/render-slide→sharprasterise en PNG 1080×1350 (4:5) → dépôt dans le bucket S3 public. Le fondartworkréutilise l'asset d'une œuvre déjà rendue (cover-crop), jamais un re-rendu. - Publication : les slides rendues sont assemblées en carrousel Instagram via
le mécanisme Graph existant (
InstagramGraphPublisher::publishCarousel). Une caption FR (voix Wilfried) + 1-2 lignes EN + hashtags est ajoutée. - Dédup : la table Postgres
editorial_carousel_post_log(clé =slug) empêche toute republication. Rien de tout cela n'entre dans lecanonical_hash/la signature d'œuvre : couche de présentation pure.
Quand sont-ils générés / publiés
- Crons hebdomadaires
SOCIAL_EDITORIAL_CAROUSEL_CRON(défaut0 12 * * 3|0 15 * * 0= mercredi 12h + dimanche 15h, ancrés Europe/Paris ; transportasync, jetable). Plusieurs créneaux se séparent par|(pas par une virgule, déjà interne à la syntaxe cron) ; vide ⇒ pas de cron du tout. Le dimanche 15h servait auparavant le carrousel œuvre+mockups (SOCIAL_CAROUSEL_CRON, désormais vide = créneau coupé, bouton back-office conservé). Deux tirages par semaine ⇒ la file éditoriale se vide deux fois plus vite : surveiller l'alerte « queue low ». - À chaque tick, la file pioche le carrousel de plus petit
orderqui n'a pas encore été publié (c.-à-d. dont leslugest absent de la table de dédupeditorial_carousel_post_log), rend ses slides à la publication (pas d'avance), publie, puis journalise sa publication dans cette table. File vide → skip. Quand il reste ≤ 2 carrousels non publiés, un log d'alerte est émis (warningsi la file est basse,errorsi elle est vide) — pas de mail : c'est une ligne Monolog à surveiller, le temps d'en rédiger d'autres. On ne boucle jamais, on ne republie jamais. - La banque compte aujourd'hui 14 carrousels rédigés (≈ 3-4 mois à 1/semaine).
Prévisualiser, publier à la demande, régénérer
Page back-office dédiée : /admin/carrousels-editoriaux (liste la file + l'état
publié/non publié).
- Prévisualiser : rend les slides d'un carrousel et affiche leurs URLs S3 publiques — sans publier (mêmes chemins que la publication).
- Publier maintenant : bouton POST (CSRF) qui déclenche la publication immédiate.
- Régénérer : les assets sont nommés
social/editorial/<slug>/slide-<index>-<hash-de-contenu>.png(hash du SVG + fond). Modifier le texte JSON ou un template produit donc automatiquement un nouvel asset au prochain rendu (preview ou publication). Pour republier un carrousel déjà publié (bloqué par la dédup), supprimer sa ligne danseditorial_carousel_post_log— ne jamais renommer unslugpublié (il est immuable, c'est la clé de dédup).
Ajouter un carrousel
Ajouter une entrée à app/config/catalog/editorial-carousels.json :
{
"slug": "mon-carrousel", // unique, [a-z0-9-]+, IMMUABLE une fois publié
"order": 150, // ordre de passage (croissant)
"caption": "…", // FR, voix Wilfried (via /ghost-writer)
"caption_en": "…", // 1-2 lignes EN ajoutées en fin de caption
"hashtags": ["generativeart", "creativecoding", "fractalart"], // 3-8
"slides": [ // 2 à 10 slides
{ "layout": "cover", "title": "…", "body": null,
"background": { "kind": "brand" } },
{ "layout": "text", "title": "…",
"body": ["Ligne 1", "Ligne 2"], // liste de lignes EXPLICITES (pas de wrap auto)
"background": { "kind": "gradient", "from": "#0b1020", "to": "#1a2a6c" } },
{ "layout": "artwork-quote", "title": null, "body": ["…"],
"background": { "kind": "artwork", "generation_number": 42 } }
]
}
layout∈{cover, text, artwork-quote}. Un nouveau layout = 1 template SVG-Twig + 1 case d'enumSlideLayout(Open/Closed, aucunswitchà toucher).background.kind∈{brand, gradient, artwork}.artworkréférence une œuvre publiée par songeneration_number(asset introuvable → échec loud au rendu).- Le
bodyest une liste de lignes : le découpage est éditorial, le SVG ne wrappe pas. Textes en FR. Rédaction via la compétence/ghost-writer. - Vérifier que le fichier passe toujours son Shape :
vendor/bin/phpunit --filter Editorial.
Mise en production
Rebuild de l'image renderer (endpoint /render-slide) + positionner
SOCIAL_EDITORIAL_CAROUSEL_CRON. La connexion INSTAGRAM_* existante est réutilisée.
Brancher TikTok (première mise en service)
TikTok est le 4ᵉ SocialPublisher : vidéo uniquement, il poste le reel du jour
sur le créneau feature de 10h (la passe image de 18h le saute). Rien à rebuilder
côté renderer, aucun nouveau cron : il réutilise le reels_9_16 déjà rendu.
Runbook de ré-authentification : docs/deploiement-production.md §10.
1. Variables d'environnement
Les 12 TIKTOK_* sont déjà remappées dans le bloc x-prod-env de
compose.prod.yaml (héritées par app, worker et scheduler). app/.env
laisse volontairement les secrets vides — sans ce remap, le Direct Post
partirait avec un token vide.
Seules 4 sont requises dans le .env racine du serveur (celui à côté de
compose.prod.yaml, pas app/.env) ; les autres ont un défaut et bloquent le
docker compose up si absentes :
TIKTOK_CLIENT_KEY=
TIKTOK_CLIENT_SECRET=
TIKTOK_REFRESH_TOKEN=
TIKTOK_POST_LOCALES=fr
Le reste est optionnel (défauts dans compose.prod.yaml) : TIKTOK_API_BASE,
TIKTOK_PRIVACY_LEVEL (SELF_ONLY), TIKTOK_REEL_FORMATS (reels_9_16),
TIKTOK_DISABLE_{COMMENT,DUET,STITCH} (false),
TIKTOK_PUBLISH_POLL_{MAX_ATTEMPTS,DELAY_SECONDS} (60/3).
Garde de régression : app/tests/Smoke/SocialTikTokWiringTest.
2. Obtenir le refresh token (une seule fois)
client_key + client_secret ne suffisent pas : le refresh token se gagne
par le flow OAuth Login Kit, à faire une fois à la main. Tout se passe hors
Docker (navigateur + curl).
a. Portail dev → Login Kit → Redirect URI. Enregistrer exactement :
https://dailydesignart.com/tiktok/callback
Contraintes TikTok : HTTPS obligatoire, ni query string ni #, chemin absolu,
match au caractère près. Aucune route Symfony à écrire — la page fera 404, le
code est lisible dans la barre d'adresse.
⚠️ Ne pas utiliser
https://dailydesignart.com/nu : la racine fait un 302 négocié vers/fr/et la query string saute au passage → lecodeest perdu.
Vérifier aussi que le compte TikTok de la marque est ajouté comme target user / testeur de l'app (avant audit, seuls eux peuvent autoriser).
b. Ouvrir dans le navigateur, connecté sur le compte de la marque :
https://www.tiktok.com/v2/auth/authorize/?client_key=TON_CLIENT_KEY&response_type=code&scope=video.publish&redirect_uri=https%3A%2F%2Fdailydesignart.com%2Ftiktok%2Fcallback&state=bootstrap1
Si le portail exige user.info.basic, passer scope=user.info.basic,video.publish.
c. Autoriser → redirection vers la 404, URL du type :
https://dailydesignart.com/tiktok/callback?code=ABC...%2A%211&scopes=video.publish&state=bootstrap1
Copier le code et le décoder (%2A → *, %21 → !) : TikTok l'exige.
Il est à usage unique et expire vite → enchaîner tout de suite.
d. Échanger le code contre le refresh token :
curl -s -X POST https://open.tiktokapis.com/v2/oauth/token/ \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'client_key=TON_CLIENT_KEY' \
--data-urlencode 'client_secret=TON_CLIENT_SECRET' \
--data-urlencode 'code=LE_CODE_DECODE' \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode 'redirect_uri=https://dailydesignart.com/tiktok/callback'
La réponse porte un access_token (24 h, jetable — l'ignorer) et le
refresh_token (365 j) : c'est lui qui va dans TIKTOK_REFRESH_TOKEN.
e. Ensuite, TikTokTokenProvider prend le relais. À chaque échange TikTok
renvoie un refresh token rotaté : il est réécrit immédiatement dans la table
durable social_token (jamais en cache — le perdre coûte une ré-authentification
manuelle complète). La valeur du .env n'est qu'une amorce, lue seulement
tant que le store est vide ; elle devient morte après le premier post.
3. Mise en production
- Jouer la migration
social_token(doctrine:migrations:migrate). - Poser les 4 variables ci-dessus dans le
.envracine, puis recréer la stack. - Tester à la main via le bouton « Poster sur TikTok » de la fiche œuvre
(back-office, page détail — TikTok seul), ou
social:post-today --pass=feature(qui poste sur tous les réseaux activés). Rejoué → sauté (idempotence(platform, locale, generation_number, pass)). TIKTOK_PRIVACY_LEVELresteSELF_ONLYtant que l'audit TikTok du scopevideo.publishn'est pas approuvé. Une fois validé : passerTIKTOK_PRIVACY_LEVEL=PUBLIC_TO_EVERYONEdans le.env— aucun code à toucher.
Docs TikTok : OAuth · Login Kit Web · Content Posting API