commit 51c56a6f30faffdb2f4d03726d10660e7b095779 Author: David Frassi Date: Wed Jun 17 01:26:44 2026 +0200 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8a9c8ed --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +GOOGLE_API_KEY=AIzaSyD6deyt0d3yp88sLbFaadJKxcOg-5cGy-A +BUFFER_ACCESS_TOKEN=EygpqeH802evGWSXqJ9EDSL_OpVBAFOBZqoVzw6OS9u +MAKE_WEBHOOK_URL=optional_make_webhook +TELEGRAM_BOT_TOKEN=your_bot_token +TELEGRAM_CHAT_ID=your_chat_id + +# Higgsfield Credentials +HIGGSFIELD_API_KEY=792d35ce-5429-4056-a2b4-5b20bf74cf69 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02cc2c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +.venv/ +__pycache__/ +*.pyc +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e411347 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.11-slim + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + ffmpeg \ + libmagic1 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Aggiungiamo src al PYTHONPATH così gli import funzionano da ovunque +ENV PYTHONPATH=/app/src + +# Copy requirements and install +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy the rest of the application +COPY . . + +# Expose port for Streamlit +EXPOSE 8501 + +# Avvio di Streamlit puntando alla nuova cartella src +CMD ["streamlit", "run", "src/app.py", "--server.port=8501", "--server.address=0.0.0.0"] diff --git a/ISTRUZIONI_SINCRONIZZAZIONE.md b/ISTRUZIONI_SINCRONIZZAZIONE.md new file mode 100644 index 0000000..c135e90 --- /dev/null +++ b/ISTRUZIONI_SINCRONIZZAZIONE.md @@ -0,0 +1,43 @@ +# 📊 Guida alla Sincronizzazione Real-Time dei Crediti Higgsfield AI + +Questa guida spiega come abilitare il tracciamento automatico e in tempo reale del consumo crediti del tuo abbonamento Higgsfield AI (Pro Plan) all'interno della dashboard Streamlit. + +Seguendo le tue indicazioni di sicurezza, **il sistema non effettua scansioni del tuo browser Chrome né accede al portachiavi di sistema (Chrome Safe Storage)**. La sincronizzazione si basa unicamente sul token di sessione configurato nel file `.env`. + +--- + +## 🛠️ Cosa è stato fatto +1. **Automazione Completa**: Rimosso il form manuale per regolare il budget dei crediti dal cruscotto Streamlit. Ora i crediti si allineano da soli online. +2. **Aggiornamento REST Real-Time**: Implementato un client REST ufficiale (`HiggsClient`) in `src/app.py` che, ad ogni avvio o refresh del cruscotto, interroga in tempo reale i server di Higgsfield per mostrare la percentuale e i crediti rimanenti effettivi. +3. **Indicatori Grafici Premium**: + * **🟢 SINCRONIZZATO ONLINE**: Indica che il token è valido e i crediti mostrati provengono in diretta dal tuo account, con data e ora dell'ultimo allineamento. + * **🟡 CONTATORE LOCALE (CACHE)**: Compare se il token non è impostato nel file `.env`, indicandoti come procedere. + * **🔴 ERRORE DI CONNESSIONE**: Compare in caso di token scaduto o problemi temporanei di connessione, mantenendo visibile l'ultima cache per stabilità. + +--- + +## 🔑 Come configurare la sincronizzazione automatica + +Per sbloccare il monitoraggio automatico dei crediti reali, devi aggiungere il tuo token di sessione all'interno del file di configurazione ambientale. Segui questi passi semplici: + +1. Apri il browser ed effettua l'accesso sul sito ufficiale **[higgsfield.ai](https://higgsfield.ai)**. +2. Premi `F12` (oppure clicca col tasto destro sulla pagina e seleziona **Ispeziona**) per aprire gli Strumenti per Sviluppatori del browser. +3. Spostati nella scheda **Application** (su Google Chrome) o **Storage** (su Firefox/Safari). +4. Nel menu laterale sinistro, espandi la voce **Cookies** e seleziona `https://higgsfield.ai`. +5. Cerca nell'elenco la riga con il nome **`__session`**. +6. Fai doppio clic sul valore del cookie per selezionarlo interamente e **copialo** (è una lunga stringa alfanumerica che inizia con `eyJ...`). +7. Apri il file [**.env**](file:///Users/davidfrassi/SRC/agenti/agenzia/.env) situato nella cartella principale del progetto. +8. Aggiungi la seguente variabile in fondo al file incollando il token copiato: + ```env + HIGGSFIELD_TOKEN=eyJ...[incolla_qui_il_tuo_token] + ``` +9. Salva il file `.env`. + +--- + +## 🚀 Risultato Atteso + +Una volta salvata la variabile nel file `.env`, ricarica il cruscotto Streamlit. Il pannello in basso dedicato ai crediti: +* Rileverà automaticamente il token. +* Mostrerà il badge verde **🟢 SINCRONIZZATO ONLINE** con la data e l'ora dell'aggiornamento. +* Mostrerà i tuoi reali crediti rimanenti online e la percentuale esatta consumata, in perfetto allineamento con la dashboard ufficiale di Higgsfield! diff --git a/azioni_implementazione.md b/azioni_implementazione.md new file mode 100644 index 0000000..67b6b57 --- /dev/null +++ b/azioni_implementazione.md @@ -0,0 +1,37 @@ +# Roadmap Implementazione: Redazione Social Multi-Agente + +Questo documento elenca i passi necessari per costruire il sistema di automazione social. + +## Fase 1: Setup Ambiente e Dati (Dockerized) +- [ ] Inizializzare un progetto con **Docker** e **docker-compose**. +- [ ] Creare la struttura delle cartelle persistenti (volumi): + - `data/images/artista_n`: per le foto. + - `data/audio/artista_n`: per i brani .mp3 da analizzare. +- [ ] Creare un file `config.json` con i profili dei 4 cantanti e i link Spotify/Distrokid. +- [ ] Preparare il `Dockerfile` con le dipendenze (Python, FFmpeg per audio, ecc.). + +## Fase 2: Sviluppo del Database delle Immagini +- [ ] Creare uno script che scansiona la cartella immagini. +- [ ] Implementare una logica di "tracking" per segnare le immagini già usate (per evitare ripetizioni). +- [ ] (Opzionale) Usare un modello Vision per pre-analizzare le immagini e salvarne una descrizione. + +## Fase 3: Sviluppo degli Agenti +- [ ] **Agente Audio Analyst**: Utilizza modelli AI multimodali (es. Gemini) per ascoltare il brano .mp3 e dedurre genere, mood, bpm e temi principali. +- [ ] **Agente Asset Manager**: Seleziona l'immagine non usata dal folder specifico dell'artista. +- [ ] **Agente Copywriter**: Genera testi basandosi sull'output dell'Audio Analyst (non più su generi predefiniti). +- [ ] **Agente Visual-Harmonizer**: Incrocia l'analisi audio con l'estetica dell'immagine scelta. +- [ ] **Agente Hashtag**: Genera tag basati sul brano analizzato e sul visual. +- [ ] **Agente Supervisore**: Verifica finale della coerenza tra audio, immagine e link. + +## Fase 4: Orchestrazione e Approvazione +- [ ] Configurare il grafo di **LangGraph** per collegare gli agenti. +- [ ] Creare una semplice interfaccia (es. **Streamlit**) per visualizzare la proposta del post e cliccare su "Approva" o "Rifiuta". +- [ ] Integrare un sistema di notifiche (es. Telegram Bot) per avvisarti quando un post è pronto. + +## Fase 5: Distribuzione (Post reale) +- [ ] Integrare le API dei social media o un aggregatore (es. **Buffer API** o un webhook verso **Make.com**). +- [ ] Testare il flusso completo con un post di prova. + +## Fase 6: Manutenzione +- [ ] Implementare un log dei post pubblicati. +- [ ] Sistema di alert se le immagini in una cartella stanno per finire. diff --git a/config.json b/config.json new file mode 100644 index 0000000..a81d55a --- /dev/null +++ b/config.json @@ -0,0 +1,85 @@ +{ + "artists": [ + { + "id": "Veronica Intorcia", + "name": "Veronica Intorcia", + "spotify_url": "https://open.spotify.com/intl-it/album/2LXeQfqTYA9SyYFz776pj5?si=k0caFWXaSHW63FLmrzifIg", + "distrokid_url": "https://distrokid.com/hyperfollow/veronika33/midnight-meridian-2", + "schedule_time": "09:00", + "drive_audio_url": "https://drive.google.com/file/d/1IfFSCPYuBBBCRaLerQYe30lrzcIjhqiI/view?usp=sharing", + "drive_images_url": "https://drive.google.com/drive/folders/1MlO5TkCGrnUNoNFzepM6NtnBnAjhyiHp?usp=drive_link", + "drive_videos_url": "https://drive.google.com/drive/folders/1oKRPFYZgusUtk0F9RWiC6a-Su8e4kZY4?usp=drive_link", + "buffer_token": "d0uh1tvuD4Y_VDJkVDlDDYZSGHOxVWDnJlLCvNoBiYO", + "narrative_style": "sile narrativo da cantnate pop figa stile dualipa", + "social_tag": "@veronika_officialpage", + "soul_id": "soul_veronica_intorcia", + "drive_starting_photos_url": "https://drive.google.com/drive/folders/1FAFyCaIInW5qGkv_pzviqLZKUbaSCIf-?usp=drive_link" + }, + { + "id": "Cinzia Carreri", + "name": "Cinzia Carreri", + "spotify_url": "https://open.spotify.com/intl-it/track/0Z5xASrrUMx60kBXyqzTfe?si=f672e645783f4fd5", + "distrokid_url": "https://distrokid.com/hyperfollow/odette4/mirror-of-life-2", + "schedule_time": "10:30", + "drive_audio_url": "https://drive.google.com/file/d/1BVn0MlYzolR9HxwZGlsAC8YmIJGBbW8y/view?usp=drive_link", + "drive_images_url": "https://drive.google.com/drive/folders/1M7mD43vGKoBV_TsSjEDrN6GCsliQrvn1?usp=drive_link", + "drive_videos_url": "https://drive.google.com/drive/folders/1jB_BJH2QKh_XvrZz5qjTuDS65jQ_NJE6?usp=drive_link", + "buffer_token": "d0uh1tvuD4Y_VDJkVDlDDYZSGHOxVWDnJlLCvNoBiYO", + "narrative_style": "lo stile narrativo deve essere un po' sognatore da amanti del genere Enya e colonne sonore di film fantasy. Scrivi il post in inglese", + "social_tag": "@odette_officialpage", + "soul_id": "soul_cinzia_carreri" + }, + { + "id": "Martina Zerjial", + "name": "Martina Zerjial", + "spotify_url": "https://open.spotify.com/intl-it/track/4jlDBu3SV24FUrH7YjTRzt?si=72142d6360e34370", + "distrokid_url": "https://distrokid.com/hyperfollow/elaya/saltwater-kisses", + "schedule_time": "14:00", + "drive_audio_url": "https://drive.google.com/file/d/13vZtqfVQ7G3Rjta1DvsYaAicEI1J__Ix/view?usp=drive_link", + "drive_images_url": "https://drive.google.com/drive/folders/1jkA5ApSNTJ68mEdHPmaVkBSa-CDJ0ihe?usp=drive_link", + "drive_videos_url": "https://drive.google.com/drive/folders/1Fk85iijYBAuznGMv_LZN018jvQOkH6M6?usp=drive_link", + "buffer_token": "d0uh1tvuD4Y_VDJkVDlDDYZSGHOxVWDnJlLCvNoBiYO", + "narrative_style": "stile narrativo da popstar che fa genere retrowave ma anche funky moderno, in stile the kolors", + "social_tag": "@elaya_officialpage", + "soul_id": "soul_martina_zerjial" + }, + { + "id": "Ambra Manca", + "name": "Ambra Manca", + "spotify_url": "", + "distrokid_url": " ", + "schedule_time": "18:00", + "drive_audio_url": "https://drive.google.com/file/d/1ScgXJmnniTK8QeFZlB_qgCrywVaR5-_J/view?usp=drive_link", + "drive_images_url": "https://drive.google.com/drive/folders/1OXYnJNILkzVrlXNcMUbZBcKMrE9KT1ho?usp=drive_link", + "drive_videos_url": "", + "buffer_token": "d0uh1tvuD4Y_VDJkVDlDDYZSGHOxVWDnJlLCvNoBiYO", + "narrative_style": "stile narrativo figo da cantante funky americano, con contaminazione alla jamiroquai", + "social_tag": "@jaderaya_official", + "soul_id": "soul_ambra_manca" + }, + { + "id": "StereoComics", + "name": "StereoComics", + "spotify_url": "", + "distrokid_url": "", + "schedule_time": "18:00", + "drive_audio_url": "https://drive.google.com/file/d/14RMoKsiupSQVj2MEzbXgNBzKZ3FPLSVQ/view?usp=sharing", + "drive_images_url": "https://drive.google.com/drive/folders/1aaReM9YqG8JhXMQKtZuFqWE9RbvwvsRp?usp=drive_link", + "drive_videos_url": "", + "buffer_token": "4OwhtCHLJ61Ld659qeQS1qd0MVFypTY3TtP1Wa9AKSA", + "narrative_style": "lo stile narrativo deve essere un po' canzonatorio, ironico, da nerd, che fa citazione degli aneddoti degli autori originari del brano, usa un linguaggio giovanile e accattivante", + "social_tag": "@spaziosigle", + "soul_id": "soul_stereocomics" + } + ], + "settings": { + "daily_post_count": 1, + "platforms": [ + "instagram", + "facebook", + "tiktok", + "x", + "youtube" + ] + } +} \ No newline at end of file diff --git a/data/buffer_logs.txt b/data/buffer_logs.txt new file mode 100755 index 0000000..b7cd847 --- /dev/null +++ b/data/buffer_logs.txt @@ -0,0 +1,51 @@ +[2026-05-05 21:05:20] INVIO POST a Canale: 69f9e7e35c4c051afa116a9e +[2026-05-05 21:05:22] RISPOSTA BUFFER: {"post": {"id": "69fa5b92508055e61464dc15"}} +[2026-05-05 21:05:22] INVIO POST a Canale: 69f9ea855c4c051afa117baa +[2026-05-05 21:05:22] ERRORE HTTP 400: {"errors":[{"message":"Variable \"$input\" got invalid value { type: \"post\" } at \"input.metadata.instagram\"; Field \"shouldShareToFeed\" of required type \"Boolean!\" was not provided.","locations":[{"line":2,"column":23}],"extensions":{"code":"BAD_USER_INPUT"}}]} + +[2026-05-05 21:07:05] INVIO POST a Canale: 69f9e7e35c4c051afa116a9e +[2026-05-05 21:07:06] RISPOSTA BUFFER: {"post": {"id": "69fa5bfaf2feba8c0c48e58f"}} +[2026-05-05 21:07:06] INVIO POST a Canale: 69f9ea855c4c051afa117baa +[2026-05-05 21:07:07] RISPOSTA BUFFER: {"post": {"id": "69fa5bfbf2feba8c0c48e5a9"}} +[2026-05-05 21:09:19] INVIO POST a Canale: 69f9e7e35c4c051afa116a9e +[2026-05-05 21:09:20] RISPOSTA BUFFER: {"post": {"id": "69fa5c80f2feba8c0c48e835"}} +[2026-05-05 21:09:20] INVIO POST a Canale: 69f9ea855c4c051afa117baa +[2026-05-05 21:09:21] RISPOSTA BUFFER: {"post": {"id": "69fa5c81508055e61464e273"}} +[2026-05-05 21:37:02] INVIO POST a Canale: 69f9e7e35c4c051afa116a9e +[2026-05-05 21:37:04] RISPOSTA BUFFER: {"post": {"id": "69fa6300f2feba8c0c492100"}} +[2026-05-05 21:37:04] INVIO POST a Canale: 69f9ea855c4c051afa117baa +[2026-05-05 21:37:05] RISPOSTA BUFFER: {"post": {"id": "69fa6301f2feba8c0c492127"}} +[2026-05-05 21:38:31] INVIO POST a Canale: 69f9e7e35c4c051afa116a9e +[2026-05-05 21:38:33] RISPOSTA BUFFER: {"post": {"id": "69fa6359508055e614652001"}} +[2026-05-05 21:38:33] INVIO POST a Canale: 69f9ea855c4c051afa117baa +[2026-05-05 21:38:35] RISPOSTA BUFFER: {"post": {"id": "69fa635bf2feba8c0c4922ab"}} +[2026-05-05 21:41:27] INVIO POST a Canale: 69f9e7e35c4c051afa116a9e +[2026-05-05 21:41:27] ERRORE HTTP 400: {"errors":[{"message":"Variable \"$input\" got invalid value { channelId: \"69f9e7e35c4c051afa116a9e\", text: \"*Un volo elettrico verso il nulla ✨*\\n\\nLuci notturne, riflessi dorati e il bisogno viscerale di lasciarsi tutto alle spalle. \\\"Midnight Meridian\\\" è la mia fuga urbana, una catarsi tra synth brillanti e ritmi che non lasciano scampo. Se senti la necessità di perderti nel movimento per ritrovare te stessa, questa è la tua nuova colonna sonora. Lascia che le ombre prendano vita e abbandonati a questa danza liberatoria. Ascolta ora: https://distrokid.com/hyperfollow/veronika33/midnight-meridian-2\\n\\n#MidnightMeridian #Veronika33 #Synthwave #ElectronicMusic #NewMusic #UrbanVibes #NightLife #Catharsis #Synthpop #DanceMusic #MusicRelease #MidnightVibes #IndependentArtist #ElectroPop #NightDrive\", mode: \"shareNow\", assets: { images: [Array] }, metadata: { tiktok: [Object] } }; Field \"schedulingType\" of required type \"SchedulingType!\" was not provided.","locations":[{"line":2,"column":23}],"extensions":{"code":"BAD_USER_INPUT"}}]} + +[2026-05-05 21:41:27] INVIO POST a Canale: 69f9ea855c4c051afa117baa +[2026-05-05 21:41:27] ERRORE HTTP 400: {"errors":[{"message":"Variable \"$input\" got invalid value { channelId: \"69f9ea855c4c051afa117baa\", text: \"*Un volo elettrico verso il nulla ✨*\\n\\nLuci notturne, riflessi dorati e il bisogno viscerale di lasciarsi tutto alle spalle. \\\"Midnight Meridian\\\" è la mia fuga urbana, una catarsi tra synth brillanti e ritmi che non lasciano scampo. Se senti la necessità di perderti nel movimento per ritrovare te stessa, questa è la tua nuova colonna sonora. Lascia che le ombre prendano vita e abbandonati a questa danza liberatoria. Ascolta ora: https://distrokid.com/hyperfollow/veronika33/midnight-meridian-2\\n\\n#MidnightMeridian #Veronika33 #Synthwave #ElectronicMusic #NewMusic #UrbanVibes #NightLife #Catharsis #Synthpop #DanceMusic #MusicRelease #MidnightVibes #IndependentArtist #ElectroPop #NightDrive\", mode: \"shareNow\", assets: { images: [Array] }, metadata: { instagram: [Object] } }; Field \"schedulingType\" of required type \"SchedulingType!\" was not provided.","locations":[{"line":2,"column":23}],"extensions":{"code":"BAD_USER_INPUT"}}]} + +[2026-05-05 21:42:30] INVIO POST a Canale: 69f9e7e35c4c051afa116a9e +[2026-05-05 21:42:41] RISPOSTA BUFFER: {"post": {"id": "69fa6447f2feba8c0c492ab3"}} +[2026-05-05 21:42:41] INVIO POST a Canale: 69f9ea855c4c051afa117baa +[2026-05-05 21:42:52] RISPOSTA BUFFER: {"post": {"id": "69fa6452f2feba8c0c492ae3"}} +[2026-05-06 00:27:00] INVIO POST a Canale: 69f9e7e35c4c051afa116a9e +[2026-05-06 00:27:01] ERRORE HTTP 400: {"errors":[{"message":"Variable \"$input\" got invalid value { video: { url: \"https://h.uguu.se/iBZbfOTe.mp4\" } } at \"input.assets\"; Field \"video\" is not defined by type \"AssetsInput\". [Suggestion hidden]?","locations":[{"line":2,"column":23}],"extensions":{"code":"BAD_USER_INPUT"}}]} + +[2026-05-06 00:27:01] INVIO POST a Canale: 69f9ea855c4c051afa117baa +[2026-05-06 00:27:01] ERRORE HTTP 400: {"errors":[{"message":"Variable \"$input\" got invalid value { video: { url: \"https://h.uguu.se/iBZbfOTe.mp4\" } } at \"input.assets\"; Field \"video\" is not defined by type \"AssetsInput\". [Suggestion hidden]?","locations":[{"line":2,"column":23}],"extensions":{"code":"BAD_USER_INPUT"}}]} + +[2026-05-06 00:28:05] INVIO POST a Canale: 69f9e7e35c4c051afa116a9e +[2026-05-06 00:28:19] RISPOSTA BUFFER: {"post": {"id": "69fa8b19e6fd3862f225c85c"}} +[2026-05-06 00:28:19] INVIO POST a Canale: 69f9ea855c4c051afa117baa +[2026-05-06 00:28:32] RISPOSTA BUFFER: {"post": {"id": "69fa8b25e6fd3862f225c8e1"}} +[2026-05-12 23:52:39] INVIO POST a Canale: 69f9e7e35c4c051afa116a9e +[2026-05-12 23:52:39] RISPOSTA BUFFER: {"message": "Channel not found"} +[2026-05-12 23:52:39] INVIO POST a Canale: 69f9ea855c4c051afa117baa +[2026-05-12 23:52:40] RISPOSTA BUFFER: {"message": "Channel not found"} +[2026-05-13 00:09:28] Nessun Profile ID fornito, avvio auto-discovery... +[2026-05-13 00:09:29] ERRORE recupero profili: {"errors":[{"message":"Cannot query field \"profiles\" on type \"Query\".","locations":[{"line":3,"column":11}],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]} + +[2026-05-13 00:09:48] Nessun Profile ID fornito, avvio auto-discovery... +[2026-05-13 00:09:48] ERRORE recupero profili: {"errors":[{"message":"Cannot query field \"profiles\" on type \"Query\".","locations":[{"line":3,"column":11}],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]} + diff --git a/data/cache/credits.json b/data/cache/credits.json new file mode 100644 index 0000000..32e0240 --- /dev/null +++ b/data/cache/credits.json @@ -0,0 +1,5 @@ +{ + "total_monthly": 1000, + "remaining": 910, + "generated_this_month": 90 +} diff --git a/data/images/generated_photos/gen_Veronica_Intorcia_veronica.png b/data/images/generated_photos/gen_Veronica_Intorcia_veronica.png new file mode 100644 index 0000000..859bdf0 Binary files /dev/null and b/data/images/generated_photos/gen_Veronica_Intorcia_veronica.png differ diff --git a/data/images/test_flux.png b/data/images/test_flux.png new file mode 100644 index 0000000..df7a83e Binary files /dev/null and b/data/images/test_flux.png differ diff --git a/data/images/test_flux_opt1.png b/data/images/test_flux_opt1.png new file mode 100644 index 0000000..a1aae3a Binary files /dev/null and b/data/images/test_flux_opt1.png differ diff --git a/data/images/test_flux_opt2.png b/data/images/test_flux_opt2.png new file mode 100644 index 0000000..437ccea Binary files /dev/null and b/data/images/test_flux_opt2.png differ diff --git a/data/images/test_flux_opt3.png b/data/images/test_flux_opt3.png new file mode 100644 index 0000000..5a40d4a Binary files /dev/null and b/data/images/test_flux_opt3.png differ diff --git a/data/redazione.db b/data/redazione.db new file mode 100755 index 0000000..4d57d4a Binary files /dev/null and b/data/redazione.db differ diff --git a/data/starting_photos/Veronica_Intorcia/1763193020.png b/data/starting_photos/Veronica_Intorcia/1763193020.png new file mode 100644 index 0000000..8be0b76 Binary files /dev/null and b/data/starting_photos/Veronica_Intorcia/1763193020.png differ diff --git a/data/starting_photos/Veronica_Intorcia/_59a099dc9486f16adab4cdf56cf7158feg.png b/data/starting_photos/Veronica_Intorcia/_59a099dc9486f16adab4cdf56cf7158feg.png new file mode 100644 index 0000000..285c106 Binary files /dev/null and b/data/starting_photos/Veronica_Intorcia/_59a099dc9486f16adab4cdf56cf7158feg.png differ diff --git a/data/starting_photos/Veronica_Intorcia/_896ce1a9d4d276382434f2ca454a2de6eg.png b/data/starting_photos/Veronica_Intorcia/_896ce1a9d4d276382434f2ca454a2de6eg.png new file mode 100644 index 0000000..f4b0b68 Binary files /dev/null and b/data/starting_photos/Veronica_Intorcia/_896ce1a9d4d276382434f2ca454a2de6eg.png differ diff --git a/data/starting_photos/Veronica_Intorcia/_c9bf84504f2b8649816481c143033bb1dr.jpeg b/data/starting_photos/Veronica_Intorcia/_c9bf84504f2b8649816481c143033bb1dr.jpeg new file mode 100644 index 0000000..147a3f1 Binary files /dev/null and b/data/starting_photos/Veronica_Intorcia/_c9bf84504f2b8649816481c143033bb1dr.jpeg differ diff --git a/data/starting_photos/Veronica_Intorcia/_d1599cf9e7650f6901c43faee173139edr.jpeg b/data/starting_photos/Veronica_Intorcia/_d1599cf9e7650f6901c43faee173139edr.jpeg new file mode 100644 index 0000000..c4d8b86 Binary files /dev/null and b/data/starting_photos/Veronica_Intorcia/_d1599cf9e7650f6901c43faee173139edr.jpeg differ diff --git a/data/starting_photos/Veronica_Intorcia/gemini-2.5-flash-image-preview (nano-banana)_a_introduci_dei_piccol (1).png b/data/starting_photos/Veronica_Intorcia/gemini-2.5-flash-image-preview (nano-banana)_a_introduci_dei_piccol (1).png new file mode 100644 index 0000000..65cad61 Binary files /dev/null and b/data/starting_photos/Veronica_Intorcia/gemini-2.5-flash-image-preview (nano-banana)_a_introduci_dei_piccol (1).png differ diff --git a/data/starting_photos/Veronica_Intorcia/gemini-2.5-flash-image-preview (nano-banana)_a_introduci_dei_piccol.png b/data/starting_photos/Veronica_Intorcia/gemini-2.5-flash-image-preview (nano-banana)_a_introduci_dei_piccol.png new file mode 100644 index 0000000..65cad61 Binary files /dev/null and b/data/starting_photos/Veronica_Intorcia/gemini-2.5-flash-image-preview (nano-banana)_a_introduci_dei_piccol.png differ diff --git a/data/starting_photos/Veronica_Intorcia/hf_20260308_192229_8772ec06-05a0-4ea5-8044-11b0515f1ebd.png b/data/starting_photos/Veronica_Intorcia/hf_20260308_192229_8772ec06-05a0-4ea5-8044-11b0515f1ebd.png new file mode 100644 index 0000000..802346a Binary files /dev/null and b/data/starting_photos/Veronica_Intorcia/hf_20260308_192229_8772ec06-05a0-4ea5-8044-11b0515f1ebd.png differ diff --git a/data/starting_photos/Veronica_Intorcia/hf_20260308_221819_4c22e2c7-5d8a-401c-aa45-766cd0b00df6.png b/data/starting_photos/Veronica_Intorcia/hf_20260308_221819_4c22e2c7-5d8a-401c-aa45-766cd0b00df6.png new file mode 100644 index 0000000..b3cfc04 Binary files /dev/null and b/data/starting_photos/Veronica_Intorcia/hf_20260308_221819_4c22e2c7-5d8a-401c-aa45-766cd0b00df6.png differ diff --git a/data/starting_photos/Veronica_Intorcia/hf_20260308_222402_b6a31ae5-876e-4693-807a-ad87e2948e67.png b/data/starting_photos/Veronica_Intorcia/hf_20260308_222402_b6a31ae5-876e-4693-807a-ad87e2948e67.png new file mode 100644 index 0000000..20a0f95 Binary files /dev/null and b/data/starting_photos/Veronica_Intorcia/hf_20260308_222402_b6a31ae5-876e-4693-807a-ad87e2948e67.png differ diff --git a/data/starting_photos/Veronica_Intorcia/hf_20260316_001724_56d2f21e-5c78-4b7f-9875-07167a678ef1.png b/data/starting_photos/Veronica_Intorcia/hf_20260316_001724_56d2f21e-5c78-4b7f-9875-07167a678ef1.png new file mode 100644 index 0000000..3db1000 Binary files /dev/null and b/data/starting_photos/Veronica_Intorcia/hf_20260316_001724_56d2f21e-5c78-4b7f-9875-07167a678ef1.png differ diff --git a/data/starting_photos/Veronica_Intorcia/hf_20260316_003000_88830ba5-c29a-4794-8381-0ce6b91b61e4.png b/data/starting_photos/Veronica_Intorcia/hf_20260316_003000_88830ba5-c29a-4794-8381-0ce6b91b61e4.png new file mode 100644 index 0000000..766508f Binary files /dev/null and b/data/starting_photos/Veronica_Intorcia/hf_20260316_003000_88830ba5-c29a-4794-8381-0ce6b91b61e4.png differ diff --git a/data/starting_photos/Veronica_Intorcia/hf_20260319_235744_c1b5522c-edb9-4a72-a826-c9a1b16d82ca.jpeg b/data/starting_photos/Veronica_Intorcia/hf_20260319_235744_c1b5522c-edb9-4a72-a826-c9a1b16d82ca.jpeg new file mode 100644 index 0000000..8a7a37c Binary files /dev/null and b/data/starting_photos/Veronica_Intorcia/hf_20260319_235744_c1b5522c-edb9-4a72-a826-c9a1b16d82ca.jpeg differ diff --git a/data/starting_photos/Veronica_Intorcia/tmpdb_nbp0n.jpg b/data/starting_photos/Veronica_Intorcia/tmpdb_nbp0n.jpg new file mode 100644 index 0000000..5e796bb Binary files /dev/null and b/data/starting_photos/Veronica_Intorcia/tmpdb_nbp0n.jpg differ diff --git a/data/starting_photos/Veronica_Intorcia/tmpjvpn61qo.jpg b/data/starting_photos/Veronica_Intorcia/tmpjvpn61qo.jpg new file mode 100644 index 0000000..e892aef Binary files /dev/null and b/data/starting_photos/Veronica_Intorcia/tmpjvpn61qo.jpg differ diff --git a/data/starting_photos/Veronica_Intorcia/tmpmvix816e.jpg b/data/starting_photos/Veronica_Intorcia/tmpmvix816e.jpg new file mode 100644 index 0000000..0c08b65 Binary files /dev/null and b/data/starting_photos/Veronica_Intorcia/tmpmvix816e.jpg differ diff --git a/data/starting_photos/Veronica_Intorcia/tmpnlfpzldz.jpg b/data/starting_photos/Veronica_Intorcia/tmpnlfpzldz.jpg new file mode 100644 index 0000000..666ed1a Binary files /dev/null and b/data/starting_photos/Veronica_Intorcia/tmpnlfpzldz.jpg differ diff --git a/data/starting_photos/test_artist/veronica.png b/data/starting_photos/test_artist/veronica.png new file mode 100644 index 0000000..df7a83e Binary files /dev/null and b/data/starting_photos/test_artist/veronica.png differ diff --git a/data/tasks/error.log b/data/tasks/error.log new file mode 100755 index 0000000..9b383e5 --- /dev/null +++ b/data/tasks/error.log @@ -0,0 +1,138 @@ + +--- Tue May 12 16:02:34 2026 --- +❌ Errore critico nel task generate (StereoComics): Failed to retrieve file url: + + Cannot retrieve the public link of the file. You may need to change + the permission to 'Anyone with the link', or have had many accesses. + Check FAQ in https://github.com/wkentaro/gdown?tab=readme-ov-file#faq. + +You may still be able to access the file from the browser: + + https://drive.google.com/uc?id=14RMoKsiupSQVj2MEzbXgNBzKZ3FPLSVQ + +but Gdown can't. Please check connections and permissions. +Traceback (most recent call last): + File "/usr/local/lib/python3.11/site-packages/gdown/download.py", line 301, in download + url = get_url_from_gdrive_confirmation(res.text) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/usr/local/lib/python3.11/site-packages/gdown/download.py", line 65, in get_url_from_gdrive_confirmation + raise FileURLRetrievalError( +gdown.exceptions.FileURLRetrievalError: Cannot retrieve the public link of the file. You may need to change the permission to 'Anyone with the link', or have had many accesses. Check FAQ in https://github.com/wkentaro/gdown?tab=readme-ov-file#faq. + +During handling of the above exception, another exception occurred: + +Traceback (most recent call last): + File "/app/src/background_runner.py", line 71, in + run_generate(args.artist_id, mode=args.mode) + File "/app/src/background_runner.py", line 43, in run_generate + results = agents.run_for_artist(artist, mode=mode) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/app/src/agents.py", line 285, in run_for_artist + audio_state = self.analyze_audio_node(initial_state) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/app/src/agents.py", line 49, in analyze_audio_node + gdown.download(url, output=path, quiet=True) + File "/usr/local/lib/python3.11/site-packages/gdown/download.py", line 312, in download + raise FileURLRetrievalError(message) +gdown.exceptions.FileURLRetrievalError: Failed to retrieve file url: + + Cannot retrieve the public link of the file. You may need to change + the permission to 'Anyone with the link', or have had many accesses. + Check FAQ in https://github.com/wkentaro/gdown?tab=readme-ov-file#faq. + +You may still be able to access the file from the browser: + + https://drive.google.com/uc?id=14RMoKsiupSQVj2MEzbXgNBzKZ3FPLSVQ + +but Gdown can't. Please check connections and permissions. + + +--- Tue May 12 20:58:46 2026 --- +❌ Errore critico nel task generate (Veronica Intorcia): (sqlite3.OperationalError) table drafts has no column named focus_points +[SQL: INSERT INTO drafts (artist_id, title, caption, hashtags, image_path, image_paths, image_url, video_url, video_path, audio_analysis, focus_points, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)] +[parameters: ('Veronica Intorcia', "Perdersi nell'Emozione", 'Lasciati trasportare dalle vibrazioni ipnotiche di "Lost In The Emotion". Una fusione magistrale tra Deep House e Melodic House, dove voci soul incon ... (151 characters truncated) ... ofisticata o vuole accendere l\'energia della notte. Ascolta ora il nuovo brano qui: https://distrokid.com/hyperfollow/veronika33/midnight-meridian-2', '#DeepHouse #MelodicHouse #ElectronicMusic #LostInTheEmotion #HouseMusic #SoulfulHouse #NewMusicAlert #HypnoticBeats #MusicJourney #NightVibes #MelodicTechno #IntrospectiveVibes #EuphoricMusic #DanceMusic #NewRelease', '', None, None, '', None, "Immergiti nelle profondità ipnotiche di 'Lost In The Emotion', una magistrale fusione di Deep House e Melodic House. Con le sue voci soul, il ritmo i ... (166 characters truncated) ... are un mood sofisticato o accendere l'energia notturna, è un viaggio sonoro accattivante, progettato per risuonare profondamente con gli ascoltatori.", None, 'pending', '2026-05-12 20:58:46.021420')] +(Background on this error at: https://sqlalche.me/e/20/e3q8) +Traceback (most recent call last): + File "/usr/local/lib/python3.11/site-packages/sqlalchemy/engine/base.py", line 1967, in _exec_single_context + self.dialect.do_execute( + File "/usr/local/lib/python3.11/site-packages/sqlalchemy/engine/default.py", line 952, in do_execute + cursor.execute(statement, parameters) +sqlite3.OperationalError: table drafts has no column named focus_points + +The above exception was the direct cause of the following exception: + +Traceback (most recent call last): + File "/app/src/background_runner.py", line 72, in + run_generate(args.artist_id, mode=args.mode) + File "/app/src/background_runner.py", line 46, in run_generate + db.save_draft( + File "/app/src/database.py", line 131, in save_draft + session.commit() + File "/usr/local/lib/python3.11/site-packages/sqlalchemy/orm/session.py", line 2030, in commit + trans.commit(_to_root=True) + File "", line 2, in commit + File "/usr/local/lib/python3.11/site-packages/sqlalchemy/orm/state_changes.py", line 137, in _go + ret_value = fn(self, *arg, **kw) + ^^^^^^^^^^^^^^^^^^^^ + File "/usr/local/lib/python3.11/site-packages/sqlalchemy/orm/session.py", line 1311, in commit + self._prepare_impl() + File "", line 2, in _prepare_impl + File "/usr/local/lib/python3.11/site-packages/sqlalchemy/orm/state_changes.py", line 137, in _go + ret_value = fn(self, *arg, **kw) + ^^^^^^^^^^^^^^^^^^^^ + File "/usr/local/lib/python3.11/site-packages/sqlalchemy/orm/session.py", line 1286, in _prepare_impl + self.session.flush() + File "/usr/local/lib/python3.11/site-packages/sqlalchemy/orm/session.py", line 4331, in flush + self._flush(objects) + File "/usr/local/lib/python3.11/site-packages/sqlalchemy/orm/session.py", line 4466, in _flush + with util.safe_reraise(): + File "/usr/local/lib/python3.11/site-packages/sqlalchemy/util/langhelpers.py", line 121, in __exit__ + raise exc_value.with_traceback(exc_tb) + File "/usr/local/lib/python3.11/site-packages/sqlalchemy/orm/session.py", line 4427, in _flush + flush_context.execute() + File "/usr/local/lib/python3.11/site-packages/sqlalchemy/orm/unitofwork.py", line 466, in execute + rec.execute(self) + File "/usr/local/lib/python3.11/site-packages/sqlalchemy/orm/unitofwork.py", line 642, in execute + util.preloaded.orm_persistence.save_obj( + File "/usr/local/lib/python3.11/site-packages/sqlalchemy/orm/persistence.py", line 93, in save_obj + _emit_insert_statements( + File "/usr/local/lib/python3.11/site-packages/sqlalchemy/orm/persistence.py", line 1233, in _emit_insert_statements + result = connection.execute( + ^^^^^^^^^^^^^^^^^^^ + File "/usr/local/lib/python3.11/site-packages/sqlalchemy/engine/base.py", line 1419, in execute + return meth( + ^^^^^ + File "/usr/local/lib/python3.11/site-packages/sqlalchemy/sql/elements.py", line 527, in _execute_on_connection + return connection._execute_clauseelement( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/usr/local/lib/python3.11/site-packages/sqlalchemy/engine/base.py", line 1641, in _execute_clauseelement + ret = self._execute_context( + ^^^^^^^^^^^^^^^^^^^^^^ + File "/usr/local/lib/python3.11/site-packages/sqlalchemy/engine/base.py", line 1846, in _execute_context + return self._exec_single_context( + ^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/usr/local/lib/python3.11/site-packages/sqlalchemy/engine/base.py", line 1986, in _exec_single_context + self._handle_dbapi_exception( + File "/usr/local/lib/python3.11/site-packages/sqlalchemy/engine/base.py", line 2363, in _handle_dbapi_exception + raise sqlalchemy_exception.with_traceback(exc_info[2]) from e + File "/usr/local/lib/python3.11/site-packages/sqlalchemy/engine/base.py", line 1967, in _exec_single_context + self.dialect.do_execute( + File "/usr/local/lib/python3.11/site-packages/sqlalchemy/engine/default.py", line 952, in do_execute + cursor.execute(statement, parameters) +sqlalchemy.exc.OperationalError: (sqlite3.OperationalError) table drafts has no column named focus_points +[SQL: INSERT INTO drafts (artist_id, title, caption, hashtags, image_path, image_paths, image_url, video_url, video_path, audio_analysis, focus_points, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)] +[parameters: ('Veronica Intorcia', "Perdersi nell'Emozione", 'Lasciati trasportare dalle vibrazioni ipnotiche di "Lost In The Emotion". Una fusione magistrale tra Deep House e Melodic House, dove voci soul incon ... (151 characters truncated) ... ofisticata o vuole accendere l\'energia della notte. Ascolta ora il nuovo brano qui: https://distrokid.com/hyperfollow/veronika33/midnight-meridian-2', '#DeepHouse #MelodicHouse #ElectronicMusic #LostInTheEmotion #HouseMusic #SoulfulHouse #NewMusicAlert #HypnoticBeats #MusicJourney #NightVibes #MelodicTechno #IntrospectiveVibes #EuphoricMusic #DanceMusic #NewRelease', '', None, None, '', None, "Immergiti nelle profondità ipnotiche di 'Lost In The Emotion', una magistrale fusione di Deep House e Melodic House. Con le sue voci soul, il ritmo i ... (166 characters truncated) ... are un mood sofisticato o accendere l'energia notturna, è un viaggio sonoro accattivante, progettato per risuonare profondamente con gli ascoltatori.", None, 'pending', '2026-05-12 20:58:46.021420')] +(Background on this error at: https://sqlalche.me/e/20/e3q8) + + +--- Thu May 28 09:50:25 2026 --- +❌ Errore critico nel task higgsfield (Veronica Intorcia): name 'subprocess' is not defined +Traceback (most recent call last): + File "/Users/davidfrassi/SRC/agenti/agenzia/src/background_runner.py", line 136, in + run_higgsfield(args.artist_id, args.prompt, dry_run=args.dry_run) + ~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/davidfrassi/SRC/agenti/agenzia/src/background_runner.py", line 114, in run_higgsfield + res = subprocess.run(cmd, capture_output=True, text=True) + ^^^^^^^^^^ +NameError: name 'subprocess' is not defined. Did you forget to import 'subprocess'? + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4e282fd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + web: + container_name: redazione_web + build: . + volumes: + - .:/app + ports: + - "8501:8501" + env_file: + - .env + command: streamlit run src/app.py --server.port=8501 --server.address=0.0.0.0 + + scheduler: + container_name: redazione_scheduler + build: . + volumes: + - .:/app + - ./videogenerati_test:/app/videogenerati + env_file: + - .env + command: python src/scheduler_service.py diff --git a/erase.sh b/erase.sh new file mode 100644 index 0000000..00cb502 --- /dev/null +++ b/erase.sh @@ -0,0 +1,6 @@ +docker stop $(docker ps -aq) 2>/dev/null; \ +docker rm $(docker ps -aq) 2>/dev/null; \ +docker rmi -f $(docker images -aq) 2>/dev/null; \ +docker network prune -f; \ +docker volume prune -f; \ +docker system prune -a --volumes -f \ No newline at end of file diff --git a/inspect_buffer_audio.py b/inspect_buffer_audio.py new file mode 100644 index 0000000..7d52435 --- /dev/null +++ b/inspect_buffer_audio.py @@ -0,0 +1,31 @@ +import os +import requests +import json +from dotenv import load_dotenv + +load_dotenv() +token = os.getenv("BUFFER_ACCESS_TOKEN") +url = "https://api.buffer.com/graphql" +headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + +query = """ +query { + __type(name: "PostInputMetaData") { + name + inputFields { + name + type { + name + kind + ofType { + name + kind + } + } + } + } +} +""" + +resp = requests.post(url, headers=headers, json={'query': query}) +print(json.dumps(resp.json(), indent=2)) diff --git a/list_soul_ids.sh b/list_soul_ids.sh new file mode 100755 index 0000000..11b2cc0 --- /dev/null +++ b/list_soul_ids.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +# ============================================================================== +# Script: list_soul_ids.sh +# Descrizione: Si collega a Higgsfield AI tramite il token predisposto in .env +# e scarica l'elenco dei Soul ID dei personaggi con il loro nome. +# ============================================================================== + +# Cambia directory sul percorso del progetto +cd "$(dirname "$0")" + +echo "====================================================================" +echo "⚡ Higgsfield Character Discovery Utility" +echo "====================================================================" + +# 1. Carica le variabili dal file .env se presente +if [ -f .env ]; then + # Esporta le variabili escludendo i commenti + export $(grep -v '^#' .env | xargs) + echo "✅ File .env caricato con successo." +else + echo "⚠️ Attenzione: File .env non trovato. Verranno usate le variabili di sistema." +fi + +# 2. Verifica la presenza di Python e dell'ambiente virtuale +if [ -f ".venv/bin/python3" ]; then + PYTHON_EXE=".venv/bin/python3" + echo "✅ Utilizzo dell'ambiente virtuale locale (.venv)." +else + PYTHON_EXE="python3" + echo "ℹ️ Ambiente virtuale locale non trovato. Utilizzo del python di sistema." +fi + +# 3. Avvia lo script di recupero e formattazione +echo "📡 Connessione a Higgsfield AI in corso..." +echo "" + +$PYTHON_EXE -m src.list_higgsfield_characters + +EXIT_CODE=$? +echo "" +if [ $EXIT_CODE -eq 0 ]; then + echo "✅ Ricerca completata con successo." +else + echo "❌ Errore durante il recupero dei dati da Higgsfield." +fi +echo "====================================================================" +exit $EXIT_CODE diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d8d372e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +langchain +langgraph +langchain-google-genai +google-generativeai +streamlit +gdown +python-dotenv +Pillow +sqlalchemy +pydantic +ffmpeg-python +python-magic +python-telegram-bot +apscheduler diff --git a/src/agents.py b/src/agents.py new file mode 100644 index 0000000..654b895 --- /dev/null +++ b/src/agents.py @@ -0,0 +1,343 @@ +import os +import json +from typing import TypedDict, List +from langgraph.graph import StateGraph, END +from audio_analyzer import AudioAnalyzer +from database import Database +from dotenv import load_dotenv +import google.generativeai as genai + +load_dotenv() + +class AgentState(TypedDict): + artist_id: str + artist_name: str + spotify_url: str + distrokid_url: str + audio_analysis: str + image_path: str + image_url: str + video_url: str # NUOVO + image_description: str + climax_start_sec: int # NUOVO + bpm: int # NUOVO + title: str + caption: str + hashtags: str + review_status: str + narrative_style: str + social_tag: str # UNIFICATO + +class SocialAgents: + def __init__(self): + self.db = Database() + self.analyzer = AudioAnalyzer() + genai.configure(api_key=os.getenv("GOOGLE_API_KEY")) + self.model = genai.GenerativeModel('gemini-3-flash-preview') + + def analyze_audio_node(self, state: AgentState): + audio = self.db.get_audio_for_artist(state['artist_id']) + if audio: + path = audio.file_path + if not os.path.exists(path) and audio.source_url: + os.makedirs("data/cache", exist_ok=True) + import gdown + # Forza estensione mp3 se il path non ce l'ha + if not path.endswith(('.mp3', '.wav', '.ogg')): + path += ".mp3" + + print(f"📥 Download audio da Drive: {audio.source_url}...") + url = f"https://drive.google.com/uc?id={audio.source_url}" + gdown.download(url, output=path, quiet=True) + else: + print(f"✅ Audio già in cache: {path}") + + # Specifichiamo il mime_type se necessario per l'analyzer + raw_analysis = self.analyzer.analyze_audio(path) + + # PULIZIA IMMEDIATA per risparmiare spazio + if os.path.exists(path): os.remove(path) + + try: + # Usa regex per estrarre solo il blocco JSON + import re + match = re.search(r'\{.*\}', raw_analysis, re.DOTALL) + if match: + clean_json = match.group(0) + else: + clean_json = raw_analysis.replace("```json", "").replace("```", "").strip() + + data = json.loads(clean_json) + return { + "audio_analysis": data.get("analysis", raw_analysis), + "climax_start_sec": data.get("climax_start_sec", 0), + "bpm": data.get("bpm", 120) + } + except: + return {"audio_analysis": raw_analysis, "climax_start_sec": 0, "bpm": 120} + return {"audio_analysis": "Nessun brano trovato.", "bpm": 120} + + def select_image_node(self, state: AgentState): + image = self.db.get_unused_image(state['artist_id']) + if image: + path = image.file_path + # Se il file non esiste localmente ma abbiamo l'ID Drive, lo scarichiamo in cache + if not os.path.exists(path) and image.source_url: + os.makedirs("data/cache", exist_ok=True) + import gdown + print(f"📥 Scaricando in cache: {path}") + try: + url = f"https://drive.google.com/uc?id={image.source_url}" + gdown.download(url, output=path, quiet=True) + except Exception as e: + return {"caption": None, "error": f"Errore download immagine: {e}"} + else: + print(f"✅ Immagine già in cache: {path}") + + if not os.path.exists(path): + return {"caption": None, "error": f"File non trovato in cache dopo il download: {path}"} + + # Upload file to Gemini + img_data = genai.upload_file(path) + + # Wait for processing + while img_data.state.name == "PROCESSING": + import time + time.sleep(2) + img_data = genai.get_file(img_data.name) + + response = self.model.generate_content(["Descrivi questa immagine per un post social musicale.", img_data]) + + # Segna come usata per non ripeterla mai più + self.db.mark_as_used(image.id) + + # PULIZIA IMMEDIATA per risparmiare spazio + if os.path.exists(path): os.remove(path) + + return {"image_path": path, "image_description": response.text, "image_url": image.source_url} + return {"image_path": "", "image_description": "Nessuna immagine disponibile.", "image_url": None} + + def write_caption_node(self, state: AgentState): + is_video = "video" if state.get("video_url") else "foto" + style_instructions = state.get("narrative_style", "scrivi un post accattivante") + + prompt = f""" + Sei un Social Manager esperto per l'artista {state['artist_name']}. + Il tuo obiettivo è scrivere un post che spacca seguendo queste ISTRUZIONI DI STILE: + --- + {style_instructions} + --- + + REQUISITI AGGIUNTIVI: + - Tag Social: Inserisci il SOCIAL TAG dell'artista all'interno del CORPO del post in modo naturale. + - Connessione: Collega ironicamente quello che si vede nella {is_video} con il testo del brano. + - Conoscenza Nerd: Se le istruzioni lo richiedono, inserisci aneddoti o curiosità plausibili sugli autori originali. + + CONTESTO: + ARTISTA: {state['artist_name']} + SOCIAL TAG: {state.get('social_tag', '')} + ANALISI AUDIO (Brano): {state['audio_analysis']} + DESCRIZIONE VISIVA ({is_video}): {state['image_description']} + LINK DI DESTINAZIONE: {state['distrokid_url']} + + FORMATO RISPOSTA (RESTITUISCI SOLO QUESTO): + TITOLO: [Titolo corto e accattivante] + CORPO: [Testo del post seguendo lo stile richiesto, termina con il link {state['distrokid_url']}] + """ + response = self.model.generate_content(prompt).text + + # Parsing semplice + title = "" + body = "" + if "TITOLO:" in response and "CORPO:" in response: + parts = response.split("CORPO:") + title = parts[0].replace("TITOLO:", "").strip() + body = parts[1].strip() + else: + body = response # fallback + + return {"title": title, "caption": body} + + def generate_hashtags_node(self, state: AgentState): + prompt = f"Genera 15 hashtag per questo post: {state['caption']}. Rispondi SOLO con gli hashtag, senza testo aggiuntivo o consigli." + response = self.model.generate_content(prompt).text + # Rimuove eventuali testi residui fuori dagli hashtag + tags = [word for word in response.split() if word.startswith("#")] + return {"hashtags": " ".join(tags)} + + def build_workflow(self): + workflow = StateGraph(AgentState) + + workflow.add_node("analyze_audio", self.analyze_audio_node) + workflow.add_node("select_image", self.select_image_node) + workflow.add_node("write_caption", self.write_caption_node) + workflow.add_node("generate_hashtags", self.generate_hashtags_node) + workflow.add_node("select_mixed_assets", self.select_mixed_assets_node) + workflow.add_node("generate_video", self.generate_video_node) + + workflow.set_entry_point("analyze_audio") + workflow.add_edge("analyze_audio", "select_mixed_assets") + workflow.add_edge("select_mixed_assets", "write_caption") + workflow.add_edge("write_caption", "generate_video") + workflow.add_edge("generate_video", "generate_hashtags") + workflow.add_edge("generate_hashtags", END) + + return workflow.compile() + def select_mixed_assets_node(self, state: AgentState): + assets = self.db.get_mixed_assets(state['artist_id'], total=12) + if not assets: + return {"image_path": "", "image_description": "Nessun asset disponibile."} + + # 1. Seleziona una copertina significativa (Preferibilmente immagine MAI USATA) + cover = next((a for a in assets if a.file_type == 'image' and not a.is_used), assets[0]) + # Altre immagini + others = [a for a in assets if a.id != cover.id] + sorted_assets = [cover] + others + + # Marcatura immediata della cover per non riusarla + self.db.mark_as_used(cover.id) + + asset_paths = [] + os.makedirs("data/cache", exist_ok=True) + import gdown + + for asset in sorted_assets: + path = asset.file_path + if not os.path.exists(path) and asset.source_url: + try: + url = f"https://drive.google.com/uc?id={asset.source_url}" + gdown.download(url, output=path, quiet=True) + asset_paths.append(path) + except: continue + elif os.path.exists(path): + asset_paths.append(path) + + # 3. AI DESCRIZIONE E FACE DETECTION per Pan-Zoom intelligente + focus_points = [] + desc = "Un montaggio video dinamico." + try: + print(f"👁️ AI: Ricerca volti (Pinpoint) e descrizione per {len(asset_paths)} asset...") + image_files = [] + for p in asset_paths: + if p.lower().endswith(('.jpg', '.jpeg', '.png')): + image_files.append(genai.upload_file(p)) + + if image_files: + # Chiediamo sia la descrizione che le coordinate in un colpo solo + prompt = "Analyze these images for a high-end music video. " \ + "1. For the first image (the cover), give a short poetic description. " \ + "2. For ALL images, identify the EXACT center coordinates (x, y as percentage 0-100) of the main subject's face. " \ + "If multiple people are present, pick the most attractive girl. " \ + "Return ONLY JSON: {'description': '...', 'focus': [{'x': val, 'y': val}, ...]}" + + response = self.model.generate_content(image_files + [prompt]) + clean_json = response.text.replace('```json', '').replace('```', '').strip() + data = json.loads(clean_json) + desc = data.get('description', desc) + focus_points = data.get('focus', []) + print(f"✅ AI: Analisi completata. Focus points trovati: {len(focus_points)}") + except Exception as e: + print(f"⚠️ AI Regia fallita (uso default): {e}") + focus_points = [None] * len(asset_paths) + + return { + "image_path": json.dumps(asset_paths), + "focus_points": json.dumps(focus_points), + "image_description": desc, + "image_url": cover.source_url + } + + def generate_video_node(self, state: AgentState): + from video_generator import VideoGenerator + from gdown import download + + audio = self.db.get_audio_for_artist(state['artist_id']) + if audio and state['image_path']: + vg = VideoGenerator() + audio_path = audio.file_path + if not os.path.exists(audio_path): + download(id=audio.source_url, output=audio_path, quiet=True) + + try: + # Se image_path è una lista JSON (caso video) + try: + image_paths = json.loads(state['image_path']) + except: + image_paths = [state['image_path']] + + print(f"🎬 AVVIO MONTAGGIO VIDEO: {len(image_paths)} asset + {audio_path} (Start: {state['climax_start_sec']}s, BPM: {state.get('bpm', 120)})") + focus_points = json.loads(state.get('focus_points', '[]')) + local_video = vg.generate_video(image_paths, audio_path, state['title'], start_time=state['climax_start_sec'], bpm=state.get('bpm', 120), focus_points=focus_points) + + print(f"🚀 Caricamento video su host temporaneo...") + video_url = vg.upload_to_ephemeral(local_video) + print(f"✅ Video caricato: {video_url}") + + # PULIZIA IMMEDIATA per risparmiare spazio + if os.path.exists(local_video): os.remove(local_video) + # La miniatura locale la teniamo solo se serve a Streamlit, ma qui la cancelliamo + # perché abbiamo detto No-Space e useremo l'URL cloud se possibile. + # In realtà vg.generate_thumbnail crea un .jpg che potremmo voler tenere, + # ma se l'utente vuole zero spazio, cancelliamo tutto. + + return {"video_url": video_url, "video_path": local_video} + except Exception as e: + print(f"❌ ERRORE CRITICO GENERAZIONE VIDEO: {e}") + finally: + # PULIZIA ASSET ORIGINALI (Sempre, anche se fallisce) + try: + image_paths = json.loads(state['image_path']) + for p in image_paths: + if os.path.exists(p): os.remove(p) + except: pass + + return {"video_url": ""} + + def run_for_artist(self, artist_data, mode="both"): + """Esegue i workflow. Mode: 'photo', 'video', 'both'""" + results = [] + + # 1. Analisi Audio (comune) + initial_state = { + "artist_id": artist_data['id'], + "artist_name": artist_data['name'], + "spotify_url": artist_data['spotify_url'], + "distrokid_url": artist_data['distrokid_url'], + "audio_analysis": "", + "image_path": "", + "image_url": "", + "video_url": "", + "image_description": "", + "climax_start_sec": 0, + "bpm": 120, + "title": "", + "caption": "", + "hashtags": "", + "review_status": "pending", + "narrative_style": artist_data.get('narrative_style', ''), + "social_tag": artist_data.get('social_tag', '') + } + + audio_state = self.analyze_audio_node(initial_state) + initial_state.update(audio_state) + + # 2. Generazione BOZZA FOTO + if mode in ["photo", "both"]: + print(f"📸 Avvio Workflow FOTO per {artist_data['name']}...") + photo_state = initial_state.copy() + photo_state.update(self.select_image_node(photo_state)) + photo_state.update(self.write_caption_node(photo_state)) + photo_state.update(self.generate_hashtags_node(photo_state)) + results.append(photo_state) + + # 3. Generazione BOZZA VIDEO + if mode in ["video", "both"]: + print(f"🎬 Avvio Workflow VIDEO per {artist_data['name']}...") + video_state = initial_state.copy() + video_state.update(self.select_mixed_assets_node(video_state)) + video_state.update(self.write_caption_node(video_state)) + video_state.update(self.generate_video_node(video_state)) + video_state.update(self.generate_hashtags_node(video_state)) + results.append(video_state) + + return results diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..c56355d --- /dev/null +++ b/src/app.py @@ -0,0 +1,643 @@ +import streamlit as st +import json +import os +import sys +import subprocess +import glob +import signal +from database import Database +from buffer_publisher import BufferPublisher +from PIL import Image +from dotenv import load_dotenv + +# Carichiamo le variabili d'ambiente (.env) prima di tutto +load_dotenv() + +st.set_page_config(page_title="Redazione Social Multi-Agente", layout="wide") + +# Inizializziamo il database e il publisher +db = Database() +publisher = BufferPublisher() + +# Carichiamo la configurazione +with open('config.json', 'r') as f: + config = json.load(f) + +artist_names = {a['id']: a['name'] for a in config['artists']} +artist_ids = list(artist_names.keys()) + +def load_credits_cache(): + # Valori di default + cache_data = {"total_monthly": 1000, "remaining": 910, "generated_this_month": 90, "last_sync_status": "no_token"} + credits_file = "data/cache/credits.json" + try: + os.makedirs("data/cache", exist_ok=True) + if os.path.exists(credits_file): + with open(credits_file, 'r') as f: + loaded = json.load(f) + if isinstance(loaded, dict): + cache_data.update(loaded) + except: + pass + + # Se c'è HIGGSFIELD_TOKEN nel file .env, facciamo la sincronizzazione in tempo reale online + token = os.getenv("HIGGSFIELD_TOKEN") + if token and token.strip() and token.startswith("eyJ"): + import datetime + try: + from higgsfield_cli.client import HiggsClient + client = HiggsClient(token=token) + wallet = client.get_wallet() + + total = wallet.total_credits + remaining = int(wallet.credits_display) + generated = max(0, total - remaining) + + now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + cache_data.update({ + "total_monthly": total, + "remaining": remaining, + "generated_this_month": generated, + "last_sync_status": "success", + "last_sync_time": now_str, + "sync_error": None + }) + save_credits_cache(cache_data) + except Exception as e: + now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + cache_data.update({ + "last_sync_status": "failed", + "last_sync_time": now_str, + "sync_error": str(e) + }) + # Non sovrascriviamo i valori reali passati in caso di errore di rete/timeout temporaneo, aggiorniamo solo lo stato del sync + save_credits_cache(cache_data) + else: + cache_data["last_sync_status"] = "no_token" + + return cache_data + +def save_credits_cache(data): + try: + os.makedirs("data/cache", exist_ok=True) + credits_file = "data/cache/credits.json" + with open(credits_file, 'w') as f: + json.dump(data, f, indent=2) + except: + pass + +def get_running_tasks(): + """Ritorna la lista dei task attualmente in corso (basandosi sui file .lock)""" + locks = glob.glob("data/tasks/*.lock") + tasks = [] + for lock in locks: + name = os.path.basename(lock).replace(".lock", "") + tasks.append(name) + return tasks + +def create_manual_lock(task_name, artist_id="all"): + """Crea un file di lock manualmente per feedback immediato nella UI""" + os.makedirs("data/tasks", exist_ok=True) + lock_file = f"data/tasks/{task_name}_{artist_id.replace(' ', '_')}.lock" + with open(lock_file, "w") as f: + f.write("starting") + return lock_file + +def manage_task(task_id, action): + """Gestisce un task in background: stop, restart o delete lock""" + lock_path = f"data/tasks/{task_id}.lock" + if not os.path.exists(lock_path): + return + + pid_str = open(lock_path).read().strip() + + # 1. Stop Process if exists + if pid_str.isdigit(): + pid = int(pid_str) + try: + os.kill(pid, signal.SIGTERM) + print(f"Inviato SIGTERM a PID {pid}") + except ProcessLookupError: + pass + except Exception as e: + st.error(f"Errore chiusura processo: {e}") + + # 2. Perform Action + if action == "stop" or action == "remove": + if os.path.exists(lock_path): + os.remove(lock_path) + + if action == "restart": + # Determina i parametri dal task_id (es: generate_Ambra_Manca) + parts = task_id.split("_") + task_type = parts[0] # sync o generate + artist_id = "_".join(parts[1:]) if len(parts) > 1 else "all" + artist_id = artist_id.replace("_", " ") # Ripristina spazi + + if os.path.exists(lock_path): + os.remove(lock_path) + + create_manual_lock(task_type, artist_id) + cmd = [sys.executable, "src/background_runner.py", "--task", task_type] + if artist_id != "all": + cmd += ["--artist_id", artist_id] + subprocess.Popen(cmd) + +# ============================================================ +# SIDEBAR: Selezione Artista + Configurazione +# ============================================================ +st.sidebar.header("🎤 Seleziona Artista") + +selected_artist_idx = st.sidebar.selectbox( + "Artista", + options=range(len(config['artists'])), + format_func=lambda i: config['artists'][i]['name'], + label_visibility="collapsed" +) + +# Rilevamento cambio artista per pulizia stato (Previene ghosting) +if "last_artist_idx" not in st.session_state: + st.session_state.last_artist_idx = selected_artist_idx + +if st.session_state.last_artist_idx != selected_artist_idx: + st.session_state.last_artist_idx = selected_artist_idx + # Pulizia totale per evitare che i checkbox o le foto rimangano in trasparenza + st.session_state.clear() + st.rerun() + +selected_artist = config['artists'][selected_artist_idx] +selected_artist_id = selected_artist['id'] +selected_artist_name = selected_artist['name'] + +st.sidebar.markdown("---") + +# Pannello di Configurazione +with st.sidebar.expander(f"⚙️ Configurazione {selected_artist_name}", expanded=False): + with st.form(key=f"form_edit_{selected_artist_idx}"): + new_name = st.text_input("Nome Artista", selected_artist.get('name', '')) + new_spotify = st.text_input("Spotify URL", selected_artist.get('spotify_url', '')) + new_distrokid = st.text_input("DistroKid URL", selected_artist.get('distrokid_url', '')) + new_time = st.text_input("Orario Pubblicazione (es. 09:00)", selected_artist.get('schedule_time', '')) + new_audio = st.text_input("Cartella Drive Audio (URL)", selected_artist.get('drive_audio_url', '')) + new_img = st.text_input("Cartella Drive Immagini (URL)", selected_artist.get('drive_images_url', '')) + new_vid = st.text_input("Cartella Drive Video (URL)", selected_artist.get('drive_videos_url', '')) + new_buffer_token = st.text_input("Buffer Access Token (Specifico)", selected_artist.get('buffer_token', ''), type="password") + new_style = st.text_area("Stile Narrativo (Istruzioni AI)", selected_artist.get('narrative_style', ""), height=100) + new_social_tag = st.text_input("Social Tag (es. @artista)", selected_artist.get('social_tag', '')) + new_soul_id = st.text_input("Higgsfield Soul ID 2.0", selected_artist.get('soul_id', '')) + new_starting_photos = st.text_input("Cartella Drive Foto Partenza / Modelli (URL)", selected_artist.get('drive_starting_photos_url', '')) + + + if st.form_submit_button("💾 Salva Modifiche"): + config['artists'][selected_artist_idx]['name'] = new_name + config['artists'][selected_artist_idx]['spotify_url'] = new_spotify + config['artists'][selected_artist_idx]['distrokid_url'] = new_distrokid + config['artists'][selected_artist_idx]['schedule_time'] = new_time + config['artists'][selected_artist_idx]['drive_audio_url'] = new_audio + config['artists'][selected_artist_idx]['drive_images_url'] = new_img + config['artists'][selected_artist_idx]['drive_videos_url'] = new_vid + config['artists'][selected_artist_idx]['buffer_token'] = new_buffer_token + config['artists'][selected_artist_idx]['narrative_style'] = new_style + config['artists'][selected_artist_idx]['social_tag'] = new_social_tag + config['artists'][selected_artist_idx]['soul_id'] = new_soul_id + config['artists'][selected_artist_idx]['drive_starting_photos_url'] = new_starting_photos + + + # Rimuovo i vecchi campi se esistenti per pulizia + for old_key in ['instagram_tag', 'tiktok_tag', 'instagram_profile_id', 'tiktok_profile_id']: + config['artists'][selected_artist_idx].pop(old_key, None) + + with open('config.json', 'w') as f: + json.dump(config, f, indent=2) + st.success(f"Configurazione di {new_name} salvata!") + st.rerun() + +st.sidebar.markdown("---") + +running_tasks = get_running_tasks() + +if running_tasks: + st.sidebar.warning("⏳ Task in background:") + for task_id in running_tasks: + col_t, col_stop, col_rel, col_del = st.sidebar.columns([4, 1, 1, 1]) + col_t.caption(f"• {task_id.replace('_', ' ')}") + + if col_stop.button("🛑", key=f"stop_{task_id}", help="Ferma"): + manage_task(task_id, "stop") + st.rerun() + if col_rel.button("🔄", key=f"rel_{task_id}", help="Riavvia"): + manage_task(task_id, "restart") + st.rerun() + if col_del.button("🗑️", key=f"del_{task_id}", help="Rimuovi Lock"): + manage_task(task_id, "remove") + st.rerun() + +if st.sidebar.button("🔄 Sincronizza Asset da Drive"): + if any(t.startswith("sync") for t in running_tasks): + st.sidebar.error("Sincronizzazione già in corso!") + else: + create_manual_lock("sync") + subprocess.Popen([sys.executable, "src/background_runner.py", "--task", "sync"]) + st.sidebar.success("✅ Sincronizzazione avviata in background!") + st.rerun() + +if st.sidebar.button("🗑️ Svuota Cache Locale"): + import shutil + try: + shutil.rmtree("data/cache") + os.makedirs("data/cache", exist_ok=True) + st.sidebar.success("✅ Cache svuotata con successo!") + st.session_state.clear() + st.rerun() + except Exception as e: + st.sidebar.error(f"Errore durante la pulizia: {e}") + +# ============================================================ +# AREA PRINCIPALE: Generazione e Gestione Bozze +# ============================================================ +with st.container(key=f"main_content_{selected_artist_id}"): + st.title(f"🚀 Redazione: {selected_artist_name}") + + # Sezione Generazione Rapida + st.subheader("✨ Nuova Generazione") + col_gen1, col_gen2 = st.columns(2) + + gen_task_id_photo = f"generate_{selected_artist_id.replace(' ', '_')}_photo" + gen_task_id_video = f"generate_{selected_artist_id.replace(' ', '_')}_video" + + # 1. Post Immagine (Leggero) + if gen_task_id_photo in running_tasks: + col_gen1.info("📸 Post Foto in corso...") + elif col_gen1.button("📸 Post Immagine (Solo 1 Foto)", use_container_width=True, key=f"btn_p_{selected_artist_id}"): + create_manual_lock("generate", f"{selected_artist_id}_photo") + subprocess.Popen([sys.executable, "src/background_runner.py", "--task", "generate", "--artist_id", selected_artist_id, "--mode", "photo"]) + st.rerun() + + # 2. Video Mix (Più pesante) + if gen_task_id_video in running_tasks: + col_gen2.info("🎬 Video Mix in corso...") + elif col_gen2.button("🎬 Video Mix (12 Asset)", use_container_width=True, key=f"btn_v_{selected_artist_id}"): + create_manual_lock("generate", f"{selected_artist_id}_video") + subprocess.Popen([sys.executable, "src/background_runner.py", "--task", "generate", "--artist_id", selected_artist_id, "--mode", "video"]) + st.rerun() + + st.markdown("---") + st.subheader("🎨 Generazione Foto Higgsfield AI (Soul ID)") + + gen_task_id_higgsfield = f"higgsfield_{selected_artist_id.replace(' ', '_')}_both" + + col_h1, col_h2 = st.columns([3, 1]) + hf_prompt = col_h1.text_input("Prompt Stile Estetico", "A beautiful editorial studio portrait, highly detailed, cinematic studio lighting, professional photography, 8k resolution, crisp details", key=f"hf_p_{selected_artist_id}") + hf_dry_run = col_h2.checkbox("Simulazione (Dry-run)", value=False, key=f"hf_dr_{selected_artist_id}") + + if gen_task_id_higgsfield in running_tasks: + st.info("🎨 Generazione Higgsfield AI in corso in background...") + else: + if st.button("🚀 Avvia Generazione Higgsfield AI per tutti i modelli", use_container_width=True, type="primary", key=f"btn_hf_{selected_artist_id}"): + # Check parameters + m_url = selected_artist.get("drive_starting_photos_url") + if not m_url: + st.error("Errore: Assicurati di aver configurato la 'Cartella Foto Partenza' nella barra laterale!") + else: + create_manual_lock("higgsfield", f"{selected_artist_id}_both") + cmd = [ + sys.executable, "src/background_runner.py", + "--task", "higgsfield", + "--artist_id", selected_artist_id, + "--prompt", hf_prompt + ] + if hf_dry_run: + cmd.append("--dry_run") + subprocess.Popen(cmd) + st.success("✅ Generazione Higgsfield avviata in background!") + st.rerun() + + st.markdown("---") + +# ============================================================ +# BOZZE PENDENTI (filtrate per artista selezionato) +# ============================================================ +st.subheader("📝 Bozze Pendenti") +drafts = db.get_pending_drafts(selected_artist_id) + +if not drafts: + st.info(f"Nessuna bozza per {selected_artist_name}. Clicca su 'Genera Nuovo Post' per iniziare.") +else: + # Callback per seleziona/deseleziona tutto + if "current_draft_ids" not in st.session_state: + st.session_state.current_draft_ids = [] + st.session_state.current_draft_ids = [d.id for d in drafts] + + def toggle_all_drafts(): + val = st.session_state.select_all_drafts_chk + for d_id in st.session_state.current_draft_ids: + st.session_state[f"sel_{d_id}"] = val + + # Pre-calcolo delle bozze selezionate per evitare NameError nelle azioni bulk + selected_drafts = [d for d in drafts if st.session_state.get(f"sel_{d.id}", False)] + + # Azioni di Gruppo + with st.expander("🛠️ Azioni di Gruppo", expanded=False): + col_btn1, col_btn2 = st.columns(2) + if col_btn1.button("🚀 PUBBLICA TUTTI I SELEZIONATI"): + if selected_drafts: + with st.spinner(f"Pubblicazione di {len(selected_drafts)} post..."): + for d in selected_drafts: + t_val = st.session_state.get(f"t_{d.id}", d.title) + c_val = st.session_state.get(f"c_{d.id}", d.caption) + h_val = st.session_state.get(f"h_{d.id}", d.hashtags) + db.update_draft(d.id, title=t_val, caption=c_val, hashtags=h_val) + full_text = f"*{t_val}*\n\n{c_val}\n\n{h_val}" + + # Recupera dati specifici artista (Token e Profile IDs) + artist_data = next((a for a in config['artists'] if a['id'] == d.artist_id), None) + artist_token = artist_data.get("buffer_token") if artist_data else None + + # Passiamo profile_ids vuoti per attivare l'auto-discovery nel publisher + profile_ids = [] + publisher.publish(profile_ids, full_text, d.image_path, d.video_url, d.image_paths, artist_token=artist_token) + db.mark_as_published(d.id) + st.success(f"Pubblicati {len(selected_drafts)} post!") + st.rerun() + + if col_btn2.button("🗑️ ELIMINA TUTTI I SELEZIONATI"): + if selected_drafts: + for d in selected_drafts: + db.delete_draft(d.id) + st.warning(f"Eliminati {len(selected_drafts)} post.") + st.rerun() + + st.checkbox("Seleziona Tutte le Bozze", key="select_all_drafts_chk", on_change=toggle_all_drafts) + + for draft in drafts: + with st.expander(f"📌 {draft.title or 'Bozza senza titolo'}", expanded=len(drafts) == 1): + c1, c2 = st.columns([1, 2]) + + with c1: + st.checkbox("Seleziona per Azione di Gruppo", key=f"sel_{draft.id}") + + # ANTEPRIMA VIDEO (Priorità assoluta se esiste) + if draft.video_url: + st.video(draft.video_url) + st.success("🎬 Video 'CapCut-style' generato!") + + # Anteprima Immagine (Metodo Base64 Infallibile) + display_url = draft.image_url + if not display_url and draft.image_path: + m_rec = db.get_media_by_path(draft.image_path) + if m_rec: display_url = m_rec.source_url + + if display_url: + try: + import requests, base64 + # Usiamo un URL di Drive che restituisce direttamente l'immagine + thumb_url = f"https://drive.google.com/thumbnail?id={display_url}&sz=w600" + response = requests.get(thumb_url, timeout=5) + if response.status_code == 200: + b64_img = base64.b64encode(response.content).decode() + st.markdown(f'', unsafe_allow_html=True) + else: + # Fallback se il server non raggiunge Google + st.markdown(f'', unsafe_allow_html=True) + except: + st.warning("🖼️ Errore caricamento (Cloud)") + elif draft.image_path and os.path.exists(draft.image_path): + try: + import base64 + with open(draft.image_path, "rb") as img_file: + b64_img = base64.b64encode(img_file.read()).decode() + st.markdown(f'', unsafe_allow_html=True) + except Exception as e: + st.image(draft.image_path) + elif draft.image_paths: + + try: + paths = json.loads(draft.image_paths) + m_rec = db.get_media_by_path(paths[0]) + if m_rec and m_rec.source_url: + img_url = f"https://lh3.googleusercontent.com/u/0/d/{m_rec.source_url}=w600" + st.markdown(f'', unsafe_allow_html=True) + except: pass + elif not draft.video_url: + st.warning("⚠️ Anteprima non disponibile") + + with c2: + edited_title = st.text_input("Titolo", draft.title, key=f"t_{draft.id}") + edited_caption = st.text_area("Testo", draft.caption, height=150, key=f"c_{draft.id}") + edited_tags = st.text_input("Hashtag", draft.hashtags, key=f"h_{draft.id}") + + if draft.audio_analysis: + with st.expander("🎵 Pitch per Playlist Spotify (Generato dall'AI)", expanded=True): + st.info(draft.audio_analysis) + + if st.button("🤖 Autocompleta Testi e Pitch con AI (Ascolta Audio)", key=f"ai_{draft.id}", use_container_width=True): + with st.spinner("Ascolto audio e generazione testi in corso..."): + from agents import SocialAgents + agents = SocialAgents() + audio = db.get_audio_for_artist(draft.artist_id) + if not audio: + st.error("Nessun audio trovato per questo artista.") + else: + state = {"artist_id": draft.artist_id, "artist_name": artist_names.get(draft.artist_id), "distrokid_url": ""} + if artist_data: + state["distrokid_url"] = artist_data.get("distrokid_url", "") + state["narrative_style"] = artist_data.get("narrative_style", "") + state["social_tag"] = artist_data.get("social_tag", "") + + analysis_res = agents.analyze_audio_node(state) + state.update(analysis_res) + state["image_description"] = "Un bellissimo post per i social." + + cap_res = agents.write_caption_node(state) + state.update(cap_res) + tag_res = agents.generate_hashtags_node(state) + + db.update_draft( + draft.id, + title=cap_res.get("title", ""), + caption=cap_res.get("caption", ""), + hashtags=tag_res.get("hashtags", ""), + audio_analysis=analysis_res.get("audio_analysis", "") + ) + st.rerun() + + if st.button(f"✅ Salva Modifiche e Pubblica", key=f"pub_{draft.id}", type="primary", use_container_width=True): + db.update_draft(draft.id, title=edited_title, caption=edited_caption, hashtags=edited_tags) + full_text = f"*{edited_title}*\n\n{edited_caption}\n\n{edited_tags}" + + # Recupera dati specifici artista (Token e Profile IDs) + artist_data = next((a for a in config['artists'] if a['id'] == draft.artist_id), None) + artist_token = artist_data.get("buffer_token") if artist_data else None + + # Passiamo profile_ids vuoti per attivare l'auto-discovery nel publisher + profile_ids = [] + res = publisher.publish(profile_ids, full_text, draft.image_path, draft.video_url, draft.image_paths, artist_token=artist_token) + if res['success']: + db.mark_as_published(draft.id) + st.success("Inviato a Buffer!") + st.rerun() + else: + st.error(f"Errore: {res['error']}") + if st.button("🗑️ Elimina Bozza", key=f"del_single_{draft.id}", type="secondary", use_container_width=True): + db.delete_draft(draft.id) + st.rerun() + + # Sezione Sostituzione Media / Generazione Video Manuale + st.markdown("---") + st.write("📸 **Sostituisci Media / Genera Video (Opzionale)**") + st.caption("Seleziona una o più foto/video recenti per generare un video animato.") + + recent_media = db.get_recent_media(draft.artist_id, limit=10) + selected_images = [] + cols_img = st.columns(5) + for i, media in enumerate(recent_media): + with cols_img[i % 5]: + img_to_show = None + if os.path.exists(media.file_path): + img_to_show = media.file_path + elif media.source_url: + # MINIATURA DIRETTA DA GOOGLE DRIVE (Metodo più robusto) + img_to_show = f"https://drive.google.com/thumbnail?id={media.source_url}&sz=w400" + + if img_to_show: + if media.file_type == 'video': + st.markdown(f"🎥 **Video**") + + try: + # TENTATIVO CLOUD-FIRST (Lookup ID da database) + m_id = media.source_url + if not m_id: + m_rec = db.get_media_by_path(media.file_path) + if m_rec: m_id = m_rec.source_url + + if m_id: + try: + import requests, base64 + thumb_url = f"https://drive.google.com/thumbnail?id={m_id}&sz=w200" + response = requests.get(thumb_url, timeout=3) + if response.status_code == 200: + b64_img = base64.b64encode(response.content).decode() + st.markdown(f'', unsafe_allow_html=True) + else: + st.markdown(f'', unsafe_allow_html=True) + except: + st.caption("🖼️ Cloud N/A") + else: + st.caption("🖼️ No ID") + except Exception: + st.caption("🖼️ Error") + + if st.checkbox("Scegli", key=f"chk_m_{draft.id}_{media.id}", label_visibility="collapsed"): + selected_images.append(media.file_path) + + if st.button("🎬 Genera Video Animato (CapCut style) con i media selezionati", key=f"gen_{draft.id}"): + if not selected_images: + st.error("Seleziona almeno un media!") + else: + with st.spinner("Generazione video in corso..."): + # Sincronizza/Scarica media + final_local_media = [] + import requests + for img_p in selected_images: + if not os.path.exists(img_p): + m_rec = db.get_media_by_path(img_p) + if m_rec and m_rec.source_url: + try: + dl_url = f"https://drive.google.com/uc?id={m_rec.source_url}" + resp = requests.get(dl_url, timeout=15) + if resp.status_code == 200: + os.makedirs(os.path.dirname(img_p), exist_ok=True) + with open(img_p, 'wb') as f: + f.write(resp.content) + final_local_media.append(img_p) + except: pass + else: + final_local_media.append(img_p) + + from video_generator import VideoGenerator + vg = VideoGenerator() + audio = db.get_audio_for_artist(draft.artist_id) + if not audio: + st.error("Nessun file audio trovato per questo artista!") + else: + audio_path = audio.file_path + if not os.path.exists(audio_path) and audio.source_url: + try: + dl_url = f"https://drive.google.com/uc?id={audio.source_url}" + resp = requests.get(dl_url, timeout=30) + if resp.status_code == 200: + os.makedirs(os.path.dirname(audio_path), exist_ok=True) + with open(audio_path, 'wb') as f: + f.write(resp.content) + except: pass + + try: + # Percorso ASSOLUTO interno a Docker per garantire il sync con il volume + out_base = "/app/data/outputs" + os.makedirs(out_base, exist_ok=True) + + safe_artist = draft.artist_id.replace(" ", "") + out_path = f"{out_base}/{safe_artist}_{draft.id}.mp4" + + vid_path = vg.generate_video(final_local_media, audio_path, edited_title, start_time=0, output_path=out_path) + thumb_path = vg.generate_thumbnail(vid_path) + video_url = vg.upload_to_ephemeral(vid_path) + if not video_url: + st.warning("Upload remoto fallito. Il video è disponibile solo localmente.") + db.update_draft(draft.id, video_url=video_url, video_path=vid_path) + st.rerun() + except Exception as e: + st.error(f"Errore generazione video: {e}") + +# ============================================================ +# STATO CONSUMO CREDITI HIGGSFIELD AI (In basso alla console) +# ============================================================ +st.markdown("---") +st.subheader("📊 Stato Consumo Crediti Higgsfield AI") + +credits_data = load_credits_cache() +total = credits_data.get("total_monthly", 1000) +remaining = credits_data.get("remaining", 1000) +consumed = total - remaining + +# Visualizzazione badge di stato del sync online +sync_status = credits_data.get("last_sync_status", "no_token") +sync_time = credits_data.get("last_sync_time", "Mai") +sync_err = credits_data.get("sync_error", "") + +if sync_status == "success": + st.markdown(f"🟢 SINCRONIZZATO ONLINE   Ultimo aggiornamento automatico: {sync_time}", unsafe_allow_html=True) +elif sync_status == "failed": + st.markdown(f"🔴 ERRORE DI CONNESSE   Impossibile allineare i dati online ({sync_err}). Utilizzo cache del {sync_time}", unsafe_allow_html=True) +else: + st.markdown("🟡 CONTATORE LOCALE (CACHE)   Token .env non configurato", unsafe_allow_html=True) + st.info("💡 **Allineamento Online in Tempo Reale**: Per allineare automaticamente il consumo effettivo con il tuo account Higgsfield AI (Pro Plan) senza usare Chrome Safe Storage, aggiungi la variabile `HIGGSFIELD_TOKEN=eyJ...` nel file `.env` (copiabile dal cookie `__session` della console browser di higgsfield.ai).") + +# Calculate percentage +pct_remaining = (remaining / total * 100) if total > 0 else 0.0 + +# Render elegant columns +col_c1, col_c2, col_c3 = st.columns(3) +col_c1.metric("Crediti Rimanenti", f"{remaining} crediti", f"{pct_remaining:.1f}% rimasti", delta_color="normal") +col_c2.metric("Crediti Consumati", f"{consumed} crediti", f"{100 - pct_remaining:.1f}% consumati", delta_color="inverse") +col_c3.metric("Budget Mensile", f"{total} crediti", help="Questo valore è allineato in tempo reale con il tuo abbonamento online") + +# Beautiful, customized progress bar matching Higgsfield's neon lime styling +progress_html = f""" +
+ ⚡ {pct_remaining:.0f}% credits left +
+
+
+
+
+""" +st.markdown(progress_html, unsafe_allow_html=True) + +# ============================================================ +# AUTO-REFRESH (Solo se ci sono task in corso) +# ============================================================ +if running_tasks: + import time + time.sleep(5) + st.rerun() diff --git a/src/audio_analyzer.py b/src/audio_analyzer.py new file mode 100644 index 0000000..ce261d9 --- /dev/null +++ b/src/audio_analyzer.py @@ -0,0 +1,54 @@ +import os +import google.generativeai as genai +from dotenv import load_dotenv + +load_dotenv() + +class AudioAnalyzer: + def __init__(self, api_key=None): + self.api_key = api_key or os.getenv("GOOGLE_API_KEY") + if self.api_key: + genai.configure(api_key=self.api_key) + self.model = genai.GenerativeModel('gemini-2.5-flash') + else: + self.model = None + + def analyze_audio(self, file_path): + if not self.model: + return "API Key non configurata." + + if not os.path.exists(file_path): + return f"File non trovato: {file_path}" + + try: + # Rilevamento mime-type per evitare errori + mime_type = "audio/mpeg" + if file_path.endswith(".wav"): mime_type = "audio/wav" + elif file_path.endswith(".ogg"): mime_type = "audio/ogg" + + # Upload file to Gemini con mime_type esplicito + print(f"🎵 Caricamento audio su Gemini: {file_path}...") + audio_file = genai.upload_file(path=file_path, mime_type=mime_type) + + # Wait for processing + while audio_file.state.name == "PROCESSING": + import time + print("...elaborazione audio in corso su Gemini...") + time.sleep(2) + audio_file = genai.get_file(audio_file.name) + + print("🧠 Analisi audio in corso con AI...") + + prompt = """ + Analizza questo brano musicale e rispondi in formato JSON con queste chiavi: + 1. "climax_start_sec": (intero) Il secondo esatto in cui inizia la parte più energica o il ritornello (es. 45). ASSICURATI che in questo punto l'audio sia chiaramente udibile (evita silenzi iniziali o intro troppo lunghe). + 2. "bpm": (intero) I battiti per minuto del brano (es. 128). Se non sei sicuro, fornisci una stima accurata basata sul ritmo. + 3. "analysis": Scrivi un "Pitch" accattivante e professionale (max 3-4 frasi) pronto da inviare ai curatori delle playlist di Spotify, mettendo in luce il genere, il mood, le vibes e i punti di forza del brano. + + Rispondi SOLO ed ESCLUSIVAMENTE con il JSON puro. Non includere blocchi markdown come ```json o ```, inizia direttamente con { e finisci con }. + """ + + response = self.model.generate_content([prompt, audio_file]) + return response.text + except Exception as e: + return f"Errore durante l'analisi audio: {str(e)}" diff --git a/src/background_runner.py b/src/background_runner.py new file mode 100644 index 0000000..d21a880 --- /dev/null +++ b/src/background_runner.py @@ -0,0 +1,146 @@ +import os +import sys +import json +import time +import argparse +from database import Database +from agents import SocialAgents + +def create_lock(task_name, artist_id="all"): + os.makedirs("data/tasks", exist_ok=True) + lock_file = f"data/tasks/{task_name}_{artist_id.replace(' ', '_')}.lock" + with open(lock_file, "w") as f: + f.write(str(os.getpid())) + return lock_file + +def remove_lock(lock_file): + if os.path.exists(lock_file): + os.remove(lock_file) + +def run_sync(): + print("🚀 Avvio Sincronizzazione in background...") + import cloud_sync + cloud_sync.sync_from_drive() + print("✅ Sincronizzazione completata.") + +def run_generate(artist_id, mode="both"): + db = Database() + agents = SocialAgents() + + with open('config.json', 'r') as f: + config = json.load(f) + artist = next((a for a in config['artists'] if a['id'] == artist_id), None) + + if not artist: + print(f"❌ Artista non trovato: {artist_id}") + return + + print(f"🤖 Avvio generazione ({mode}) per {artist['name']}...") + # Sincronizzazione preventiva (leggera) + import cloud_sync + cloud_sync.sync_from_drive() + + results = agents.run_for_artist(artist, mode=mode) + for res in results: + if 'error' not in res: + db.save_draft( + artist_id=artist_id, + title=res.get('title', 'Nuovo Post'), + caption=res.get('caption', ''), + hashtags=res.get('hashtags', ''), + image_path=res.get('image_path'), + image_url=res.get('image_url'), + video_url=res.get('video_url'), + audio_analysis=res.get('audio_analysis'), + video_path=res.get('video_path'), + focus_points=res.get('focus_points') + ) + print(f"✅ Generazione completata per {artist['name']}.") + +def run_higgsfield(artist_id, prompt, dry_run=False): + print(f"🎨 Avvio task Higgsfield per {artist_id}...") + with open('config.json', 'r') as f: + config = json.load(f) + artist = next((a for a in config['artists'] if a['id'] == artist_id), None) + + if not artist: + print(f"❌ Artista non trovato: {artist_id}") + return + + models_url = artist.get('drive_starting_photos_url') + if not models_url: + print(f"❌ Nessuna cartella Drive Foto Partenza configurata per {artist_id}") + return + + soul_id = artist.get('soul_id') + if not soul_id: + print(f"❌ Nessun Higgsfield Soul ID configurato per {artist_id}") + return + + # 1. Download starting photos from Drive + import cloud_sync + import gdown + + fid = cloud_sync.get_drive_id(models_url) + local_dir = f"data/starting_photos/{artist_id.replace(' ', '_')}" + os.makedirs(local_dir, exist_ok=True) + + print(f"📥 Controllo cartella Drive per modelli/foto partenza ({fid})...") + files = cloud_sync.list_files_in_public_folder(fid) + if not files: + print(f"⚠️ Nessun file trovato nella cartella Drive: {models_url}") + + for fid_sub, fname in files: + if fname.lower().endswith(('.jpg', '.jpeg', '.png', '.webp')): + local_path = f"{local_dir}/{fname}" + if not os.path.exists(local_path): + print(f"📥 Scaricamento foto partenza: {fname}...") + url = f"https://drive.google.com/uc?id={fid_sub}" + try: + gdown.download(url, output=local_path, quiet=True) + except Exception as dl_err: + print(f"⚠️ Errore download {fname}: {dl_err}") + + # 2. Run generate_higgsfield_photos.py via subprocess + cmd = [ + sys.executable, "-m", "src.generate_higgsfield_photos", + "--dir", local_dir, + "--prompt", prompt + ] + if dry_run: + cmd.append("--dry-run") + + print(f"🚀 Esecuzione script generatore: {' '.join(cmd)}") + res = subprocess.run(cmd, capture_output=True, text=True) + print(res.stdout) + if res.stderr: + print(f"⚠️ Stderr: {res.stderr}") + print("✅ Task Higgsfield completato.") + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--task", choices=["sync", "generate", "higgsfield"], required=True) + parser.add_argument("--artist_id", default="all") + parser.add_argument("--mode", choices=["photo", "video", "both"], default="both") + parser.add_argument("--prompt", default="A beautiful editorial studio portrait, highly detailed, cinematic studio lighting, professional photography, 8k resolution, crisp details") + parser.add_argument("--dry_run", action="store_true") + args = parser.parse_args() + + lock = create_lock(args.task, f"{args.artist_id}_{args.mode}") + try: + if args.task == "sync": + run_sync() + elif args.task == "generate": + run_generate(args.artist_id, mode=args.mode) + elif args.task == "higgsfield": + run_higgsfield(args.artist_id, args.prompt, dry_run=args.dry_run) + except Exception as e: + import traceback + error_msg = f"❌ Errore critico nel task {args.task} ({args.artist_id}): {str(e)}\n{traceback.format_exc()}" + print(error_msg) + os.makedirs("data/tasks", exist_ok=True) + with open("data/tasks/error.log", "a") as f: + f.write(f"\n--- {time.ctime()} ---\n{error_msg}\n") + finally: + remove_lock(lock) + diff --git a/src/buffer_publisher.py b/src/buffer_publisher.py new file mode 100644 index 0000000..ac6b6ce --- /dev/null +++ b/src/buffer_publisher.py @@ -0,0 +1,153 @@ +import os +import requests +import json +from datetime import datetime +from dotenv import load_dotenv + +load_dotenv() + +class BufferPublisher: + def __init__(self, token=None): + self.token = token or os.getenv("BUFFER_ACCESS_TOKEN") + self.graphql_url = "https://api.buffer.com/graphql" + self.log_file = "data/buffer_logs.txt" + + def log(self, message): + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + os.makedirs("data", exist_ok=True) + with open(self.log_file, "a") as f: + f.write(f"[{timestamp}] {message}\n") + + def get_profiles(self): + query = """ + query { + profiles { + id + type + service + } + } + """ + headers = { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json" + } + try: + response = requests.post(self.graphql_url, headers=headers, json={'query': query}) + if response.status_code == 200: + data = response.json() + profiles = data.get('data', {}).get('profiles', []) + # Filtriamo per i servizi che ci interessano (Instagram e TikTok) + return [p['id'] for p in profiles if p['service'] in ['instagram', 'tiktok']] + else: + self.log(f"ERRORE recupero profili: {response.text}") + return [] + except Exception as e: + self.log(f"ECCEZIONE recupero profili: {e}") + return [] + + def publish(self, profile_ids, text, image_path, video_url=None, image_paths=None, artist_token=None): + # Garantisce l'uso del token specifico o il fallback al globale per ogni singola chiamata + self.token = artist_token or os.getenv("BUFFER_ACCESS_TOKEN") + + # Se non ci sono profile_ids, li cerchiamo automaticamente + if not profile_ids: + self.log("Nessun Profile ID fornito, avvio auto-discovery...") + profile_ids = self.get_profiles() + if not profile_ids: + return {"success": False, "error": "Nessun canale social trovato per questo account Buffer."} + self.log(f"Canali trovati automaticamente: {len(profile_ids)}") + + return self.publish_via_graphql(profile_ids, text, image_path, video_url, image_paths) + + def publish_via_graphql(self, profile_ids, text, image_path, video_url=None, image_paths=None): + from database import Database, Media + db = Database() + session = db.Session() + + # Prepariamo gli asset: se c'è un video, ha la precedenza + assets_payload = None + if video_url: + assets_payload = {"videos": [{"url": video_url}]} + elif image_paths: + # Gestione Carousel (lista di percorsi locali) + if isinstance(image_paths, str): + try: image_paths = json.loads(image_paths) + except: image_paths = [image_paths] + + image_urls = [] + for path in image_paths: + media = session.query(Media).filter_by(file_path=path).first() + if media and media.source_url: + image_urls.append({"url": f"https://lh3.googleusercontent.com/d/{media.source_url}=s1080"}) + + if image_urls: + assets_payload = {"images": image_urls} + else: + media = session.query(Media).filter_by(file_path=image_path).first() + if media and media.source_url: + direct_link = f"https://lh3.googleusercontent.com/d/{media.source_url}=s1080" + assets_payload = {"images": [{"url": direct_link}]} + + results = [] + for channel_id in profile_ids: + mutation = """ + mutation ($input: CreatePostInput!) { + createPost(input: $input) { + ... on PostActionSuccess { post { id } } + ... on MutationError { message } + } + } + """ + + # Input base per la PUBBLICAZIONE DIRETTA + post_input = { + "channelId": channel_id, + "text": text, + "schedulingType": "automatic", # Questo campo è obbligatorio per l'API anche se condividiamo subito + "mode": "shareNow" # Ignora la coda e pubblica ISTANTANEAMENTE + } + + if assets_payload: + post_input["assets"] = assets_payload + + metadata = {} + if channel_id == "69f9ea855c4c051afa117baa": # Instagram + metadata["instagram"] = { + "type": "post", + "shouldShareToFeed": True # CAMPO MANCANTE RIPRISTINATO + } + elif channel_id == "69f9e7e35c4c051afa116a9e": # TikTok + metadata["tiktok"] = {"title": text[:50]} + + if metadata: + post_input["metadata"] = metadata + + headers = { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json" + } + + self.log(f"INVIO POST a Canale: {channel_id}") + response = requests.post(self.graphql_url, headers=headers, json={ + 'query': mutation, + 'variables': {"input": post_input} + }) + + if response.status_code == 200: + data = response.json() + res = data.get('data', {}).get('createPost', {}) + self.log(f"RISPOSTA BUFFER: {json.dumps(res)}") + + if 'message' in res: + results.append({"success": False, "error": res['message']}) + else: + results.append({"success": True, "id": res.get('post', {}).get('id')}) + else: + self.log(f"ERRORE HTTP {response.status_code}: {response.text}") + results.append({"success": False, "error": response.text}) + + session.close() + if any(r['success'] for r in results): + return {"success": True, "details": results} + return {"success": False, "error": results[0].get('error', 'Errore')} diff --git a/src/cloud_sync.py b/src/cloud_sync.py new file mode 100644 index 0000000..c587a98 --- /dev/null +++ b/src/cloud_sync.py @@ -0,0 +1,120 @@ +import os +import json +import requests +import re +import subprocess +from database import Database + +db = Database() + +def get_drive_id(url): + match = re.search(r'/file/d/([a-zA-Z0-9_-]+)', url) + if match: return match.group(1) + match = re.search(r'id=([a-zA-Z0-9_-]+)', url) + if match: return match.group(1) + match = re.search(r'folders/([a-zA-Z0-9_-]+)', url) + if match: return match.group(1) + return None + +def list_files_in_public_folder(folder_id): + url = f"https://drive.google.com/embeddedfolderview?id={folder_id}" + files_found = [] + try: + response = requests.get(url, timeout=15) + if response.status_code == 200: + entries = re.findall(r'id="entry-([a-zA-Z0-9_-]+)".*?class="flip-entry-title">([^<]+)', response.text, re.DOTALL) + for fid, fname in entries: + if fid not in [f[0] for f in files_found]: + files_found.append((fid, fname)) + except Exception as e: + print(f"Errore Drive: {e}") + return files_found + +def generate_video_thumbnail(video_fid, output_path): + """Genera una miniatura da un video di Google Drive usando FFmpeg""" + if os.path.exists(output_path): + print(f"⏩ Miniatura già esistente: {output_path}") + return True + + print(f"🎬 Generazione miniatura per FID: {video_fid}...") + # URL di download diretto + url = f"https://drive.google.com/uc?id={video_fid}&export=download" + + # Comando FFmpeg per estrarre un frame a 1 secondo + # Usiamo parametri per velocizzare l'apertura dello stream + cmd = [ + 'ffmpeg', '-ss', '00:00:01', '-i', url, + '-frames:v', '1', '-q:v', '2', + '-vf', 'scale=320:-1', + output_path, '-y' + ] + + try: + # Timeout per evitare blocchi infiniti su file troppo grandi o link protetti + res = subprocess.run(cmd, capture_output=True, timeout=25) + if res.returncode != 0: + print(f"⚠️ FFmpeg ha restituito un errore (probabile file protetto o troppo grande): {video_fid}") + return False + return os.path.exists(output_path) + except subprocess.TimeoutExpired: + print(f"⏳ Timeout FFmpeg per {video_fid} - saltato.") + return False + except Exception as e: + print(f"❌ Errore inaspettato thumbnail: {e}") + return False + +def sync_from_drive(): + if not os.path.exists('config.json'): return + + # Cartella cache unica + cache_dir = "data/cache" + os.makedirs(cache_dir, exist_ok=True) + + with open('config.json', 'r') as f: + config = json.load(f) + + for artist in config['artists']: + artist_id = artist['id'] + + # Audio Discovery + audio_url = artist.get('drive_audio_url') + if audio_url: + fid = get_drive_id(audio_url) + if "/file/d/" in audio_url: + db.add_media(f"{cache_dir}/audio_{fid}", 'audio', artist_id, source_url=fid) + elif fid: + for fid_sub, fname in list_files_in_public_folder(fid): + db.add_media(f"{cache_dir}/{fname}", 'audio', artist_id, source_url=fid_sub) + + # Images Discovery + images_url = artist.get('drive_images_url') + if images_url: + fid = get_drive_id(images_url) + if "/file/d/" in images_url: + db.add_media(f"{cache_dir}/image_{fid}", 'image', artist_id, source_url=fid) + elif fid: + for fid_sub, fname in list_files_in_public_folder(fid): + db.add_media(f"{cache_dir}/{fname}", 'image', artist_id, source_url=fid_sub) + + # Videos Discovery + videos_url = artist.get('drive_videos_url') + if videos_url: + fid = get_drive_id(videos_url) + if not fid: + print(f"⚠️ URL Video non valido per {artist_id}: {videos_url}") + elif "/file/d/" in videos_url: + # Nessuna miniatura locale per risparmiare spazio + db.add_media(f"{cache_dir}/video_{fid}", 'video', artist_id, source_url=fid) + else: + v_files = list_files_in_public_folder(fid) + if not v_files: + print(f"ℹ️ Nessun video trovato nella cartella di {artist_id}") + for fid_sub, fname in v_files: + # Registriamo solo il metadata, la miniatura sarà caricata 'on-the-fly' dalla UI + db.add_media(f"{cache_dir}/{fname}", 'video', artist_id, source_url=fid_sub) + else: + print(f"ℹ️ Nessuna cartella video configurata per {artist_id}") + +if __name__ == "__main__": + sync_from_drive() + print("Sincro Cache completata.") diff --git a/src/database.py b/src/database.py new file mode 100644 index 0000000..f7beb0d --- /dev/null +++ b/src/database.py @@ -0,0 +1,239 @@ +import datetime +from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, Text +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +Base = declarative_base() + +class Media(Base): + __tablename__ = 'media' + + id = Column(Integer, primary_key=True) + file_path = Column(String, unique=True, nullable=False) + file_type = Column(String) # 'image' or 'audio' + artist_id = Column(String) + is_used = Column(Boolean, default=False) + description = Column(Text) + source_url = Column(String) + thumbnail_path = Column(String) # NUOVO + created_at = Column(DateTime, default=datetime.datetime.utcnow) + last_used_at = Column(DateTime) + +class Draft(Base): + __tablename__ = 'drafts' + id = Column(Integer, primary_key=True) + artist_id = Column(String) + title = Column(String) + caption = Column(Text) + hashtags = Column(Text) + image_path = Column(String) + image_paths = Column(Text) # JSON list of local paths + image_url = Column(String) # NUOVA COLONNA PER ANTEPRIMA DIRETTA + video_url = Column(String) + video_path = Column(String) # NUOVO + audio_analysis = Column(Text) + focus_points = Column(Text) # NUOVO: Coordinate JSON per la regia AI + status = Column(String, default='pending') # pending, approved, published + created_at = Column(DateTime, default=datetime.datetime.utcnow) + +class Database: + def __init__(self, db_url="sqlite:///data/redazione.db"): + self.engine = create_engine(db_url) + Base.metadata.create_all(self.engine) + self.Session = sessionmaker(bind=self.engine) + + def add_media(self, file_path, file_type, artist_id, description=None, source_url=None, thumbnail_path=None): + session = self.Session() + try: + media = session.query(Media).filter_by(file_path=file_path).first() + if not media: + media = Media( + file_path=file_path, + file_type=file_type, + artist_id=artist_id, + description=description, + source_url=source_url, + thumbnail_path=thumbnail_path + ) + session.add(media) + session.commit() + elif thumbnail_path and not media.thumbnail_path: + media.thumbnail_path = thumbnail_path + session.commit() + return media + finally: + session.close() + + def get_unused_image(self, artist_id): + from sqlalchemy.sql.expression import func + session = self.Session() + try: + return session.query(Media).filter_by( + artist_id=artist_id, + file_type='image', + is_used=False + ).order_by(func.random()).first() + finally: + session.close() + + def get_media_by_artist(self, artist_id, file_type): + session = self.Session() + try: + return session.query(Media).filter_by( + artist_id=artist_id, + file_type=file_type + ).first() + finally: + session.close() + + def get_audio_for_artist(self, artist_id): + return self.get_media_by_artist(artist_id, 'audio') + + def mark_as_used(self, media_id): + session = self.Session() + try: + media = session.query(Media).get(media_id) + if media: + media.is_used = True + media.last_used_at = datetime.datetime.utcnow() + session.commit() + finally: + session.close() + + def get_audio_for_artist(self, artist_id): + # We assume one main audio track per session or the latest added + session = self.Session() + try: + return session.query(Media).filter_by( + artist_id=artist_id, + file_type='audio' + ).order_by(Media.created_at.desc()).first() + finally: + session.close() + + def save_draft(self, artist_id, title, caption, hashtags, image_path, audio_analysis, image_url=None, video_url=None, image_paths=None, video_path=None, focus_points=None): + session = self.Session() + try: + draft = Draft( + artist_id=artist_id, + title=title, + caption=caption, + hashtags=hashtags, + image_path=image_path, + image_paths=image_paths, + image_url=image_url, + video_url=video_url, + video_path=video_path, + audio_analysis=audio_analysis, + focus_points=focus_points # SALVATAGGIO + ) + session.add(draft) + session.commit() + return draft.id + finally: + session.close() + + def get_recent_media(self, artist_id, limit=10): + session = self.Session() + try: + return session.query(Media).filter( + Media.artist_id == artist_id, + Media.file_type.in_(['image', 'video']) + ).order_by(Media.created_at.desc()).limit(limit).all() + finally: + session.close() + + def get_mixed_assets(self, artist_id, total=12): + from sqlalchemy.sql.expression import func + session = self.Session() + try: + # 1. Seleziona una copertina (preferibilmente immagine mai usata) + cover = session.query(Media).filter_by( + artist_id=artist_id, + file_type='image', + is_used=False + ).order_by(func.random()).first() + + if not cover: + cover = session.query(Media).filter_by( + artist_id=artist_id, + file_type='image' + ).order_by(func.random()).first() + + if not cover: + # Se proprio non ci sono immagini, prendi un video come cover + cover = session.query(Media).filter_by( + artist_id=artist_id, + file_type='video' + ).order_by(func.random()).first() + + if not cover: + return [] + + # 2. Seleziona un mix di altre immagini e video + others = session.query(Media).filter( + Media.artist_id == artist_id, + Media.file_type.in_(['image', 'video']), + Media.id != cover.id + ).order_by(func.random()).limit(total - 1).all() + + return [cover] + others + finally: + session.close() + + def get_pending_drafts(self, artist_id=None): + session = self.Session() + try: + query = session.query(Draft).filter_by(status='pending') + if artist_id: + query = query.filter_by(artist_id=artist_id) + return query.all() + finally: + session.close() + def get_media_by_path(self, file_path): + session = self.Session() + try: + return session.query(Media).filter_by(file_path=file_path).first() + finally: + session.close() + + def delete_draft(self, draft_id): + session = self.Session() + try: + draft = session.query(Draft).filter_by(id=draft_id).first() + if draft: + session.delete(draft) + session.commit() + finally: + session.close() + + def update_draft(self, draft_id, title=None, caption=None, hashtags=None, image_path=None, status=None, audio_analysis=None, video_url=None, video_path=None, image_paths=None, focus_points=None): + session = self.Session() + try: + draft = session.query(Draft).get(draft_id) + if draft: + if title is not None: draft.title = title + if caption is not None: draft.caption = caption + if hashtags is not None: draft.hashtags = hashtags + if image_path is not None: draft.image_path = image_path + if status is not None: draft.status = status + if audio_analysis is not None: draft.audio_analysis = audio_analysis + if video_url is not None: draft.video_url = video_url + if video_path is not None: draft.video_path = video_path + if image_paths is not None: draft.image_paths = image_paths + if focus_points is not None: draft.focus_points = focus_points # AGGIORNAMENTO + session.commit() + return True + return False + finally: + session.close() + + def mark_as_published(self, draft_id): + session = self.Session() + try: + draft = session.query(Draft).filter_by(id=draft_id).first() + if draft: + draft.status = 'published' + session.commit() + finally: + session.close() diff --git a/src/force_test_publication.py b/src/force_test_publication.py new file mode 100644 index 0000000..be7f11d --- /dev/null +++ b/src/force_test_publication.py @@ -0,0 +1,86 @@ +import os +import json +import sys +from dotenv import load_dotenv + +# Aggiungiamo src al path +sys.path.append(os.path.join(os.getcwd(), 'src')) + +from database import Database +from agents import SocialAgents +from buffer_publisher import BufferPublisher + +load_dotenv() + +def force_test(): + db = Database() + agents = SocialAgents() + publisher = BufferPublisher() + + # 1. Carichiamo i dati di Veronica + with open('config.json', 'r') as f: + config = json.load(f) + veronica = next(a for a in config['artists'] if "Veronica" in a['name']) + + print(f"--- FASE 1: GENERAZIONE PER {veronica['name']} ---") + result = agents.run_for_artist(veronica) + + if 'error' in result: + print(f"Errore Generazione: {result['error']}") + return + + print(f"Post Generato: {result['title']}") + + # 2. Salvataggio bozza locale + draft_id = db.save_draft( + artist_id=veronica['id'], + title=result['title'], + caption=result['caption'], + hashtags=result['hashtags'], + image_path=result['image_path'], + audio_analysis=result['audio_analysis'] + ) + + # 3. PUBBLICAZIONE SU BUFFER + print(f"\n--- FASE 2: INVIO A BUFFER ---") + profile_ids = ["69f9e7e35c4c051afa116a9e", "69f9ea855c4c051afa117baa"] + full_text = f"*{result['title']}*\n\n{result['caption']}\n\n{result['hashtags']}" + + pub_res = publisher.publish(profile_ids, full_text, result['image_path']) + print(f"Risultato Pubblicazione: {pub_res}") + + # 4. VERIFICA REALE SU BUFFER + print(f"\n--- FASE 3: VERIFICA BOZZE SU BUFFER ---") + token = os.getenv("BUFFER_ACCESS_TOKEN") + url = "https://api.buffer.com/graphql" + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + # Chiediamo i post in stato 'draft' per Instagram + query = """ + query { + channels(input: {ids: ["69f9ea855c4c051afa117baa"]}) { + name + posts(input: {state: draft}) { + totalCount + nodes { + id + text + } + } + } + } + """ + + resp = requests.post(url, headers=headers, json={'query': query}) + if resp.status_code == 200: + data = resp.json() + drafts = data['data']['channels'][0]['posts'] + print(f"Bozze totali su Instagram: {drafts['totalCount']}") + for node in drafts['nodes']: + print(f"- Bozza trovata ID: {node['id']} | Testo: {node['text'][:50]}...") + else: + print(f"Errore verifica: {resp.text}") + +if __name__ == "__main__": + import requests # Lo importiamo qui per sicurezza + force_test() diff --git a/src/generate_higgsfield_photos.py b/src/generate_higgsfield_photos.py new file mode 100644 index 0000000..aa6c997 --- /dev/null +++ b/src/generate_higgsfield_photos.py @@ -0,0 +1,401 @@ +import os +import sys +import json +import argparse +import subprocess +import re +import requests +from pathlib import Path +from dotenv import load_dotenv + +# Add the directory containing this script to sys.path to allow imports from src +current_dir = Path(__file__).resolve().parent +if str(current_dir) not in sys.path: + sys.path.insert(0, str(current_dir)) + +# Load environment variables +load_dotenv() + +def normalize_string(s): + """Normalize a string to lowercase and remove non-alphanumeric characters for fuzzy matching.""" + if not s: + return "" + return re.sub(r'[^a-z0-9]', '', s.lower()) + +def load_artists_config(config_path="config.json"): + """Load artists configuration from config.json.""" + try: + with open(config_path, 'r', encoding='utf-8') as f: + config = json.load(f) + return config.get("artists", []) + except Exception as e: + print(f"❌ Error loading config.json: {e}", file=sys.stderr) + return [] + +def match_artist(file_path, artists): + """ + Fuzzy match a file path/filename to an artist in config.json. + Returns the matched artist dict, or None if no match found. + """ + path_obj = Path(file_path) + # Check parent directory name and filename + search_space = f"{path_obj.parent.name} {path_obj.name}" + normalized_search = normalize_string(search_space) + + # Try exact or substring matches + for artist in artists: + artist_id = artist.get("id", "") + artist_name = artist.get("name", "") + social_tag = artist.get("social_tag", "") + + norm_id = normalize_string(artist_id) + norm_name = normalize_string(artist_name) + norm_tag = normalize_string(social_tag) + + # Check if artist name/id is in the search space + if (norm_id and norm_id in normalized_search) or \ + (norm_name and norm_name in normalized_search) or \ + (norm_tag and norm_tag in normalized_search): + return artist + + # Fuzzy match check (e.g. if singer first name matches) + for artist in artists: + artist_name = artist.get("name", "") + first_name = artist_name.split()[0] if artist_name else "" + norm_first = normalize_string(first_name) + if norm_first and norm_first in normalized_search: + return artist + + return None + +def run_higgsfield_cli_generate(prompt, soul_id, image_path, output_path): + """ + Run Higgsfield generation using the Higgsfield CLI via subprocess. + """ + print(f"🎬 [CLI] Generating AI photo for Soul ID: {soul_id}...") + try: + # First, upload the reference image if provided + upload_cmd = ["higgsfield", "upload", str(image_path)] + print(f" Uploading reference image: {' '.join(upload_cmd)}") + upload_res = subprocess.run(upload_cmd, capture_output=True, text=True, check=True) + + # Extract UUID from upload response + # Standard UUID regex + uuids = re.findall(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}', upload_res.stdout, re.IGNORECASE) + if not uuids: + print(f" ❌ No UUID found in upload output: {upload_res.stdout}") + return False + + uuid = uuids[0] + print(f" Uploaded successfully. Reference UUID: {uuid}") + + # Create generation job + gen_cmd = [ + "higgsfield", "generate", "create", "soul_v2", + "--prompt", prompt, + "--soul-id", soul_id, + "--image-id", uuid, + "--wait" + ] + print(f" Running generation: {' '.join(gen_cmd)}") + gen_res = subprocess.run(gen_cmd, capture_output=True, text=True, check=True) + + # Find output URL or download path + # If the CLI downloads automatically or provides a URL, we capture it + urls = re.findall(r'https?://[^\s]+', gen_res.stdout) + if urls: + url = urls[0] + print(f" Generation completed. Output URL: {url}") + # Download file + resp = requests.get(url, timeout=30) + if resp.status_code == 200: + with open(output_path, "wb") as f: + f.write(resp.content) + print(f" ✅ Saved generated photo to: {output_path}") + return True + else: + print(f" ❌ Could not extract output URL from generation log: {gen_res.stdout}") + return False + + except subprocess.CalledProcessError as e: + print(f" ❌ CLI command failed: {e.stderr}") + return False + except Exception as e: + print(f" ❌ CLI generation error: {e}") + return False + +def run_higgsfield_api_generate(prompt, model_to_use, soul_id, image_path, output_path, api_key, api_secret=None): + """ + Run Higgsfield generation using direct REST API requests via the official SDK. + """ + import higgsfield_client + + if soul_id: + print(f"🔌 [API] Generating AI photo using model '{model_to_use}' for Soul ID: {soul_id}...") + else: + print(f"🔌 [API] Generating AI photo using fallback model '{model_to_use}'...") + + try: + # Set environment variables for the SDK + os.environ["HF_API_KEY"] = api_key + if api_secret: + os.environ["HF_API_SECRET"] = api_secret + + print(" Initializing Higgsfield SDK Client...") + client = higgsfield_client.SyncClient() + + # 1. Upload starting photo + print(f" Uploading starting photo: {image_path}...") + public_url = client.upload_file(image_path) + print(f" Uploaded successfully. URL: {public_url}") + + # 2. Submit Generation Job + if model_to_use == "flux-2": + arguments = { + "prompt": prompt, + "aspect_ratio": "1:1", + "resolution": "2k", + "input_images": [public_url] + } + else: + # Fallback/Default for soul or others if soul_id is present + # We use the 'soul' endpoint on the platform API + arguments = { + "prompt": prompt, + "aspect_ratio": "1:1", + "style_id": "realistic", # default style_id required by the API + "character_id": soul_id, + "input_images": [public_url] + } + + print(f" Submitting job to endpoint '{model_to_use}'...") + controller = client.submit( + application=model_to_use, + arguments=arguments + ) + request_id = controller.request_id + print(f" Job submitted successfully. Request ID: {request_id}. Polling for completion...") + + # 3. Poll for completion + for status in controller.poll_request_status(delay=3.0): + # Print status to stdout for log visibility + print(f" Job status: {status}") + + # 4. Download result + print(" Retrieving completed job data...") + result = controller.get() + + # In flux-2/soul, completed output is stored in 'images' list or 'outputs' list + images = result.get("images") or result.get("outputs") or [] + output_url = result.get("output_url") or (images[0].get("url") if images else None) + + if not output_url: + print(f" ❌ Job completed but no output URL found in response: {result}") + return False + + print(f" ✅ Generation complete! Downloading from: {output_url}") + resp = requests.get(output_url, timeout=30) + if resp.status_code == 200: + with open(output_path, "wb") as f: + f.write(resp.content) + print(f" ✅ Saved generated photo to: {output_path}") + return True + else: + print(f" ❌ Download failed with status: {resp.status_code}") + return False + + except Exception as e: + print(f" ❌ REST API generation error: {e}") + import traceback + traceback.print_exc() + return False +def deduct_credits(amount=1): + try: + cache_dir = Path("data/cache") + cache_dir.mkdir(parents=True, exist_ok=True) + credits_file = cache_dir / "credits.json" + + # Load existing or create + if credits_file.exists(): + with open(credits_file, 'r') as f: + data = json.load(f) + else: + data = {"total_monthly": 1000, "remaining": 1000, "generated_this_month": 0} + + data["remaining"] = max(0, data.get("remaining", 1000) - amount) + data["generated_this_month"] = data.get("generated_this_month", 0) + amount + + with open(credits_file, 'w') as f: + json.dump(data, f, indent=2) + + print(f" 📊 Local credits updated: {data['remaining']}/{data['total_monthly']} remaining ({data['generated_this_month']} generated)") + except Exception as e: + print(f" ⚠️ Could not update credits cache: {e}") + +def main(): + parser = argparse.ArgumentParser(description="Batch generate AI photos using Higgsfield AI with Soul ID character consistency.") + parser.add_argument("--dir", required=True, help="Directory containing starting photos.") + parser.add_argument("--soul-ids", help="JSON string mapping artist names/IDs to Higgsfield Soul IDs.") + parser.add_argument("--output-dir", default="data/images/generated_photos", help="Output directory.") + parser.add_argument("--prompt", default="A beautiful editorial studio portrait, highly detailed, cinematic studio lighting, professional photography, 8k resolution, crisp details", help="Prompt override for visual style.") + parser.add_argument("--dry-run", action="store_true", help="Simulate the execution and check mappings without making API/CLI requests.") + + args = parser.parse_args() + + input_dir = Path(args.dir) + if not input_dir.is_dir(): + print(f"❌ Error: {input_dir} is not a valid directory.", file=sys.stderr) + sys.exit(1) + + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + # Load artists from config + artists = load_artists_config() + if not artists: + print("⚠️ Warning: No artists found in config.json.", file=sys.stderr) + + # Load custom soul IDs mapping + custom_soul_ids = {} + if args.soul_ids: + try: + custom_soul_ids = json.loads(args.soul_ids) + print(f"ℹ️ Loaded custom Soul ID overrides: {custom_soul_ids}") + except Exception as e: + print(f"❌ Error parsing --soul-ids JSON: {e}", file=sys.stderr) + sys.exit(1) + + # Compile final artist -> soul_id mapping + artist_soul_map = {} + for artist in artists: + name = artist.get("name", "") + artist_id = artist.get("id", "") + # Priority: 1. CLI Override by Name, 2. CLI Override by ID, 3. config.json soul_id + soul_id = custom_soul_ids.get(name) or custom_soul_ids.get(artist_id) or artist.get("soul_id") + if soul_id: + artist_soul_map[name] = soul_id + + print(f"ℹ️ Active Singer Soul ID mapping: {artist_soul_map}") + + # Scan input directory for images + supported_extensions = ['.png', '.jpg', '.jpeg', '.webp'] + image_files = [f for f in input_dir.rglob("*") if f.suffix.lower() in supported_extensions] + + if not image_files: + print(f"⚠️ No starting photos found in {input_dir}.") + sys.exit(0) + + print(f"🔍 Found {len(image_files)} starting photos. Beginning processing...") + + # Retrieve credentials + api_key = os.getenv("HIGGSFIELD_API_KEY") + api_secret = os.getenv("HIGGSFIELD_SECRET") # optional + + # Determine generation method + use_api = bool(api_key and api_key != "your_higgsfield_api_key_here") + + # If explicitly in dry-run mode + if args.dry_run: + print("\n🧪 [DRY-RUN] Simulating Higgsfield AI generation pipeline. No real API calls will be made.") + elif not use_api: + print("ℹ️ HIGGSFIELD_API_KEY is not set or is placeholder. Using CLI-based generation.") + # Check if higgsfield CLI is installed + try: + res = subprocess.run(["which", "higgsfield"], capture_output=True, text=True) + if res.returncode != 0: + print("⚠️ Warning: Higgsfield CLI ('higgsfield') not found on system path.") + print(" Automatically falling back to DRY-RUN simulation mode.") + args.dry_run = True + except Exception as e: + print("⚠️ Warning: Error checking for Higgsfield CLI.") + print(" Automatically falling back to DRY-RUN simulation mode.") + args.dry_run = True + + success_count = 0 + failure_count = 0 + + for img_path in image_files: + print(f"\n📸 Processing photo: {img_path.name}") + + # 1. Match artist + matched_artist = match_artist(img_path, artists) + if not matched_artist: + print(f" ⚠️ Skipping: Could not match image path/name to any known artist in config.json.") + failure_count += 1 + continue + + artist_name = matched_artist.get("name") + print(f" Matched Artist: {artist_name}") + + # 2. Retrieve Soul ID & Check if valid + soul_id = artist_soul_map.get(artist_name) + is_placeholder = bool(soul_id and (soul_id.startswith("soul_") or soul_id == "your_soul_id_here")) + + if not soul_id or is_placeholder: + print(f" ℹ️ No valid Soul ID configured for artist '{artist_name}'. Falling back to flux-2 model!") + model_to_use = "flux-2" + soul_id = None + else: + model_to_use = "soul" + + # 3. Generate output file name + out_filename = f"gen_{artist_name.replace(' ', '_')}_{img_path.stem}.png" + out_path = output_dir / out_filename + + # 4. Generate AI Photo + if args.dry_run: + print(f" 🧪 [DRY-RUN] Would generate AI photo:") + print(f" - Starting photo: {img_path.absolute()}") + print(f" - Model to use: {model_to_use}") + print(f" - Soul ID reference: {soul_id}") + print(f" - Style prompt: {args.prompt}") + print(f" - Output destination: {out_path.absolute()}") + success = True + # Simulate DB save in dry-run + print(f" 🧪 [DRY-RUN] Would register in webapp database as Draft for {artist_name}") + elif use_api: + success = run_higgsfield_api_generate(args.prompt, model_to_use, soul_id, img_path, out_path, api_key, api_secret) + else: + # CLI fallback doesn't support model argument in our signature, so use original + success = run_higgsfield_cli_generate(args.prompt, soul_id, img_path, out_path) + + if success: + success_count += 1 + if not args.dry_run: + try: + from database import Database + db_instance = Database() + + if model_to_use == "flux-2": + analysis_text = "Immagine generata tramite Higgsfield AI utilizzando il modello Flux-2 con riferimento visivo." + hashtags_text = "#higgsfield #flux #consistentcharacter" + else: + analysis_text = f"Immagine generata tramite Higgsfield AI utilizzando il Soul ID: {soul_id}." + hashtags_text = "#higgsfield #soul #consistentcharacter" + + draft_id = db_instance.save_draft( + artist_id=matched_artist.get("id"), + title=f"AI Photo - {artist_name}", + caption=f"Ecco un nuovo post generato con l'intelligenza artificiale per {artist_name}! #music #ai", + hashtags=hashtags_text, + image_path=str(out_path), + audio_analysis=analysis_text + ) + print(f" 💾 Registered in webapp database as Draft ID: {draft_id}") + deduct_credits(1) + except Exception as db_err: + print(f" ⚠️ Could not register in webapp database: {db_err}") + else: + failure_count += 1 + + print(f"\n📊 Processing complete!") + print(f" Successfully processed/simulated: {success_count} photos") + print(f" Failed or skipped: {failure_count} photos") + if args.dry_run: + print(f" 🧪 Dry-run simulation completed successfully. No files were written to: {output_dir.absolute()}") + else: + print(f" Generated photos are saved in: {output_dir.absolute()}") + + +if __name__ == "__main__": + main() diff --git a/src/generate_now.py b/src/generate_now.py new file mode 100644 index 0000000..b12a0a0 --- /dev/null +++ b/src/generate_now.py @@ -0,0 +1,35 @@ +import json +import asyncio +from agents import SocialAgents +from database import Database + +async def generate(): + db = Database() + agents = SocialAgents() + + with open('config.json', 'r') as f: + config = json.load(f) + artists = config['artists'] + + for artist in artists: + try: + print(f"Generazione per {artist['name']}...") + result = agents.run_for_artist(artist) + + if result['caption']: + db.save_draft( + artist_id=artist['id'], + title=result['title'], + caption=result['caption'], + hashtags=result['hashtags'], + image_path=result['image_path'], + audio_analysis=result['audio_analysis'] + ) + print(f"✅ Bozza salvata per {artist['name']}") + else: + print(f"⚠️ Nessun contenuto generato per {artist['name']} (controlla se ci sono file)") + except Exception as e: + print(f"❌ Errore per {artist['name']}: {str(e)}") + +if __name__ == "__main__": + asyncio.run(generate()) diff --git a/src/inspect_asset_input.py b/src/inspect_asset_input.py new file mode 100644 index 0000000..06fc12f --- /dev/null +++ b/src/inspect_asset_input.py @@ -0,0 +1,36 @@ +import os +import requests +from dotenv import load_dotenv + +load_dotenv() + +def inspect_asset_detail(): + token = os.getenv("BUFFER_ACCESS_TOKEN") + url = "https://api.buffer.com/graphql" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + + query = """ + query { + __type(name: "ImageAssetInput") { + inputFields { + name + } + } + } + """ + + response = requests.post(url, headers=headers, json={'query': query}) + if response.status_code == 200: + data = response.json() + fields = data['data']['__type']['inputFields'] + print("\n--- CAMPI DI IMAGEASSETINPUT ---") + for f in fields: + print(f"Campo: {f['name']}") + else: + print(f"Errore {response.status_code}") + +if __name__ == "__main__": + inspect_asset_detail() diff --git a/src/inspect_buffer_audio.py b/src/inspect_buffer_audio.py new file mode 100644 index 0000000..a3e804d --- /dev/null +++ b/src/inspect_buffer_audio.py @@ -0,0 +1,28 @@ +import os +import requests +import json +from dotenv import load_dotenv + +load_dotenv() +token = os.getenv("BUFFER_ACCESS_TOKEN") +url = "https://api.buffer.com/graphql" +headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + +query = """ +query { + __type(name: "AssetsInput") { + inputFields { + name + type { + name + kind + ofType { name kind } + } + } + } +} + +""" + +resp = requests.post(url, headers=headers, json={'query': query}) +print(json.dumps(resp.json(), indent=2)) diff --git a/src/inspect_create_post.py b/src/inspect_create_post.py new file mode 100644 index 0000000..b47b2b9 --- /dev/null +++ b/src/inspect_create_post.py @@ -0,0 +1,45 @@ +import os +import requests +from dotenv import load_dotenv + +load_dotenv() + +def inspect_metadata(): + token = os.getenv("BUFFER_ACCESS_TOKEN") + url = "https://api.buffer.com/graphql" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + + # Cerchiamo il tipo del campo 'metadata' + query = """ + query { + __type(name: "CreatePostInput") { + inputFields { + name + type { + name + kind + ofType { + name + kind + } + } + } + } + } + """ + + response = requests.post(url, headers=headers, json={'query': query}) + if response.status_code == 200: + data = response.json() + fields = data['data']['__type']['inputFields'] + for f in fields: + if f['name'] == 'metadata': + print(f"Tipo di metadata: {f['type']['name'] or f['type']['ofType']['name']}") + else: + print(f"Errore {response.status_code}") + +if __name__ == "__main__": + inspect_metadata() diff --git a/src/inspect_enums.py b/src/inspect_enums.py new file mode 100644 index 0000000..9a46a7e --- /dev/null +++ b/src/inspect_enums.py @@ -0,0 +1,39 @@ +import os +import requests +from dotenv import load_dotenv + +load_dotenv() + +def inspect_enums(): + token = os.getenv("BUFFER_ACCESS_TOKEN") + url = "https://api.buffer.com/graphql" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + + query = """ + query { + sched: __type(name: "SchedulingType") { + enumValues { name } + } + mode: __type(name: "ShareMode") { + enumValues { name } + } + } + """ + + response = requests.post(url, headers=headers, json={'query': query}) + if response.status_code == 200: + data = response.json() + print("\n--- VALORI SCHEDULING ---") + for v in data['data']['sched']['enumValues']: + print(v['name']) + print("\n--- VALORI SHARE MODE ---") + for v in data['data']['mode']['enumValues']: + print(v['name']) + else: + print(f"Errore {response.status_code}") + +if __name__ == "__main__": + inspect_enums() diff --git a/src/inspect_post_metadata.py b/src/inspect_post_metadata.py new file mode 100644 index 0000000..2afd8b5 --- /dev/null +++ b/src/inspect_post_metadata.py @@ -0,0 +1,52 @@ +import os +import requests +from dotenv import load_dotenv + +load_dotenv() + +def inspect_enum(): + token = os.getenv("BUFFER_ACCESS_TOKEN") + url = "https://api.buffer.com/graphql" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + + # Cerchiamo il tipo del campo 'type' in InstagramPostMetadataInput + query = """ + query { + __type(name: "InstagramPostMetadataInput") { + inputFields { + name + type { + name + kind + } + } + } + } + """ + + response = requests.post(url, headers=headers, json={'query': query}) + data = response.json() + for f in data['data']['__type']['inputFields']: + if f['name'] == 'type': + print(f"Tipo del campo 'type': {f['type']['name']}") + # Ora ispezioniamo quell'ENUM + enum_query = f""" + query {{ + __type(name: "{f['type']['name']}") {{ + enumValues {{ + name + }} + }} + }} + """ + res_enum = requests.post(url, headers=headers, json={'query': enum_query}) + data_enum = res_enum.json() + print("Valori possibili:") + for val in data_enum['data']['__type']['enumValues']: + print(f"- {val['name']}") + +if __name__ == "__main__": + inspect_enum() diff --git a/src/introspect_buffer.py b/src/introspect_buffer.py new file mode 100644 index 0000000..f0ed9f0 --- /dev/null +++ b/src/introspect_buffer.py @@ -0,0 +1,51 @@ +import os +import requests +from dotenv import load_dotenv + +load_dotenv() + +def introspect(): + token = os.getenv("BUFFER_ACCESS_TOKEN") + url = "https://api.buffer.com/graphql" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + + # Query per elencare tutte le mutation disponibili + query = """ + query { + __schema { + mutationType { + fields { + name + args { + name + type { + name + kind + } + } + } + } + } + } + """ + + response = requests.post(url, headers=headers, json={'query': query}) + if response.status_code == 200: + data = response.json() + if 'errors' in data: + print(f"Errore: {data['errors']}") + else: + fields = data['data']['__schema']['mutationType']['fields'] + print("\n--- MUTATION DISPONIBILI SU BUFFER ---") + for f in fields: + print(f"Nome: {f['name']}") + for arg in f['args']: + print(f" Arg: {arg['name']} | Type: {arg['type']['name']} ({arg['type']['kind']})") + else: + print(f"Errore {response.status_code}: {response.text}") + +if __name__ == "__main__": + introspect() diff --git a/src/introspect_payload.py b/src/introspect_payload.py new file mode 100644 index 0000000..36c3a9f --- /dev/null +++ b/src/introspect_payload.py @@ -0,0 +1,40 @@ +import os +import requests +from dotenv import load_dotenv + +load_dotenv() + +def introspect_payload(): + token = os.getenv("BUFFER_ACCESS_TOKEN") + url = "https://api.buffer.com/graphql" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + + query = """ + query { + __type(name: "PostActionPayload") { + fields { + name + type { + name + kind + } + } + } + } + """ + + response = requests.post(url, headers=headers, json={'query': query}) + if response.status_code == 200: + data = response.json() + fields = data['data']['__type']['fields'] + print("\n--- CAMPI DI POSTACTIONPAYLOAD ---") + for f in fields: + print(f"Campo: {f['name']} | Tipo: {f['type']['name']}") + else: + print(f"Errore {response.status_code}") + +if __name__ == "__main__": + introspect_payload() diff --git a/src/list_buffer_profiles.py b/src/list_buffer_profiles.py new file mode 100644 index 0000000..3863768 --- /dev/null +++ b/src/list_buffer_profiles.py @@ -0,0 +1,56 @@ +import os +import requests +from dotenv import load_dotenv + +load_dotenv() + +def list_profiles(): + token = os.getenv("BUFFER_ACCESS_TOKEN") + if not token: + print("Errore: BUFFER_ACCESS_TOKEN non trovato nel file .env") + return + + # Nuova URL per GraphQL API + url = "https://api.buffer.com/graphql" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + + # Query GraphQL per ottenere l'account e le organizzazioni + query = """ + query { + account { + organizations { + id + name + channels { + id + service + name + } + } + } + } + """ + + response = requests.post(url, headers=headers, json={'query': query}) + + if response.status_code == 200: + data = response.json() + if 'errors' in data: + print(f"Errore GraphQL: {data['errors']}") + return + + organizations = data['data']['account']['organizations'] + print("\n--- ORGANIZZAZIONI E CANALI BUFFER ---") + for org in organizations: + print(f"\nOrganizzazione: {org['name']} (ID: {org['id']})") + for channel in org['channels']: + print(f" - ID Canale: {channel['id']} | Social: {channel['service']} | Nome: {channel['name']}") + print("---------------------------------------\n") + else: + print(f"Errore API Buffer: {response.status_code} - {response.text}") + +if __name__ == "__main__": + list_profiles() diff --git a/src/list_higgsfield_characters.py b/src/list_higgsfield_characters.py new file mode 100644 index 0000000..ef93dbf --- /dev/null +++ b/src/list_higgsfield_characters.py @@ -0,0 +1,141 @@ +import os +import sys +import json +import asyncio +from pathlib import Path +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +async def fetch_mcp_details(api_key, api_secret=None): + from mcp.client.streamable_http import streamablehttp_client + from mcp import ClientSession + + url = "https://mcp.higgsfield.ai/mcp" + + # MCP server accepts Bearer tokens (unlike REST API which uses "Key" format) + # Note: MCP server is designed for OAuth browser auth, API keys provide limited access + headers = { + "Authorization": f"Bearer {api_key}" + } + + print(f"🔌 Connecting to Higgsfield hosted MCP server ({url})...") + + try: + async with streamablehttp_client(url, headers=headers) as (read, write, _): + async with ClientSession(read, write) as session: + print(" Initializing MCP session...") + await session.initialize() + print("✅ Session initialized successfully!") + + # 1. Query available tools + print("\n🔍 Fetching available tools...") + tools_res = await session.list_tools() + tools = tools_res.tools + print(f" Found {len(tools)} tools:") + for t in tools: + desc = t.description[:80] if t.description else "No description" + print(f" 🛠️ Tool: {t.name} - {desc}...") + + # 2. Query available resources + print("\n🔍 Fetching available resources...") + resources_res = await session.list_resources() + resources = resources_res.resources + print(f" Found {len(resources)} resources:") + for r in resources: + print(f" 🔗 Resource: {r.uri} - {r.name}") + + # 3. Pre-flight check: verify API key works by calling balance + print("\n🔑 Verifying API key validity...") + try: + preflight = await session.call_tool("balance", {}) + preflight_text = preflight.content[0].text if preflight.content else "" + if "something went wrong" in preflight_text.lower() or "error" in preflight_text.lower(): + print(f" ⚠️ API key may be invalid or expired. Server response: {preflight_text}") + print(" 💡 Tip: Log in to https://higgsfield.ai and regenerate your API key.") + print(" The MCP connection works, but tool calls are failing server-side.") + else: + print(f" ✅ API key verified. Account info: {preflight_text}") + except Exception as e: + print(f" ⚠️ Pre-flight check failed: {e}") + + # 4. Try to list characters via the show_characters tool + print("\n📖 Fetching Soul Characters via show_characters tool...") + char_tools = [t for t in tools if "character" in t.name.lower() or "soul" in t.name.lower()] + + if char_tools: + for t in char_tools: + print(f" Calling tool: {t.name}...") + try: + result = await session.call_tool(t.name, {"action": "list", "status": "ready", "size": 50}) + print(f"✅ Tool result:") + print("-" * 50) + if result.content: + text = result.content[0].text + try: + parsed = json.loads(text) + if isinstance(parsed, list): + for char in parsed: + char_id = char.get("id") or char.get("uuid") or char.get("soul_id") + name = char.get("name") or "Unnamed Character" + status = char.get("status", "unknown") + print(f"🆔 Soul ID: {char_id}") + print(f"👤 Name : {name}") + print(f"📊 Status : {status}") + print("-" * 50) + elif isinstance(parsed, dict): + items = parsed.get("characters") or parsed.get("items") or parsed.get("data") or [parsed] + if isinstance(items, list): + for char in items: + char_id = char.get("id") or char.get("uuid") or char.get("soul_id") + name = char.get("name") or "Unnamed Character" + status = char.get("status", "unknown") + print(f"🆔 Soul ID: {char_id}") + print(f"👤 Name : {name}") + print(f"📊 Status : {status}") + print("-" * 50) + else: + print(json.dumps(parsed, indent=2)) + else: + print(text) + except json.JSONDecodeError: + print(text) + else: + print(" (No content returned)") + print("-" * 50) + except Exception as tool_err: + print(f" ⚠️ Could not invoke tool {t.name}: {tool_err}") + else: + print(" ⚠️ No character-related tools found on the server.") + + # 4. Also check for any other listing tools + list_tools = [t for t in tools if "list" in t.name.lower() and t not in char_tools] + if list_tools: + print("\n💡 Other listing tools available:") + for t in list_tools: + desc = t.description[:100] if t.description else "No description" + print(f" 🛠️ {t.name}: {desc}") + + except Exception as e: + print(f"❌ MCP Connection Error: {e}", file=sys.stderr) + import traceback + traceback.print_exc() + sys.exit(1) + +def main(): + api_key = os.getenv("HIGGSFIELD_API_KEY") + api_secret = os.getenv("HIGGSFIELD_SECRET") + + if not api_key or api_key == "your_higgsfield_api_key_here": + print("❌ Error: HIGGSFIELD_API_KEY is not configured in .env.") + print(" Please add your real token to .env to authenticate.", file=sys.stderr) + sys.exit(1) + + if not api_secret: + print("⚠️ Warning: HIGGSFIELD_SECRET is not set. Some API calls may fail.") + + asyncio.run(fetch_mcp_details(api_key, api_secret)) + +if __name__ == "__main__": + main() diff --git a/src/list_models.py b/src/list_models.py new file mode 100644 index 0000000..a9f2d59 --- /dev/null +++ b/src/list_models.py @@ -0,0 +1,11 @@ +import os +import google.generativeai as genai +from dotenv import load_dotenv + +load_dotenv() +genai.configure(api_key=os.getenv("GOOGLE_API_KEY")) + +print("Modelli disponibili:") +for m in genai.list_models(): + if 'generateContent' in m.supported_generation_methods: + print(m.name) diff --git a/src/list_profiles.py b/src/list_profiles.py new file mode 100644 index 0000000..9696219 --- /dev/null +++ b/src/list_profiles.py @@ -0,0 +1,49 @@ +import os +import requests +from dotenv import load_dotenv + +load_dotenv() + +def list_profiles(): + token = os.getenv("BUFFER_ACCESS_TOKEN") + url = "https://api.buffer.com/graphql" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + + query = """ + query { + account { + id + email + organizations { + id + name + channels(input: {}) { + id + name + service + } + } + } + } + """ + + response = requests.post(url, headers=headers, json={'query': query}) + if response.status_code == 200: + data = response.json() + acc = data.get('data', {}).get('account') + if acc: + print(f"\nAccount: {acc['email']} ({acc['id']})") + for org in acc['organizations']: + print(f"\n--- ORGANIZZAZIONE: {org['name']} ({org['id']}) ---") + for c in org['channels']: + print(f"ID: {c['id']} | Servizio: {c['service']} | Nome: {c['name']}") + else: + print("Account non trovato:", data) + else: + print(f"Errore {response.status_code}: {response.text}") + +if __name__ == "__main__": + list_profiles() diff --git a/src/scheduler_service.py b/src/scheduler_service.py new file mode 100644 index 0000000..2641e0d --- /dev/null +++ b/src/scheduler_service.py @@ -0,0 +1,99 @@ +import os +import json +import time +import asyncio +from datetime import datetime +from apscheduler.schedulers.background import BackgroundScheduler +from telegram import Bot +from agents import SocialAgents +from database import Database +from dotenv import load_dotenv + +load_dotenv() + +class SchedulerService: + def __init__(self): + self.db = Database() + self.agents = SocialAgents() + self.bot_token = os.getenv("TELEGRAM_BOT_TOKEN") + self.chat_id = os.getenv("TELEGRAM_CHAT_ID") + self.bot = Bot(token=self.bot_token) if self.bot_token else None + + async def send_notification(self, message): + if self.bot and self.chat_id: + try: + await self.bot.send_message(chat_id=self.chat_id, text=message) + except Exception as e: + print(f"Errore invio Telegram: {e}") + + def run_single_artist_job(self, artist): + print(f"[{datetime.now()}] Avvio generazione per {artist['name']}...") + try: + # Sincronizzazione automatica da Drive e locale + os.system("python src/cloud_sync.py") + os.system("python src/sync_assets.py") + + # Validazione materiale prima della generazione + audio = self.db.get_audio_for_artist(artist['id']) + image = self.db.get_unused_image(artist['id']) + + if not audio or not image: + msg = f"⚠️ Materiale mancante per {artist['name']}. Caricare MP3 e Foto!" + print(msg) + asyncio.run(self.send_notification(msg)) + return + + results = self.agents.run_for_artist(artist) + for result in results: + if 'error' not in result: + self.db.save_draft( + artist_id=artist['id'], + title=result['title'], + caption=result['caption'], + hashtags=result['hashtags'], + image_path=result['image_path'], + image_url=result.get('image_url'), + audio_analysis=result['audio_analysis'], + video_url=result.get('video_url') + ) + asyncio.run(self.send_notification(f"✅ Post pronti per {artist['name']} (Foto + Video)!")) + except Exception as e: + err = f"❌ Errore per {artist['name']}: {str(e)}" + print(err) + asyncio.run(self.send_notification(err)) + + def start(self): + scheduler = BackgroundScheduler() + + with open('config.json', 'r') as f: + config = json.load(f) + artists = config['artists'] + + for artist in artists: + s_time = artist.get('schedule_time', '09:00') + try: + hour, minute = map(int, s_time.split(':')) + scheduler.add_job( + self.run_single_artist_job, + 'cron', + hour=hour, + minute=minute, + args=[artist], + id=f"job_{artist['id'].replace(' ', '_')}" + ) + print(f"Pianificato {artist['name']} alle {s_time}") + except Exception as e: + print(f"Errore pianificazione per {artist['name']}: {e}") + + scheduler.start() + print("Scheduler avviato.") + + try: + while True: + time.sleep(60) + except (KeyboardInterrupt, SystemExit): + scheduler.shutdown() + +if __name__ == "__main__": + service = SchedulerService() + service.start() diff --git a/src/sync_assets.py b/src/sync_assets.py new file mode 100644 index 0000000..327d6e3 --- /dev/null +++ b/src/sync_assets.py @@ -0,0 +1,35 @@ +import os +import json +from database import Database + +def sync(): + db = Database() + + # Load config to get artists + with open('config.json', 'r') as f: + config = json.load(f) + artists = [a['id'] for a in config['artists']] + + base_path = "data" + + for artist_id in artists: + # Sync Images + img_dir = os.path.join(base_path, "images", artist_id) + if os.path.exists(img_dir): + for file in os.listdir(img_dir): + if file.lower().endswith(('.png', '.jpg', '.jpeg', '.webp')): + file_path = os.path.join(img_dir, file) + db.add_media(file_path, 'image', artist_id) + + # Sync Audio + audio_dir = os.path.join(base_path, "audio", artist_id) + if os.path.exists(audio_dir): + for file in os.listdir(audio_dir): + if file.lower().endswith(('.mp3', '.wav', '.m4a')): + file_path = os.path.join(audio_dir, file) + db.add_media(file_path, 'audio', artist_id) + + print("Sincronizzazione completata.") + +if __name__ == "__main__": + sync() diff --git a/src/test_buffer_publish.py b/src/test_buffer_publish.py new file mode 100644 index 0000000..32747b7 --- /dev/null +++ b/src/test_buffer_publish.py @@ -0,0 +1,49 @@ +import os +import requests +from dotenv import load_dotenv + +load_dotenv() + +def test_publish(): + token = os.getenv("BUFFER_ACCESS_TOKEN") + url = "https://api.buffer.com/graphql" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + + mutation = """ + mutation ($input: CreatePostInput!) { + createPost(input: $input) { + ... on PostActionSuccess { + post { + id + } + } + ... on MutationError { + message + } + } + } + """ + + variables = { + "input": { + "channelId": "69f9ea855c4c051afa117baa", + "text": "Test di pubblicazione via API (In Coda) 🎼🚀", + "schedulingType": "automatic", + "mode": "addToQueue" + } + } + + print("Inviando TEST REALE (In Coda) a Buffer...") + response = requests.post(url, headers=headers, json={ + 'query': mutation, + 'variables': variables + }) + + print(f"Status Code: {response.status_code}") + print(f"Risposta: {response.text}") + +if __name__ == "__main__": + test_publish() diff --git a/src/test_draft.py b/src/test_draft.py new file mode 100644 index 0000000..1b7aaa9 --- /dev/null +++ b/src/test_draft.py @@ -0,0 +1,51 @@ +import os +import requests +from dotenv import load_dotenv + +load_dotenv() + +def test_draft(): + token = os.getenv("BUFFER_ACCESS_TOKEN") + url = "https://api.buffer.com/graphql" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + + # Canale Instagram + channel_id = "69f9ea855c4c051afa117baa" + + mutation = """ + mutation ($input: CreatePostInput!) { + createPost(input: $input) { + ... on PostActionSuccess { + post { + id + text + } + } + ... on MutationError { + message + } + } + } + """ + + variables = { + "input": { + "channelId": channel_id, + "text": "TEST BOZZA DAL BOT - " + os.popen("date").read().strip(), + "schedulingType": "automatic", + "mode": "addToQueue", + "saveToDraft": True + } + } + + response = requests.post(url, headers=headers, json={'query': mutation, 'variables': variables}) + if response.status_code == 200: + print("Risposta Buffer:", response.json()) + else: + print(f"Errore {response.status_code}: {response.text}") + +if __name__ == "__main__": + test_draft() diff --git a/src/test_generation.py b/src/test_generation.py new file mode 100644 index 0000000..d69108d --- /dev/null +++ b/src/test_generation.py @@ -0,0 +1,28 @@ +import json +from agents import SocialAgents +from database import Database + +def test(): + db = Database() + agents = SocialAgents() + + with open('config.json', 'r') as f: + config = json.load(f) + # Cerchiamo l'artista 2 + artist = next((a for a in config['artists'] if a['id'] == 'artista_2'), None) + + if artist: + print(f"--- Inizio generazione per {artist['name']} ---") + result = agents.run_for_artist(artist) + + print("\n--- RISULTATO GENERAZIONE ---") + print(f"IMMAGINE: {result['image_path']}") + print(f"\nANALISI AUDIO:\n{result['audio_analysis']}") + print(f"\nCAPTION:\n{result['caption']}") + print(f"\nHASHTAGS:\n{result['hashtags']}") + print("-----------------------------") + else: + print("Artista 2 non trovato in config.json") + +if __name__ == "__main__": + test() diff --git a/src/video_generator.py b/src/video_generator.py new file mode 100644 index 0000000..23609a5 --- /dev/null +++ b/src/video_generator.py @@ -0,0 +1,184 @@ +import os +import subprocess +import requests +import json +import time + +class VideoGenerator: + def __init__(self, cache_dir="data/cache"): + self.cache_dir = cache_dir + os.makedirs(self.cache_dir, exist_ok=True) + + def generate_video(self, media_paths, audio_path, title, start_time=0, output_path=None, bpm=120, focus_points=None): + if isinstance(media_paths, str): + media_paths = [media_paths] + + if not output_path: + output_path = os.path.join(self.cache_dir, "final_video.mp4") + + # Assicuriamoci che la cartella di destinazione esista + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + if os.path.exists(output_path): + os.remove(output_path) + + # Assicuriamoci che focus_points sia una lista della stessa lunghezza di media_paths + if not focus_points: + focus_points = [None] * len(media_paths) + + safe_title = title.replace(":", "\\:").replace("'", "").replace("%", "") + + # Calcolo tempi basato su BPM (Sincronizzazione) + beat_duration = 60.0 / bpm + num_assets = len(media_paths) + + # Costruiamo il comando FFmpeg dinamico + inputs = [] + for asset in media_paths: + inputs.extend(['-i', asset]) + + filter_complex = "" + + import random + for i, asset in enumerate(media_paths): + is_video = asset.lower().endswith(('.mp4', '.mov', '.avi', '.mkv')) + focus = focus_points[i] if i < len(focus_points) else None + + # Ritmo basato su multipli del beat + beats_per_clip = random.choice([2, 4]) + if random.random() > 0.8: beats_per_clip = 8 + + d_img_curr = beat_duration * beats_per_clip + d_frames = int(d_img_curr * 25) + + # Effetti comuni + clean_effects = [ + "eq=contrast=1.2:saturation=1.5", + "colorbalance=rs=0.2:gs=0.1:bs=0.2,eq=contrast=1.3:saturation=1.4", + "eq=brightness=0.05:contrast=1.4:saturation=1.3", + "unsharp=5:5:1.0:5:5:0.0" + ] + effect = random.choice(clean_effects) + if random.random() > 0.7: + effect += ",rgbashift=rh=2:rv=2:gh=-2:gv=-2" + + flash = f"fade=t=in:st=0:d=0.2:color=white," + + if not is_video: + # LOGICA IMMAGINE: Zoompan + zoom_speed = 0.005 if beats_per_clip < 4 else 0.002 + + if focus: + # Se abbiamo il focus dall'AI, puntiamo lì (coordinate 0-100) + fx = focus.get('x', 50) / 100.0 + fy = focus.get('y', 40) / 100.0 # Leggermente più su del centro se non specificato + + # Centriamo il pan sul punto di interesse + target_x = f"iw*{fx}-(iw/zoom/2)" + target_y = f"ih*{fy}-(ih/zoom/2)" + else: + # Altrimenti movimento casuale classico + dir_x = random.uniform(-0.5, 0.5) + dir_y = random.uniform(-0.3, 0.3) + target_x = f"iw/2-(iw/zoom/2)+({dir_x}*on)" + target_y = f"ih/2-(ih/zoom/2)+({dir_y}*on)" + + filter_complex += f"[{i}:v]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920," \ + f"zoompan=z='min(max(zoom,1.0)+{zoom_speed},1.5)':" \ + f"x='{target_x}':" \ + f"y='{target_y}':" \ + f"d={d_frames}:s=1080x1920," \ + f"{flash}{effect},setsar=1[v{i}];" + else: + # LOGICA VIDEO: Trim e Scale + # Prendiamo uno spezzone casuale (assumiamo video > 5s per sicurezza) + start_trim = random.uniform(0, 2.0) + filter_complex += f"[{i}:v]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920," \ + f"trim=start={start_trim}:duration={d_img_curr},setpts=PTS-STARTPTS," \ + f"fps=25,{flash}{effect},setsar=1[v{i}];" + + concat_inputs = "" + for i in range(num_assets): + concat_inputs += f"[v{i}]" + + filter_complex += f"{concat_inputs}concat=n={num_assets}:v=1:a=0[outv]" + + temp_block_path = os.path.join(self.cache_dir, "temp_block.mp4") + cmd_block = ['ffmpeg', '-y'] + inputs + [ + '-filter_complex', filter_complex, + '-map', '[outv]', + '-c:v', 'libx264', '-crf', '23', '-preset', 'fast', '-pix_fmt', 'yuv420p', + temp_block_path + ] + + print(f"🛠️ FFmpeg: Generazione blocco base (Filtro complesso)...") + res1 = subprocess.run(cmd_block, capture_output=True, text=True) + if res1.returncode != 0: + print(f"❌ ERRORE FFMPEG BLOCK: {res1.stderr}") + raise Exception("FFmpeg failed to create base block") + + print(f"🛠️ FFmpeg: Assemblaggio finale (Loop + Audio)...") + cmd_final = [ + 'ffmpeg', '-y', + '-stream_loop', '-1', '-i', temp_block_path, + '-ss', str(start_time), '-i', audio_path, + '-c:v', 'copy', + '-c:a', 'aac', '-b:a', '192k', + '-t', '30', '-shortest', + output_path + ] + + res2 = subprocess.run(cmd_final, capture_output=True, text=True) + if os.path.exists(temp_block_path): + os.remove(temp_block_path) + + if res2.returncode != 0: + print(f"ERRORE FFMPEG FINAL: {res2.stderr}") + raise Exception("FFmpeg failed to assemble final video") + + return output_path + + def generate_thumbnail(self, video_path, output_path=None): + """Genera una miniatura locale dal video generato""" + if not output_path: + output_path = video_path.replace(".mp4", ".jpg") + + if os.path.exists(output_path): return output_path + + print(f"🖼️ Generazione miniatura locale: {output_path}...") + cmd = [ + 'ffmpeg', '-y', '-ss', '00:00:01', + '-i', video_path, + '-vframes', '1', '-q:v', '2', + output_path + ] + res = subprocess.run(cmd, capture_output=True) + if res.returncode == 0: + return output_path + return None + + def upload_to_ephemeral(self, file_path): + """Carica su host temporanei per ottenere un URL pubblico per Buffer""" + # Tentativo 1: BashUpload (Molto semplice e diretto) + print(f"Tentativo upload su BashUpload...") + try: + filename = os.path.basename(file_path) + with open(file_path, 'rb') as f: + response = requests.put(f'https://bashupload.com/{filename}', data=f, timeout=30) + if response.status_code == 200: + # BashUpload restituisce l'URL nel corpo della risposta + for line in response.text.split('\n'): + if 'https://bashupload.com/' in line: + return line.strip().split(' ')[-1] + except Exception as e: print(f"BashUpload errore: {e}") + + # Tentativo 2: Uguu.se + print(f"Tentativo upload su Uguu.se...") + try: + with open(file_path, 'rb') as f: + response = requests.post('https://uguu.se/upload.php', files={'files[]': f}, timeout=30) + if response.status_code == 200: + return response.json()['files'][0]['url'] + except Exception as e: print(f"Uguu.se errore: {e}") + + return None