Sei passi da fare una volta sola. Alla fine mandi le clip dal telefono e ricevi il video montato. Non serve saper programmare: serve copiare, incollare e leggere due righe in italiano. Il terminale si apre due volte in tutto.
Questo passo si fa nel browser e non serve nient'altro. Telegram dà a chiunque due codici per far parlare un programma col proprio account: sono gratis e ci vogliono tre minuti.
Vai su my.telegram.org ed entra col tuo numero. Il codice di conferma arriva dentro Telegram, non per SMS: guarda nelle chat.
Clicca API development tools.
Riempi il modulo: come nome va bene qualsiasi cosa, per esempio montaggio, e come tipo scegli Desktop.
Escono api_id (un numero) e api_hash (una fila lunga di lettere e numeri). Tienili aperti, servono fra un minuto.
Questi due codici sono tuoi
Non si mandano in chat, non si fotografano, non si mettono in una cartella condivisa. Valgono come le chiavi di casa.
2 Scarica i quattro file
Quattro file piccoli. Tre sono il programma che fa il lavoro, il quarto sono le tue preferenze di montaggio. Premi i bottoni: finiscono nella cartella Download, e al posto giusto ce li porta il passo 3.
Se il browser chiede se tenerli, rispondi di sì: sono file di testo e li puoi aprire e leggere con un doppio clic. Ognuno comincia con la spiegazione di quello che fa.
3 Un comando solo
Qui si apre il terminale. È l'unico momento del genere, e non devi capire quello che scrive: devi solo leggere le ultime righe, che sono in italiano.
Come si apre
Su Mac
Premi cmd + barra spaziatrice, scrivi Terminale e premi invio. Si apre una finestra bianca o nera con del testo e un cursore che lampeggia.
Su Windows
Premi il tasto Windows, scrivi PowerShell e premi invio. Si apre una finestra blu scuro con del testo e un cursore che lampeggia.
Su Linux
Premi ctrl + alt + T, oppure cerca Terminale fra le applicazioni.
Che cosa incollare
Copia il blocco qui sotto col bottone Copia, incollalo nella finestra che si è aperta (cmd + V su Mac, ctrl + V altrove) e premi invio. Ci mette da uno a dieci minuti, e intanto scrive quello che sta facendo.
Su Mac
# Prepara la postazione. Dice sempre che cosa sta per fare.
# Si puo' rilanciare quante volte si vuole: quello che c'e' gia' lo salta.
set -u
C="$HOME/video-ai"; V="$HOME/.venv-tg"
dire(){ printf '\n %s\n' "$1"; }
sos(){ printf '\n ATTENZIONE: %s\n\n' "$1"; exit 1; }
dire "1/5 Guardo che cosa c'e' gia'"
if command -v ffmpeg >/dev/null 2>&1; then
printf ' ffmpeg: c%s\n' "'e'"
elif command -v brew >/dev/null 2>&1; then
dire " ffmpeg manca. Lo installo con Homebrew (qualche minuto)"
brew install ffmpeg || sos "l'installazione di ffmpeg non e' riuscita"
elif command -v apt >/dev/null 2>&1; then
sos "manca ffmpeg. Lancia: sudo apt update && sudo apt install -y ffmpeg python3-venv"
else
sos "manca ffmpeg e non trovo come installarlo. Su Mac installa prima Homebrew da brew.sh"
fi
command -v python3 >/dev/null 2>&1 || sos "manca python3. Su Mac: brew install python"
printf ' python3: %s\n' "$(python3 -V 2>&1 | awk '{print $2}')"
dire "2/5 Preparo lo spazio di lavoro"
if [ -x "$V/bin/python" ]; then printf ' %s: c%s gia\n' "$V" "'e'"
else python3 -m venv "$V" || sos "non riesco a creare $V"; printf ' creato %s\n' "$V"; fi
dire "3/5 Installo le due librerie (un minuto)"
"$V/bin/pip" install --quiet --upgrade pip
"$V/bin/pip" install --quiet telethon cryptg || sos "l'installazione delle librerie non e' riuscita"
"$V/bin/python" -c "import telethon, cryptg" || sos "le librerie non si caricano"
printf ' telethon e cryptg: a posto\n'
dire "4/5 Metto i file al loro posto"
mkdir -p "$C"
trovati=0
for f in tg.py monta.py sub.js regole.md; do
if [ -f "$C/$f" ]; then trovati=$((trovati+1))
elif [ -f "$HOME/Downloads/$f" ]; then mv "$HOME/Downloads/$f" "$C/$f"; trovati=$((trovati+1))
printf ' spostato %s\n' "$f"
else printf ' MANCA %s: scaricalo dalla guida (passo 2)\n' "$f"; fi
done
dire "5/5 Riepilogo"
printf ' cartella di lavoro: %s\n' "$C"
printf ' file pronti: %s su 4\n' "$trovati"
if [ -n "${TG_API_ID:-}" ]; then printf ' codici di Telegram: ci sono\n'
else printf ' codici di Telegram: ANCORA DA METTERE (passo 1 della guida)\n'; fi
if [ "$trovati" -eq 4 ] && [ -n "${TG_API_ID:-}" ]; then
printf '\n Tutto pronto. Il prossimo passo e collegare l%saccount.\n\n' "'"
else
printf '\n Finisci i passi segnati qui sopra, poi rilancia questo stesso comando.\n\n'
fi
Su Windows
# Prepara la postazione. Dice sempre che cosa sta per fare.
# Si puo' rilanciare quante volte si vuole: quello che c'e' gia' lo salta.
$C = "$HOME\video-ai"; $V = "$HOME\.venv-tg"
Write-Host "`n 1/5 Guardo che cosa c'e' gia'"
if (Get-Command ffmpeg -ErrorAction SilentlyContinue) { Write-Host " ffmpeg: c'e'" }
elseif (Get-Command winget -ErrorAction SilentlyContinue) {
Write-Host " ffmpeg manca. Lo installo (qualche minuto)"
winget install --id Gyan.FFmpeg -e --accept-source-agreements --accept-package-agreements
Write-Host " CHIUDI questa finestra, riaprila e rilancia questo comando."
exit
} else { Write-Host "`n ATTENZIONE: manca ffmpeg e non trovo winget. Installa ffmpeg da ffmpeg.org`n"; exit 1 }
$py = Get-Command py -ErrorAction SilentlyContinue
if (-not $py) { $py = Get-Command python -ErrorAction SilentlyContinue }
if (-not $py) {
Write-Host " python manca. Lo installo"
winget install --id Python.Python.3.12 -e --accept-source-agreements --accept-package-agreements
Write-Host " CHIUDI questa finestra, riaprila e rilancia questo comando."
exit
}
Write-Host " python: c'e'"
Write-Host "`n 2/5 Preparo lo spazio di lavoro"
if (Test-Path "$V\Scripts\python.exe") { Write-Host " $V : c'e' gia" }
else { & $py.Source -m venv $V; Write-Host " creato $V" }
Write-Host "`n 3/5 Installo le due librerie (un minuto)"
& "$V\Scripts\python.exe" -m pip install --quiet --upgrade pip
& "$V\Scripts\python.exe" -m pip install --quiet telethon cryptg
& "$V\Scripts\python.exe" -c "import telethon, cryptg"
if ($LASTEXITCODE -ne 0) { Write-Host "`n ATTENZIONE: le librerie non si caricano`n"; exit 1 }
Write-Host " telethon e cryptg: a posto"
Write-Host "`n 4/5 Metto i file al loro posto"
New-Item -ItemType Directory -Force -Path $C | Out-Null
$trovati = 0
foreach ($f in @("tg.py","monta.py","sub.js","regole.md")) {
if (Test-Path "$C\$f") { $trovati++ }
elseif (Test-Path "$HOME\Downloads\$f") {
Move-Item "$HOME\Downloads\$f" "$C\$f"; $trovati++; Write-Host " spostato $f"
} else { Write-Host " MANCA $f : scaricalo dalla guida (passo 2)" }
}
Write-Host "`n 5/5 Riepilogo"
Write-Host " cartella di lavoro: $C"
Write-Host " file pronti: $trovati su 4"
if ($env:TG_API_ID) { Write-Host " codici di Telegram: ci sono" }
else { Write-Host " codici di Telegram: ANCORA DA METTERE (passo 1 della guida)" }
if ($trovati -eq 4 -and $env:TG_API_ID) { Write-Host "`n Tutto pronto. Il prossimo passo e' collegare l'account.`n" }
else { Write-Host "`n Finisci i passi segnati qui sopra, poi rilancia questo stesso comando.`n" }
Su Linux
# Prepara la postazione. Dice sempre che cosa sta per fare.
# Si puo' rilanciare quante volte si vuole: quello che c'e' gia' lo salta.
set -u
C="$HOME/video-ai"; V="$HOME/.venv-tg"
dire(){ printf '\n %s\n' "$1"; }
sos(){ printf '\n ATTENZIONE: %s\n\n' "$1"; exit 1; }
dire "1/5 Guardo che cosa c'e' gia'"
if command -v ffmpeg >/dev/null 2>&1; then
printf ' ffmpeg: c%s\n' "'e'"
elif command -v brew >/dev/null 2>&1; then
dire " ffmpeg manca. Lo installo con Homebrew (qualche minuto)"
brew install ffmpeg || sos "l'installazione di ffmpeg non e' riuscita"
elif command -v apt >/dev/null 2>&1; then
sos "manca ffmpeg. Lancia: sudo apt update && sudo apt install -y ffmpeg python3-venv"
else
sos "manca ffmpeg e non trovo come installarlo. Su Mac installa prima Homebrew da brew.sh"
fi
command -v python3 >/dev/null 2>&1 || sos "manca python3. Su Mac: brew install python"
printf ' python3: %s\n' "$(python3 -V 2>&1 | awk '{print $2}')"
dire "2/5 Preparo lo spazio di lavoro"
if [ -x "$V/bin/python" ]; then printf ' %s: c%s gia\n' "$V" "'e'"
else python3 -m venv "$V" || sos "non riesco a creare $V"; printf ' creato %s\n' "$V"; fi
dire "3/5 Installo le due librerie (un minuto)"
"$V/bin/pip" install --quiet --upgrade pip
"$V/bin/pip" install --quiet telethon cryptg || sos "l'installazione delle librerie non e' riuscita"
"$V/bin/python" -c "import telethon, cryptg" || sos "le librerie non si caricano"
printf ' telethon e cryptg: a posto\n'
dire "4/5 Metto i file al loro posto"
mkdir -p "$C"
trovati=0
for f in tg.py monta.py sub.js regole.md; do
if [ -f "$C/$f" ]; then trovati=$((trovati+1))
elif [ -f "$HOME/Downloads/$f" ]; then mv "$HOME/Downloads/$f" "$C/$f"; trovati=$((trovati+1))
printf ' spostato %s\n' "$f"
else printf ' MANCA %s: scaricalo dalla guida (passo 2)\n' "$f"; fi
done
dire "5/5 Riepilogo"
printf ' cartella di lavoro: %s\n' "$C"
printf ' file pronti: %s su 4\n' "$trovati"
if [ -n "${TG_API_ID:-}" ]; then printf ' codici di Telegram: ci sono\n'
else printf ' codici di Telegram: ANCORA DA METTERE (passo 1 della guida)\n'; fi
if [ "$trovati" -eq 4 ] && [ -n "${TG_API_ID:-}" ]; then
printf '\n Tutto pronto. Il prossimo passo e collegare l%saccount.\n\n' "'"
else
printf '\n Finisci i passi segnati qui sopra, poi rilancia questo stesso comando.\n\n'
fi
Che cosa deve uscire
Cinque blocchi numerati da 1/5 a 5/5, e in fondo il riepilogo. Se dice file pronti: 4 su 4 sei a posto. Se ti dice di chiudere e riaprire la finestra, fallo e incolla di nuovo lo stesso comando: non si rompe niente, quello che ha già fatto lo salta.
Prima di incollare
Quel blocco è scritto qui per intero e si legge: controlla che cosa c'è, installa quello che manca, crea una cartella e ci sposta dentro i quattro file. Non tocca altro.
E vale in generale, non solo qui: non incollare mai nel terminale un comando che non riesci a leggere, da nessun sito, nemmeno da questo.
I due codici del passo 1
Adesso vanno scritti dove il computer li ritrova ogni volta. Metti i tuoi due valori al posto di quelli finti, poi incolla:
Su Mac e Linux una riga id= col tuo numero. Se esce id= e basta, qualcosa non ha funzionato: riprova controllando le virgolette. Su Windows non esce niente ed è normale, ma devi chiudere la finestra e riaprirla.
4 Collega il tuo account
Una volta sola. Ti chiede il numero di telefono con il prefisso (+39...) e poi il codice che arriva dentro Telegram.
Su Mac
cd ~/video-ai
~/.venv-tg/bin/python tg.py login
Su Windows
cd $HOME\video-ai
& "$HOME\.venv-tg\Scripts\python.exe" tg.py login
Su Linux
cd ~/video-ai
~/.venv-tg/bin/python tg.py login
Che cosa deve uscire
Una riga Dentro come col tuo nome. Da qui in poi non te lo chiede più.
Adesso sul computer c'è una chiave del tuo Telegram
Si chiama sessione.session e sta in una cartella nascosta del tuo profilo. Chi se la porta via entra nel tuo Telegram senza bisogno di nessun codice, e non solo nei video: in tutte le tue conversazioni.
Quindi non si copia su chiavette o altri computer, e non si mette in cartelle sincronizzate con la nuvola. Il giorno che non ti serve più, o anche solo se hai un dubbio, apri Telegram → Impostazioni → Dispositivi e chiudi la sessione: dieci secondi e quella chiave non vale più niente.
5 Le tue regole di montaggio
Il file regole.md che hai scaricato è dove scrivi come vuoi i tuoi video. L'assistente lo legge prima di ogni lavoro, quindi queste cose non le devi più ripetere.
Aprilo con un doppio clic, oppure così:
Su Mac
open -e ~/video-ai/regole.md
Su Windows
notepad $HOME\video-ai\regole.md
Su Linux
xdg-open ~/video-ai/regole.md
È un file di testo normale. Questo è il punto di partenza, e copre verticale e orizzontale:
# Come monto i miei video
## Il formato, se non dico altro
- Verticale 9:16, 1080x1920, 30 fotogrammi al secondo.
- Se scrivo "orizzontale" o "YouTube": 16:9, 1920x1080, sempre 30.
- Le clip che non stanno nella cornice si tagliano al centro.
Se dentro c'e' uno schermo, una lavagna o del testo, sfondo sfocato
invece di tagliare.
## Sempre
- Via il silenzio all'inizio e alla fine.
- Musica a 0.13, che sfuma negli ultimi 2 secondi.
- Le voci pareggiate fra una clip e l'altra.
- Sottotitoli impressi, bianchi con bordo nero.
In verticale: in basso, a 260 px dal fondo, corpo 62.
In orizzontale: in basso, a 90 px dal fondo, corpo 44.
## Quanto lunghi
- Verticale: sotto i 60 secondi. Se viene piu' lungo, dimmelo.
- Orizzontale: nessun limite, ma dimmi quanto e' venuto.
## Cosa non fare mai
- Non accelerare il parlato.
- Niente effetti di transizione. Tagli netti.
- Non inventare sottotitoli: se una parola non si capisce,
scrivila come si sente e segnalamela.
## Quando fermarti e chiedere
- Se una clip e' girata storta.
- Se in una clip si sente una voce che non e' la mia.
- Se il video finito supererebbe il doppio della lunghezza solita.
Non riscriverlo tutto adesso
Verrebbe pieno di regole per casi che non ti capitano e vuoto sui casi veri. Cambia i numeri che già conosci (il formato, dove vuoi i sottotitoli, quanto lungo) e lascia stare il resto. Poi aggiungi una riga ogni volta che un video esce diverso da come lo volevi: dopo un mese quel file è il tuo modo di montare, scritto.
La sezione che conta di più è l'ultima, quando fermarti e chiedere. Senza quella l'assistente tira dritto anche quando ha capito male.
6 Il primo video
Ci sei. Il giro di tutti i giorni è questo, e da qui in avanti sono due minuti.
Dal telefono
Apri Telegram e vai nei Messaggi salvati (in cima all'elenco delle chat, con l'icona del segnalibro). Manda le clip nell'ordine in cui le vuoi, poi scrivi un messaggio che comincia con /monta e di' che cosa vuoi, come lo diresti a un montatore:
/monta verticale 9:16, la prima parte mossa quindi taglia i primi 2 secondi, musica sotto piano, sottotitoli
/monta orizzontale 16:9, unisci tutto, musica sotto, sottotitoli
Dal computer
Questo comando scarica le clip e ti dice che cosa sono: quanto durano, che formato hanno, se hanno l'audio.
Su Mac
cd ~/video-ai
~/.venv-tg/bin/python tg.py prendi
Su Windows
cd $HOME\video-ai
& "$HOME\.venv-tg\Scripts\python.exe" tg.py prendi
Su Linux
cd ~/video-ai
~/.venv-tg/bin/python tg.py prendi
Poi apri l'assistente nella cartella video-ai e digli, con parole tue: leggi regole.md, prendi il lavoro da Telegram e montalo. Quando il video è pronto te lo rimanda nella stessa chat.
Guardalo prima di pubblicarlo, e guardane la fine
Un montaggio sbagliato quasi mai dà un errore: il file esce, si apre, dura il tempo giusto. I guasti si vedono nella seconda metà, di solito come audio che sparisce o come immagine che cambia formato. Trenta secondi di controllo, ogni volta. È l'unica parte che non si delega.
Se qualcosa non torna
Incolla questo nel terminale: fa l'elenco di quello che c'è e di quello che manca, e le righe [manca] ti dicono a quale passo tornare.
Se resti bloccato, scrivimi: dimmi a quale passo sei e copiami quello che ti ha risposto il terminale. Con quelle due cose si capisce quasi sempre al primo colpo.
#!/usr/bin/env python3
"""I video vanno e vengono dai Messaggi salvati di Telegram.
python3 _strumenti/tg.py login # una volta sola, chiede il codice
python3 _strumenti/tg.py prendi # scarica le clip del lavoro piu' recente
python3 _strumenti/tg.py manda montato.mp4 --testo "fatto" --fatto 12345
## Perche' l'account e non un bot
Un bot di Telegram **scarica al massimo 20 MB per file** e ne manda 50
(core.telegram.org/bots/api: «For the moment, bots can download files of up to
20MB in size»). Un minuto girato col telefono ne pesa fra 150 e 400: con un bot
il video non si riesce nemmeno a prendere. Dal proprio account, che parla
MTProto, il limite e' 2 GB per file (4 con Premium). Quindi si usa l'account, e
i Messaggi salvati fanno da casella: e' una chat che esiste gia', e' solo tua, e
niente di quello che ci metti arriva a qualcun altro.
⚠️ **Il file di sessione vale quanto la password.** Chi se lo porta via entra nel
tuo Telegram senza codice. Sta in ~/.tg-video/ con permessi 600, mai dentro a
questo repository, e si revoca da Telegram → Impostazioni → Dispositivi.
⚠️ **api_id e api_hash si prendono da my.telegram.org**, sezione «API
development tools», e stanno in due variabili d'ambiente. Sono identificatori
dell'applicazione, non credenziali d'accesso, ma restano roba tua:
export TG_API_ID=...
export TG_API_HASH=...
## Come si segna un lavoro
Nei Messaggi salvati mandi le clip, poi un messaggio che comincia con **/monta**
seguito da cosa vuoi. `prendi` cerca il /monta piu' recente che non ha ancora
lavorato, raccoglie i video mandati prima di quello (fino al /monta precedente),
li scarica e scrive `lavoro.json` con le istruzioni e le misure di ogni clip.
Il confine e' il /monta e non «gli ultimi N video» perche' cosi' si possono
accodare piu' lavori: mandi tre clip e /monta, poi altre due e un altro /monta,
e restano due lavori distinti invece di un mucchio solo.
"""
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
CASA = Path.home() / ".tg-video"
SESSIONE = CASA / "sessione"
STATO = CASA / "stato.json"
LAVORI = CASA / "lavori"
def client():
try:
from telethon.sync import TelegramClient
except ImportError:
sys.exit("Manca Telethon. ~/.venv-tg/bin/pip install telethon cryptg")
api_id, api_hash = os.environ.get("TG_API_ID"), os.environ.get("TG_API_HASH")
if not api_id or not api_hash:
sys.exit("Servono TG_API_ID e TG_API_HASH (da my.telegram.org).")
CASA.mkdir(mode=0o700, exist_ok=True)
return TelegramClient(str(SESSIONE), int(api_id), api_hash)
def stato_leggi():
if STATO.exists():
return json.loads(STATO.read_text())
return {"ultimo": 0}
def stato_scrivi(d):
CASA.mkdir(mode=0o700, exist_ok=True)
STATO.write_text(json.dumps(d, indent=2))
STATO.chmod(0o600)
def misure(percorso):
"""Cosa c'e' dentro a una clip. Claude legge di qui prima di decidere il taglio."""
try:
out = subprocess.run(
["ffprobe", "-v", "error", "-print_format", "json",
"-show_format", "-show_streams", str(percorso)],
capture_output=True, text=True, check=True).stdout
d = json.loads(out)
except Exception as e:
return {"errore": str(e)}
v = next((s for s in d["streams"] if s["codec_type"] == "video"), None)
a = next((s for s in d["streams"] if s["codec_type"] == "audio"), None)
m = {"durata": round(float(d["format"].get("duration", 0)), 2),
"peso_mb": round(int(d["format"].get("size", 0)) / 1048576, 1),
"audio": bool(a)}
if v:
fps = v.get("avg_frame_rate", "0/1")
n, _, den = fps.partition("/")
m |= {"larghezza": v.get("width"), "altezza": v.get("height"),
"codec": v.get("codec_name"),
"fps": round(int(n) / int(den or 1), 2) if den and int(den or 0) else None}
# Il telefono gira in verticale e scrive la rotazione a parte: senza
# guardarla si monta un video coricato.
for sd in v.get("side_data_list", []) or []:
if "rotation" in sd:
m["rotazione"] = sd["rotation"]
m["verticale"] = bool(m.get("altezza", 0) > m.get("larghezza", 0)) ^ \
(abs(m.get("rotazione", 0)) == 90)
if a:
m["audio_codec"] = a.get("codec_name")
return m
def e_video(msg):
if msg.video:
return True
doc = getattr(msg, "document", None)
return bool(doc and (doc.mime_type or "").startswith("video/"))
def cmd_login(_):
with client() as c:
io = c.get_me()
SESSIONE.with_suffix(".session").chmod(0o600)
print(f"Dentro come {io.first_name} (@{io.username or io.id}).")
print(f"Sessione: {SESSIONE}.session (vale quanto la password)")
def cmd_prendi(a):
stato = stato_leggi()
with client() as c:
msgs = list(c.iter_messages("me", limit=a.quanti)) # dal piu' recente
ordine = a.comando.lower()
inizio = next((i for i, m in enumerate(msgs)
if (m.message or "").strip().lower().startswith(ordine)
and m.id > stato["ultimo"]), None)
if inizio is None:
print("Nessun /monta nuovo nei Messaggi salvati.")
return 1
ordine_msg = msgs[inizio]
istruzioni = (ordine_msg.message or "")[len(ordine):].strip()
clip = []
for m in msgs[inizio + 1:]: # all'indietro nel tempo
if (m.message or "").strip().lower().startswith(ordine):
break # il lavoro precedente
if e_video(m):
clip.append(m)
clip.reverse() # nell'ordine in cui le hai mandate
if not clip:
print("Il /monta c'e' ma prima non ci sono video.")
return 1
dove = Path(a.dove) if a.dove else LAVORI / str(ordine_msg.id)
dove.mkdir(parents=True, exist_ok=True)
lavoro = {"id_ordine": ordine_msg.id, "istruzioni": istruzioni,
"cartella": str(dove), "clip": []}
for n, m in enumerate(clip, 1):
nome = dove / f"clip-{n:02d}.mp4"
if not nome.exists():
print(f" scarico clip {n}/{len(clip)}…", flush=True)
c.download_media(m, file=str(nome))
voce = {"n": n, "file": str(nome), "id": m.id,
"didascalia": (m.message or "").strip()}
voce |= misure(nome)
lavoro["clip"].append(voce)
(dove / "lavoro.json").write_text(json.dumps(lavoro, indent=2, ensure_ascii=False))
print(json.dumps(lavoro, indent=2, ensure_ascii=False))
return 0
def cmd_manda(a):
f = Path(a.file)
if not f.exists():
sys.exit(f"Non esiste: {f}")
with client() as c:
c.send_file("me", str(f), caption=a.testo or "", supports_streaming=True,
reply_to=a.rispondi)
print(f"Mandato {f.name} ({f.stat().st_size / 1048576:.1f} MB) nei Messaggi salvati.")
if a.fatto:
stato_scrivi(stato_leggi() | {"ultimo": a.fatto})
print(f"Lavoro {a.fatto} segnato come fatto.")
def main():
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
s = p.add_subparsers(dest="cmd", required=True)
s.add_parser("login", help="entra e crea la sessione")
g = s.add_parser("prendi", help="scarica le clip del /monta piu' recente")
g.add_argument("--quanti", type=int, default=60, help="quanti messaggi guardare")
g.add_argument("--dove", help="cartella di lavoro")
g.add_argument("--comando", default="/monta")
m = s.add_parser("manda", help="rimanda un file nei Messaggi salvati")
m.add_argument("file")
m.add_argument("--testo", default="")
m.add_argument("--rispondi", type=int, help="id del messaggio a cui rispondere")
m.add_argument("--fatto", type=int, help="id del /monta da segnare come fatto")
a = p.parse_args()
sys.exit({"login": cmd_login, "prendi": cmd_prendi, "manda": cmd_manda}[a.cmd](a) or 0)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""I pezzi del montaggio, uno per comando. Li mette in fila Claude.
python3 _strumenti/monta.py normalizza clip-*.mp4 --formato reel --out pronte/
python3 _strumenti/monta.py unisci pronte/*.mp4 --out grezzo.mp4
python3 _strumenti/monta.py taglia grezzo.mp4 --da 1.5 --a 28 --out tagliato.mp4
python3 _strumenti/monta.py musica tagliato.mp4 --brano bed.mp3 --out finito.mp4
python3 _strumenti/monta.py sottotitoli finito.mp4 --srt testo.srt --out sub.mp4
Ogni comando stampa la riga di ffmpeg che sta per lanciare, e con `--secco` la
stampa e basta. Serve a due cose: si capisce cosa e' successo quando il
risultato non e' quello che si voleva, e in un corso la riga si legge.
## Perche' `normalizza` esiste, ed e' il comando che conta
Unire due video presi da telefoni diversi fallisce quasi sempre, e fallisce in
modi che sembrano magia: l'audio va fuori sincrono a meta', il secondo pezzo
esce schiacciato, oppure il file si apre ma dura tre secondi. Il motivo e' che
`concat` incolla flussi, e pretende che siano identici: stessa misura, stesso
fps, stessi pixel quadrati, stesso codec audio, e **l'audio ci deve essere**.
Una clip girata senza microfono non ha la traccia audio, e a quel punto tutte
le clip dopo di lei perdono il suono.
Quindi prima si porta tutto allo stesso formato (e chi non ha audio riceve
silenzio), poi si incolla senza ricomprimere. E' anche il giro piu' veloce:
si comprime una volta sola.
## I formati
reel 1080x1920 verticale, per Instagram e Shorts
youtube 1920x1080 orizzontale
quadrato 1080x1080
## Come ci si sta dentro (`--adatta`)
riempi ingrandisce e taglia i bordi. Il soggetto resta grande. Preferito.
sfoca il video intero dentro, e dietro lo stesso video sfocato a riempire.
barre il video intero dentro, e dietro il nero.
"""
import argparse
import json
import re
import shlex
import subprocess
import sys
from pathlib import Path
FORMATI = {"reel": (1080, 1920), "youtube": (1920, 1080), "quadrato": (1080, 1080),
"verticale": (1080, 1920), "orizzontale": (1920, 1080)}
def codec_buono():
"""Il codificatore migliore CHE C'E' su questa macchina.
⚠️ Prima qui c'era `h264_videotoolbox` scritto fisso, e su Windows e Linux
quel codificatore NON ESISTE: ffmpeg si fermava con «Unknown encoder». Il
montaggio non e' roba da Mac, quindi la scelta si fa guardando.
`libx264` c'e' sempre e lavora con il processore. `h264_videotoolbox` usa
il chip dei Mac ed e' parecchio piu' veloce, quindi si prende quando c'e'.
"""
try:
out = subprocess.run(["ffmpeg", "-hide_banner", "-encoders"],
capture_output=True, text=True).stdout
except FileNotFoundError:
sys.exit("ffmpeg non risulta installato.")
if sys.platform == "darwin" and re.search(r"\sh264_videotoolbox\s", out):
return "h264_videotoolbox"
if re.search(r"\slibx264\s", out):
return "libx264"
sys.exit("Questo ffmpeg non ha nemmeno libx264: va reinstallato.")
def qualita(codec, bitrate):
"""Come si chiede la qualita' cambia da un codificatore all'altro.
A `libx264` si dice `-crf`, che e' un obiettivo di qualita' e lascia
decidere quanti bit servono. `h264_videotoolbox` il crf non lo ascolta e
vuole un bitrate. Passare il bitrate a libx264 funziona e spreca spazio.
"""
if codec == "libx264":
return ["-crf", "20", "-preset", "veryfast"]
return ["-b:v", bitrate]
def lancia(cmd, secco=False):
print(" " + " ".join(shlex.quote(c) for c in cmd), file=sys.stderr)
if secco:
return
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode:
sys.exit(f"ffmpeg si e' fermato:\n{r.stderr[-2500:]}")
def sonda(f):
out = subprocess.run(["ffprobe", "-v", "error", "-print_format", "json",
"-show_format", "-show_streams", str(f)],
capture_output=True, text=True, check=True).stdout
return json.loads(out)
def ha_audio(f):
return any(s["codec_type"] == "audio" for s in sonda(f)["streams"])
def durata(f):
return float(sonda(f)["format"]["duration"])
def inquadra(l, h, adatta):
"""Il filtro che porta un video qualsiasi dentro a una cornice l x h."""
if adatta == "riempi":
return (f"scale={l}:{h}:force_original_aspect_ratio=increase,"
f"crop={l}:{h},setsar=1")
if adatta == "sfoca":
return (f"split[a][b];"
f"[a]scale={l}:{h}:force_original_aspect_ratio=increase,"
f"crop={l}:{h},boxblur=28:4[sf];"
f"[b]scale={l}:{h}:force_original_aspect_ratio=decrease[fr];"
f"[sf][fr]overlay=(W-w)/2:(H-h)/2,setsar=1")
return (f"scale={l}:{h}:force_original_aspect_ratio=decrease,"
f"pad={l}:{h}:(ow-iw)/2:(oh-ih)/2:black,setsar=1")
def cmd_normalizza(a):
l, h = FORMATI[a.formato]
fuori = Path(a.out)
fuori.mkdir(parents=True, exist_ok=True)
fatte = []
for n, f in enumerate(a.clip, 1):
dest = fuori / f"{n:02d}-{Path(f).stem}.mp4"
cmd = ["ffmpeg", "-y", "-i", str(f)]
if not ha_audio(f):
# Senza questa riga la clip muta fa sparire l'audio di tutte le altre.
cmd += ["-f", "lavfi", "-t", str(durata(f)),
"-i", f"anullsrc=channel_layout=stereo:sample_rate={a.hz}"]
mappa = ["-map", "0:v:0", "-map", "1:a:0"]
else:
mappa = ["-map", "0:v:0", "-map", "0:a:0"]
cmd += mappa + [
"-vf", f"{inquadra(l, h, a.adatta)},fps={a.fps}",
"-c:v", a.codec, *qualita(a.codec, a.bitrate), "-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "192k", "-ar", str(a.hz), "-ac", "2",
"-video_track_timescale", "90000", "-movflags", "+faststart",
str(dest)]
lancia(cmd, a.secco)
fatte.append(str(dest))
print("\n".join(fatte))
def cmd_unisci(a):
lista = Path(a.out).with_suffix(".lista.txt")
lista.write_text("".join(f"file {shlex.quote(str(Path(c).resolve()))}\n" for c in a.clip))
lancia(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(lista),
"-c", "copy", "-movflags", "+faststart", a.out], a.secco)
if not a.secco:
lista.unlink(missing_ok=True)
print(a.out)
def cmd_taglia(a):
cmd = ["ffmpeg", "-y", "-ss", str(a.da)]
if a.a is not None:
cmd += ["-to", str(a.a)]
cmd += ["-i", a.video, "-c:v", a.codec, *qualita(a.codec, a.bitrate), "-c:a", "aac",
"-b:a", "192k", "-movflags", "+faststart", a.out]
lancia(cmd, a.secco)
print(a.out)
def cmd_musica(a):
# La musica si accorcia sul video (non il contrario) e sfuma in coda.
d = durata(a.video)
filtro = (f"[1:a]volume={a.volume},afade=t=out:st={max(d - 2, 0):.2f}:d=2[m];"
f"[0:a][m]amix=inputs=2:duration=first:dropout_transition=0,"
f"dynaudnorm=f=250:g=15[a]")
lancia(["ffmpeg", "-y", "-i", a.video, "-stream_loop", "-1", "-i", a.brano,
"-filter_complex", filtro, "-map", "0:v", "-map", "[a]",
"-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-t", f"{d:.3f}",
"-movflags", "+faststart", a.out], a.secco)
print(a.out)
def leggi_srt(f):
"""Un .srt in blocchi {da, a, testo}. Niente libreria: il formato e' tre righe."""
def sec(t):
h, m, resto = t.split(":")
s, _, ms = resto.replace(",", ".").partition(".")
return int(h) * 3600 + int(m) * 60 + int(s) + int(ms.ljust(3, "0")) / 1000
blocchi = []
for pezzo in re.split(r"\n\s*\n", Path(f).read_text(encoding="utf-8").strip()):
righe = [r for r in pezzo.splitlines() if r.strip()]
tempo = next((i for i, r in enumerate(righe) if "-->" in r), None)
if tempo is None:
continue
da, _, a = righe[tempo].partition("-->")
blocchi.append({"da": sec(da.strip()), "a": sec(a.strip()),
"testo": "\n".join(righe[tempo + 1:])})
return blocchi
def ha_libass():
out = subprocess.run(["ffmpeg", "-hide_banner", "-filters"],
capture_output=True, text=True).stdout
return bool(re.search(r"^\s*\S+\s+subtitles\s", out, re.M))
def cmd_sottotitoli(a):
"""Imprime un .srt nel video.
Con libass si usa il filtro `subtitles`. Senza (e l'ffmpeg di Homebrew non
ce l'ha) i blocchi li disegna il browser con `sub.js` e si sovrappongono
come immagini: stesso risultato, e col carattere del sito invece che con
quello di sistema.
"""
if a.libass and ha_libass():
stile = (f"FontName={a.font},Fontsize={a.corpo},PrimaryColour=&H00FFFFFF,"
f"OutlineColour=&H00000000,BorderStyle=1,Outline=3,Shadow=0,"
f"Alignment=2,MarginV={a.basso}")
via = str(Path(a.srt).resolve()).replace("\\", "/").replace(":", "\\:")
lancia(["ffmpeg", "-y", "-i", a.video,
"-vf", f"subtitles='{via}':force_style='{stile}'",
"-c:v", a.codec, *qualita(a.codec, a.bitrate), "-c:a", "copy",
"-movflags", "+faststart", a.out], a.secco)
print(a.out)
return
blocchi = leggi_srt(a.srt)
if not blocchi:
sys.exit(f"Nessun blocco leggibile in {a.srt}")
v = next(s for s in sonda(a.video)["streams"] if s["codec_type"] == "video")
lav = Path(a.out).parent / (Path(a.out).stem + "-sub")
lav.mkdir(parents=True, exist_ok=True)
for n, b in enumerate(blocchi, 1):
b["file"] = str(lav / f"{n:03d}.png")
manifesto = lav / "manifesto.json"
manifesto.write_text(json.dumps(
{"larghezza": v["width"], "altezza": v["height"],
"corpo": a.corpo, "basso": a.basso, "righe": blocchi},
indent=2, ensure_ascii=False))
print(f" node _strumenti/sub.js {manifesto}", file=sys.stderr)
if not a.secco:
r = subprocess.run(["node", str(Path(__file__).parent / "sub.js"), str(manifesto)])
if r.returncode:
sys.exit("sub.js si e' fermato.")
cmd = ["ffmpeg", "-y", "-i", a.video]
for b in blocchi:
# ⚠️ `-loop 1` va messo sempre: senza, l'immagine consegna UN
# fotogramma a t=0 e finisce, e l'overlay nella sua finestra non ha piu'
# niente da sovrapporre. Il guasto e' infido perche' i sottotitoli dei
# primi secondi si vedono lo stesso, e sembra che funzioni.
cmd += ["-loop", "1", "-i", b["file"]]
catena, ultimo = [], "0:v"
for i, b in enumerate(blocchi, 1):
eti = f"v{i}"
# `enable` accende la sovrapposizione solo nella finestra del blocco:
# un solo passaggio sul video, non uno per sottotitolo.
catena.append(f"[{ultimo}][{i}:v]overlay=0:0:"
f"enable='between(t,{b['da']:.3f},{b['a']:.3f})'[{eti}]")
ultimo = eti
cmd += ["-filter_complex", ";".join(catena), "-map", f"[{ultimo}]", "-map", "0:a?",
"-shortest", # le immagini in loop sono infinite: senza, non finisce mai
"-c:v", a.codec, *qualita(a.codec, a.bitrate), "-c:a", "copy",
"-movflags", "+faststart", a.out]
lancia(cmd, a.secco)
print(a.out)
def cmd_copertina(a):
lancia(["ffmpeg", "-y", "-ss", str(a.quando), "-i", a.video, "-frames:v", "1",
"-q:v", "2", a.out], a.secco)
print(a.out)
def cmd_guarda(a):
for f in a.video:
d = sonda(f)
v = next(s for s in d["streams"] if s["codec_type"] == "video")
print(f"{f}: {v['width']}x{v['height']} {float(d['format']['duration']):.2f}s "
f"{v['codec_name']} audio={'si' if ha_audio(f) else 'NO'} "
f"{int(d['format']['size'])/1048576:.1f} MB")
def main():
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
p.add_argument("--secco", action="store_true", help="stampa i comandi e basta")
# «auto» guarda che cosa c'e' su questa macchina: vedi codec_buono().
p.add_argument("--codec", default="auto")
p.add_argument("--bitrate", default="8M")
s = p.add_subparsers(dest="cmd", required=True)
n = s.add_parser("normalizza", help="tutte le clip allo stesso formato")
n.add_argument("clip", nargs="+")
n.add_argument("--formato", default="reel", choices=sorted(FORMATI))
n.add_argument("--adatta", default="riempi", choices=["riempi", "sfoca", "barre"])
n.add_argument("--fps", type=int, default=30)
n.add_argument("--hz", type=int, default=48000)
n.add_argument("--out", default="pronte")
u = s.add_parser("unisci", help="incolla le clip normalizzate")
u.add_argument("clip", nargs="+")
u.add_argument("--out", required=True)
t = s.add_parser("taglia", help="tiene solo un pezzo")
t.add_argument("video")
t.add_argument("--da", type=float, default=0)
t.add_argument("--a", type=float)
t.add_argument("--out", required=True)
m = s.add_parser("musica", help="mette un tappeto sotto")
m.add_argument("video")
m.add_argument("--brano", required=True)
m.add_argument("--volume", type=float, default=0.13)
m.add_argument("--out", required=True)
b = s.add_parser("sottotitoli", help="imprime un .srt nel video")
b.add_argument("video")
b.add_argument("--srt", required=True)
b.add_argument("--font", default="Instrument Sans")
b.add_argument("--corpo", type=int, default=62)
b.add_argument("--basso", type=int, default=260)
b.add_argument("--libass", action="store_true",
help="usa il filtro subtitles se ffmpeg ce l'ha")
b.add_argument("--out", required=True)
c = s.add_parser("copertina", help="tira fuori un fotogramma")
c.add_argument("video")
c.add_argument("--quando", type=float, default=1.0)
c.add_argument("--out", required=True)
g = s.add_parser("guarda", help="cosa c'e' dentro a una clip")
g.add_argument("video", nargs="+")
a = p.parse_args()
if a.codec == "auto":
a.codec = codec_buono()
{"normalizza": cmd_normalizza, "unisci": cmd_unisci, "taglia": cmd_taglia,
"musica": cmd_musica, "sottotitoli": cmd_sottotitoli,
"copertina": cmd_copertina, "guarda": cmd_guarda}[a.cmd](a)
if __name__ == "__main__":
main()
/**
* I sottotitoli disegnati dal browser, uno per blocco, su fondo trasparente.
*
* node _strumenti/sub.js <manifesto.json>
*
* ⚠️ Esiste perche' l'ffmpeg di Homebrew **non ha libass**: i filtri
* `subtitles`, `ass` e `drawtext` non ci sono (verificato il 2026-09-28 su
* ffmpeg 8.1.2_1, e nelle sue dipendenze libass non compare piu'). Senza
* libass un .srt non si imprime.
*
* Il ripiego pero' e' meglio dell'originale, ed e' la ragione per cui resta
* anche il giorno che libass torna: libass usa i caratteri **di sistema**, e
* Instrument Sans qui e' un file in `fonts/gf/`, non un font installato. Con
* libass i sottotitoli uscirebbero in Helvetica. Col browser escono col
* carattere del sito, e con lo stesso motore che disegna le slide dei video
* (`slide.js`) e le card social (`og-card.js`).
*
* Ogni blocco diventa un PNG **grande quanto il video**, trasparente tranne
* dove c'e' il testo: cosi' `monta.py` lo sovrappone a 0:0 e non deve fare
* nessun conto sulla posizione.
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
/* ⚠️ Playwright si cerca, non si indovina. Qui c'era scritto fisso
* `/opt/homebrew/lib/node_modules/@playwright/test`, che e' il posto dove lo
* mette Homebrew SU UN MAC: su Windows, su Linux e su un Mac senza Homebrew
* quel percorso non esiste e lo script si fermava alla prima riga. */
function trovaChromium() {
const posti = ['playwright', '@playwright/test', 'playwright-core'];
for (const nome of posti) {
try { return require(nome).chromium; } catch (e) {}
}
// Non e' fra le dipendenze qui accanto: proviamo dove npm installa in globale.
try {
const g = execSync('npm root -g', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
for (const nome of posti) {
try { return require(path.join(g, nome)).chromium; } catch (e) {}
}
} catch (e) {}
console.error('\nNon trovo Playwright, che serve a disegnare i sottotitoli.');
console.error('Installalo una volta sola con:\n\n npm install -g playwright');
console.error(' npx playwright install chromium\n');
process.exit(1);
}
const chromium = trovaChromium();
const RADICE = path.resolve(__dirname, '..');
/* ⚠️ Il carattere del sito e' un file, e fuori da questo repository non c'e'.
* Se manca si salta la regola e si scende sul carattere di sistema: meglio dei
* sottotitoli in Helvetica che nessun sottotitolo. */
const CARATTERE = path.join(RADICE, 'fonts/gf/instrument-sans-400700-normal-latin.woff2');
const faccia = (m) => {
const f = m.carattere || (fs.existsSync(CARATTERE) ? CARATTERE : null);
if (!f) return '';
return `@font-face { font-family: 'Sottotitolo';
src: url('file://${f}') format('woff2'); font-weight: 400 700; font-display: block; }`;
};
const html = (m, riga) => `<!doctype html><meta charset="utf-8"><style>
${faccia(m)}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: ${m.larghezza}px; height: ${m.altezza}px; background: transparent; }
body { display: flex; align-items: flex-end; justify-content: center;
padding: 0 ${Math.round(m.larghezza * 0.07)}px ${m.basso}px; }
p {
font-family: 'Sottotitolo', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
font-size: ${m.corpo}px; font-weight: 700; line-height: 1.22;
letter-spacing: -.01em; text-align: center; color: #fff;
text-wrap: balance;
/* Il bordo nero e' quello che tiene il testo leggibile su un fondo
qualsiasi: senza, su una parete chiara il bianco sparisce. */
-webkit-text-stroke: ${Math.round(m.corpo * 0.11)}px #000;
paint-order: stroke fill;
text-shadow: 0 ${Math.round(m.corpo * 0.06)}px ${Math.round(m.corpo * 0.18)}px rgba(0,0,0,.55);
}
${m.css || ''}
</style><p>${riga.testo.replace(/&/g, '&').replace(/</g, '<').replace(/\n/g, '<br>')}</p>`;
(async () => {
const m = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const browser = await chromium.launch();
const pagina = await browser.newPage({
viewport: { width: m.larghezza, height: m.altezza },
deviceScaleFactor: 1,
});
for (const riga of m.righe) {
await pagina.setContent(html(m, riga), { waitUntil: 'load' });
await pagina.evaluate(() => document.fonts.ready);
await pagina.screenshot({ path: riga.file, type: 'png', omitBackground: true });
console.error(` ${path.basename(riga.file)} ${riga.testo.slice(0, 48)}`);
}
await browser.close();
})();
Federico BoggiaRispondo io, in giornata
Ciao! Dimmi che ti serve: un corso online, un workshop di AI in aula a Livorno o la formazione per la tua azienda.