Αντρω δι Νεξυνω

[Python] giocando con i video 01 - immagini ed audio, moviepy : una interessante libreria

« Older   Newer »
  Share  
view post Posted on 28/1/2022, 10:58
Avatar

Nubbio x Sempre

Group:
Moderazione globale
Posts:
7,226

Status:


I miei saluti ad improbabili lettori.

Recentemente mi sono imbattuto in una discussione che mi ha intrigato, l'autore voleva creare un file video da immagini utilizzando un improbabile miscuglio tra linea di comando e finestre di utilità (gestione files) tkinter ... c'è da dire che vi era un "che" di ingegnoso nel modo in cui riusciva effettivamente ad utilizzare le box tkinter aggirandone il mainloop, ma non era questo che mi ha intrigato.

L'oggetto intrigante è stato la libreria moviepy, un wrapper per ffmpeg mirato a semplificare in qualche modo l'utilizzo di quel panzer per l'edithing audio-video ... in questo periodo, giusto per passare il tempo, ogni tanto metto mano ad una utility che mi sto realizzando per riordinare la mia mediateca e moviepy potrebbe essermi utile sotto vari aspetti (eliminare pubblicità indesiderate, salvare solo parti interessanti, comporre, etc.) ho quindi deciso di provarla realizzando "qualcosa" di analogo a quello che secondo me lo OP del post intendeva realizzare.

Cosa ho stabilito di fare


In questo primo test delle funzionalità di moviepy ho deciso di realizzare un semplice video, in qualchuno dei formati più comuni, costituito da una successione di immagini ed un singolo file audio, senza alcun effetto di transizione, testo, etc. ... una cosa facile facile, insomma, senza fronzoli e che pensavo potesse risolversi con poche righe di codice.
Inutile dire che le "poche righe di codice" sono una pia illusione, insomma, si, se ci si vuole mantenere entro il tracciato stabilito da Zulko (l'autore di moviepy) basta la linea di comando e con pochi comandi lo si fa, ma l'autore ha deciso di interfacciarsi a pygame per eventuali anteprime ed a ipython per la rappresentazione dei video, entrambe soluzioni che a me NON stanno bene, da buon dinosauro voglio la mia brava GUI da cui interagire, per quanto riguarda la rappresentazione di video in python ho già i miei metodi da un bel po', ed circa l'anteprima ci posso tranquillamente rinunciare, per ora.

Quindi, in sintesi, è da realizzarsi un sistema che permetta :
  1. di impostare un tipo di video
  2. di dimensionare il video
  3. di selezionare le immagini da utilizzare
  4. di selezionare l'audio da usare
  5. di definire destinazione e nome del video
Dal punto di vista "interfaccia grafica" la semplice finestra in figura ha tutte le caratteristiche che servono, più alcune altre sussidiarie

png


Come potete evincere dalla figura, dal punto di vista grafico non è niente di trascendentale, una list-box nata per esporre i files immagine selezionati, ed eventualmente rimuoverli, un canvas (a sfondo nero) per visualizzare le immagini scelte, alcuni controlli per inserire il tipo e le dimensioni del video da realizzare, una label per comunicazioni, una progress-bar ed una serie di pulsanti di comando.

Funzionamento della GUI


Esaminiamo preliminarmente il funzionamento d'uso della interfaccia grafica, di per se molto semplice e per cui si rimanda ai vari callback per i dettagli implementativi.
I comandi disponibili permettono di caricare o singole immagini (pulsante "Carica file") oppure tutte le immagini contenute in una directory (pulsante "Carica directory"), metodo a mio parere preferibile, nel secondo caso i files contenuti nella directory verranno ordinati per nome-file, ciò permette di preparare in una direttrice i file-immagine che si vogliono utilizzare rinominandoli secondo l'ordine in cui si vuole vengano visualizzati nel filmato finale, ovviamente verrà mantenuto l'ordine con cui i files verranno inseriti con comandi successivi.

Tipi di immagini supportati


Le tipologie di immagini supportate sono quelle gestibili in pillow, fork di PIL installato nel venv dedicato, quando verranno caricate delle immagini avrete disponibili i nome-file delle stesse nella list-box, basterà selezionarne una per vederla nel canvas di visualizzazione

png


Nel caso il tipo di immagine non sia supportato non verrà segnalato errore ma il canvas diverrà, o rimarrà, nero e potrete rimuovere l'immagine selezionandola e premendo il pulsante "Rimuovi immagine" posto immediatamente sotto la list-box, ovviamente tale bottone funziona anche per le immagini supportate, se non vi piacciono ;).
Vi è da precisare che se in una directory caricata vi sono presenti dei files che NON sono immagini, essi non verranno caricati, in effetti il callback del pulsante, una volta stabilità una directory di origine lancia il metodo "_search_img(self)" della classe "MainWin" che esamina i files tramite i metodi della libreria python-magic prima di aggiungerli alla lista dei files da utilizzarsi (proprietà "self.img_list" della classe MainWin) ... forse, può interessare il codice di tale metodo
CODICE
def _search_img(self):
       '''Aggiunge tutti i file-immagine di una directory ordinandoli per nome'''
       tot_f = [f for f in os.listdir(self.start_dir) if
                os.path.isfile(os.path.join(self.start_dir, f))]
       if not tot_f:
           self._make_msg('Nessun file utile trovato')
           return
       new_f = []
       for f in tot_f:
           f_name = os.path.join(os.path.join(self.start_dir, f))
           if 'image' in magic.from_file(f_name, mime=True):
               if f_name not in self.img_list:
                   new_f.append(f_name)
       num_new_img = len(new_f)
       if not new_f:
           self._make_msg('Nessuna nuova immagine trovata')
           return
       new_f.sort()
       self.img_list += new_f
       msg = 'Trovate %d nuove immagini, ora %d immagini totali' % (num_new_img,
                                                                    len(self.img_list))
       self._make_msg(msg)
       self._reset_list()


File audio ...


Questa prova prevede, obbligatoriamente, la presenza di un file audio da collegare alle immagini per le quali si sta realizzando il video.
Anche se possibile utilizzare più file audio nella composizione di un video con moviepy, in questa prova si è scelto di utilizzarne uno ed uno soltanto, la sua durata determina la durata totale del filmato che verrà ripartita equamente tra le immagini caricate per la visualizzazione. L'eventuale caricamento di un nuovo audio comporta la sostituzione della precedente scelta.

... e continuando ...


Fasi successive e necessarie per poter produrre il video sono la definizione della directory di destinazione, eseguibile tramite il pulsante di comando "Destinazione" che farà apparire una finestra di dialogo mirata alla selezione (non definizione, deve esistere già) della dirctory di destinazione, e lo stabilire del nome da assegnare al video, effettuabile tramite il pulsante "Nome video" che farà comparire una finestra in input relativa.
Il nome del video NON deve avere estensione, l'estensione sarà assegnata durante il processo di creazione in base al tipo di video selezionato tra le opzioni della sezione "Tipi video" dell'apposito pannello ... tali opzioni ricoprono una infima parte delle tipologie possibili, per questo primo test ho scelto le più comuni, è solo un esercizio.
Il secondo blocco di opzioni disponibile riferisce alla dimensione del video da realizzare, le dimensioni di default definite sono 4, crescenti a partire da quelle del classivo DVD (720 x 576) ma è possibile definirle a piacere scegliendo l'opzione "altro" della sezione "Risoluzione", tale scelta renderà attive due caselle di testo per tale inserimento, ricordate che le convenzioni per l'edithing video recitano che le dimensioni dovrebbero essere multipli di 16, non vi darà errore se non lo saranno, come invece farà se NON inserirete un numero : avrete un errore non gestito (questo è solo un test) che interromperà il processo e il programma sembrerà non far niente :lol:

Una volta definiti i quattro articoli sopra (immagini, audio, destinazione e nome) vedrete diventare disponibile il pulsante "Crea video"

png


la cui pressione darà avvio alle operazioni di produzione del video stesso ... e finalmente si è giunti a qualcosa di cui vale la pena parlare.

Il processo di creazione del video


Sin dai primi test effettuati a linea di comando mi son reso conto di alcune cose :
1° moviepy è stato creato mirando alle tecnologie web (ipython ed il suo notebook in sostanza), non ad ambienti desktop.
In una GUI desktop vi è una certa difficoltà ad intercettare i vari output, con particolare riferimento a quelli emessi da ffmpeg, cosa invece abbastanza semplice da un notebook ipython.
2° gli automatismi di moviepy producono un filmato di dimensioni pari alla immagine più grande passata per la riproduzione;
3° è possibile ridimensionare le immagini, o meglio i vari spezzoni video per le singole immagini, ma le immagini vengono deformate se non hanno un rapporto lunghezza/altezza pari a quello della risoluzione scelta;
4° ci vuole un certo tempo perché il video venga composto.

Uno dei problemi su cui ho fatto più prove è stato senz'altro quello della perdita di proporzione delle immagini, molto antipatico visivamente e potenzialmente limitante, dopo numerosi tentativi di effettuare un adeguato proporzionamento delle immagini decente tramite gli strumenti di moviepy son giunto alla conclusione che o la libreria non dispone di strumenti adeguati oppure io non li ho trovati. In ogni caso, volendo ottenere un corretto proporzionamento la soluzione più pratica era creare una immagine con sfondo nero della dimensione voluta, incollarci dentro l'immagine da rappresentare scalata, salvarla da qualche parte e passare l'immagine creata per l'elaborazione del video, ciò per ogni singola immagine.

Ovviamente, l'isieme delle immagini "manipolate" può essere salvato in qualsiasi punto del file-system ma ho deciso di utilizzare la libreria appdirs, che consente di avere un "set" di directory predefinite standardizzato, ho approfittato di tale libreria per definire un set che va a valorizzare una variabile globale (DEFAULT_DIRS = {}) a livello di modulo, che diviene riferimento per le elaborazioni da effettuarsi ... ovviamente, ho creato delle apposite funzioni per verifica dell'esistenza ed eventuale creazione delle directory interessate
CODICE
def make_recursive_dir(pathname):
   dir_name = os.path.basename(pathname)
   dir_mater = os.path.dirname(pathname)
   if os.path.exists(pathname) and os.path.isdir(pathname):
       return
   else:
       make_recursive_dir(dir_mater)
       os.mkdir(pathname)

def def_dirs():
   '''
   Definisce le directory da utilizzare nella applicazione,
   eventualmente non esistano le crea.'''
   global DEFAULT_DIRS
   app_name = 'movie_test'
   app_author = 'nuzzopippo'
   DEFAULT_DIRS['data'] = appdirs.user_data_dir(app_name, app_author)
   DEFAULT_DIRS['cache'] = appdirs.user_cache_dir(app_name, app_author)
   DEFAULT_DIRS['log'] = appdirs.user_log_dir(app_name, app_author)
   DEFAULT_DIRS['state'] = appdirs.user_state_dir(app_name, app_author)
   DEFAULT_DIRS['config'] = appdirs.user_config_dir(app_name, app_author)
   try:
       for key in DEFAULT_DIRS.keys():
           make_recursive_dir(DEFAULT_DIRS[key])
   except OSError as e:
       exit(1)

la funzione "def_dirs()" sopra codificata viene invocata all'avvio della applicazione, essa provvede a compilare il dizionario globale DEFAULT_DIRS ed invocare la funzione "make_recursive(...)" su ogni singola voce dei path definiti, quest'ultima provvede ad esaminare ricorsivamente l'albero di directory necessario creando le directory mancanti, se non le trova.

Una volta stabilito "dove" salvare le immagini ben proporzionate, ho definito una classe preposta a detto proporzionamento : "ImgAdapter" ... beh, forse non è il nome "giusto" ma, in fin dei conti, sta "adattando" le immagini no?
CODICE
class ImgAdapter:
   '''Dalle immagini originali produce nuove immagini ri-scalate alla dimensione
      finale voluta per il video.
   '''
   def __init__(self, parent, img_list, img_size, lblitem, prgitem):
       self.parent = parent
       self.source = img_list
       self.d_size = img_size
       self.l_msg = lblitem
       self.progress = prgitem
       self.result = []
       self.cache = DEFAULT_DIRS['cache']
   
   def resize_images(self):
       tot_img = len(self.source)
       for i in range(len(self.source)):
           f_bname = os.path.basename(self.source[i])
           msg = 'Ridimensionamento di %s' % f_bname
           self.l_msg['text'] = msg
           try:
               self._resize(self.source[i], i)
           except (FileNotFoundError,
                   UnidentifiedImageError,
                   ValueError,
                   TypeError,
                   OSError) as e:
               self.parent.message('error', repr(e))
               return
           perc = (i + 1) * 100 / tot_img
           self.progress['value'] = floor(perc)
       self.parent.message('image_resize_finished', self.result)
           
   def _resize(self, f_name, img_num):
       n_name = 'img%04d.png' % img_num
       new_fname = os.path.join(self.cache, n_name)
       bg_img = Image.new(mode= 'RGB', size=self.d_size,
                          color=(0, 0, 0))
       d_w, d_h = bg_img.size
       img = Image.open(f_name, 'r')
       img_w, img_h = img.size
       # calcola i fattori di scala
       r_img = img_w / img_h
       r_bg = d_w / d_h
       if r_bg <= r_img:
           f_scala = d_w / img_w
       else:
           f_scala = d_h / img_h
       fw = int(img_w * f_scala)
       fh = int(img_h * f_scala)
       img = img.resize((fw, fh))
       offset = ((d_w - fw) //2, (d_h - fh) // 2)
       bg_img.paste(img, offset)
       bg_img.save(new_fname)
       self.result.append(new_fname)

un oggetto ImgAdapter deve esser invocato con un insieme predefinito di parametri, nell'ordine
parent, l'oggetto chiamante
img_list, una lista delle immagini originali da comporre
img_size, le dimensioni delle immagini da produrre
lblitem, una label in cui indicare cosa si sta elaborando
prgitem, una progressbar con cui indicare la percentale complessiva di elaborazione.
Non mi dilungo sui processi di ri-definizione delle immagini, codice e documentazione parleranno chiaro agli interessati, ciò che importa è che ImgAdapter mette a disposizione il metodo "resize_images()" che può essere posto quale target di un thread, in tal modo, approfittando del fatto che il mainloop di tkinter è thread-safe, l'aggiornamento della GUI avviene in parallelo alla lavorazione, per altro ImgAdapter esige che il "parent" possieda un metodo "message(message, data)" al quale comunicare di aver finito i suoi processi e fornire i dati prodotti (pathname deile nuove immagini) ... in questo processo la Image di PIL proclama ogni eventuale errore, interrompendo l'elaborazione.

I messaggi sulla label e nella barra di avanzamento, quasi inutili per poche decine di immagini, sono significativi in fase di produzione del filmato

png


processo che richiede un certo tempo per essere effettuato.
I dati di avanzamento della produzione del video non sono propri di moviepy, bensì di ffmpeg, della quale moviepy è un wrapper, e trovare il modo di acqusire l'output prodotto da ffmpeg è stato difficoltoso, ne son venuto a capo trovando un riferimento a "proglog" da parte di Zulko in una discussione sul sito della libreria moviepy.
Tra le altre cosette, proglog rende disponibile un oggetto "ProgressBarLogger" che viene accettato da un oggetto clip di moviepy in fase di produzione del video, quale logger.
L'oggetto ProgressBarLogger ha un metodo "callback(self, **changes)" che interviene alla sua variazione di stato, è stato sufficiente ridefinire tale metodo leggendo lo stato del ProgressBarLogger (un dizionario) per identificare i dati necessari (comparandoli con l'output di ffmpeg a linea di comando) e farli trasmettere alla GUI
CODICE
class MyLogger(ProgressBarLogger):
   '''Oggetto per intercettare l'output di ffmpeg e comunicarlo alla finestra'''
   def __init__(self, lblitem, progritem):
       super().__init__(init_state=None, bars=None, ignored_bars=None,
                        logged_bars='all', min_time_interval=0, ignore_bars_under=0)
       self.lbl = lblitem
       self.progr = progritem
   
   def callback(self, **changes):
       ''' Qui legge lo stato di avanzamento del processo, un dizionario come segue:
           {'bars': OrderedDict([('chunk',
                                  {'title': 'chunk',
                                   'index': 4898,
                                   'total': 4898,
                                   'message': None,
                                   'indent': 0}),
                                 ('t',
                                  {'title': 't',
                                   'index': 5554,
                                   'total': 5554,
                                   'message': None,
                                   'indent': 2})]),
            'message': 'Moviepy - Writing video /home/nuzzopippo/test/leggo_state.mp4\n'}
       '''
       try:
           index = self.state['bars']['t']['index']
           total = self.state['bars']['t']['total']
           percent = index / total * 100
           if percent < 0:
               percent = 0
           if percent > 100:
               percent = 100
           self.progr['value'] = percent
           self.lbl['text'] = \
               f"{index} di {total} video frames completati... ({floor(percent)}%)"
       except KeyError as e:
           pass


Una volta risolto il problema del proporzionamento delle immagini e della intercettazione dello stato di elaborazione emesso da ffmpeg, il resto è in discesa ...

Occorre, naturalmente un "mulo" cui caricare il lavoro pesante, tale bestia da soma è rappresentato dalla classe "MyWorker" che per la sua istanza richiede solo il "parent", ossia la finesta in cui viene istanziato (ma potrebbe essere qualciasi cosa con un metodo "nessage") e che rende disponibile il metodo "make_video(self, kwargs)" che richiede un solo parametro : un dizionario contenente precise chiavi valorizzate e la cui eventuale carenza produce l'interruzione con errore
CODICE
class MyWorker:
   '''Esecutore della creazione del video'''
   def __init__(self, parent):
       self.parent = parent
   
   def make_video(self, kwargs):
       try:
           img_list = kwargs['img_list']
           au_f = kwargs['audio']
           size = kwargs['size']
           logger = kwargs['logger']
           name = kwargs['name']
           # codecs da https://ffmpeg.org/ffmpeg-codecs.html
           # libxvid da errore, forse per bug di moviepy, forse per mancanza di parametri
           # si applica, per i "contenitori" la sola libx264
           #if name.split('.')[-1] == 'avi':
           #    codec = 'libxvid'
           #elif  name.split('.')[-1] == 'mkv':
           #    codec = 'libx264'
           if name.split('.')[-1] in ['avi', 'mkv']:
               codec = 'libx264'
           else:
               codec = None
       except KeyError as e:
           print(repr(e))
           self.parent.message('Error')
           return
       au_clip = AudioFileClip(au_f)
       t_d = au_clip.duration / len(img_list)
       clips = []
       for f in img_list:
           clip = ImageClip(f).set_duration(t_d)
           clips.append(clip.resize(size))
       video = concatenate(clips)
       video.audio = au_clip
       if codec is None:
           video.write_videofile(name, fps=25, logger=logger)
       else:
           video.write_videofile(name, codec=codec, fps=25, logger=logger)
       self.parent.message('done')

Il su esposto codice di MyWorker è decisamente semplice, poco più di quanto occorra utilizzando la linea di comando ... ma giusto perché si è voluto semplificare al massimo, noterete certo che per le tipologie "avi" e "mkv" viene prodotto un elemento "codec" che non esiste per file mp4 o webm, ciò perche i formati AVI e matroska sono dei "contenitori" e possono contenere numerosi elementi e diversi tipi di codifica, in questa prima prova si è utilizzata un singolo encoder ma la in realtà i formati audio/video e gli encoders utilizzabili sono numerosi, oltre tutto con una caterva di possibili ozioni ... un utilizzo puntuale delle varie possibilità richiederebbe processi molto più complessi del presente test (si notino i commenti su "libxvid", c'è una pagina di moviepy dove si invita a segnalare malfunzionamenti ;) ).

Cosa avviente premuto "Crea video"?


Come sarà evidente da quanto detto, il processo di creazione del video è diviso in due fasi logiche, svolte da due successivi thread, nella prima fase vengono ridimensionate le immagini originali e prodotte nuove immagini ben proporzionate, nella seconda fase le nuove immagini prodotte vengono utilizzate per creare spezzoni di video di lunghezza calcolata che poi vengono assemblati e montati unitamente all'audio.

il callback del pulsante "Crea video", metodo "_make(self)" della GUI si limita ad istanziare un ImgAdapter ed un threading.Thread a cui assegna il metodo resize_images dell'ImgAdapter istanziato, invoca l'avvio del thread ("t.start()") ed esce.

Alla fine della sua esecuzione l'oggetto ImgAdapter istanziato invocherà il metodo "message(self, text, data=None)" valorizzando il parametro "text" con la stringa "image_resize_finished" ed il parametro "data" con la lista dei pathname delle immagini prodotte.
Nel metodo "message" viene effettuata la valutazione del testo ricevuto e riconosciuta la stringa "image_resize_finished" viene invocato il metodo "_make_video(self, img_list)" della GUI, passando la lista delle nuove immagini, quest'ultimo metodo provvederà a raccogliere i parametri di produzione impostati inserendoli in un dizionario, istanzierà un oggetto MyLogger che archivierà nel dizionarie per poi istanziare un oggetto MyWorker ed un nuovo threading.Thread, cui passerà il metodo "make_video" quale target ed il dizionario creato quale dati associati al target, quindi avvierà il thread ed uscirà.

Man mano che la creazione del video avanza il MyLogger istanziato informerà l'utente dello stato delle cose ed al completamento delle elaborazioni l'oggetto MyWorker provvederà a comunicare l'esito positivo alla GUI che informerà l'utente

png



Il file sarà pronto da visualizzare dove l'utente ha stabilito che stia, salvo, ovviamente, eventuali errori.

segue il codice completo del test
CODICE
# -*- coding: utf-8 -*-

import tkinter as tk
import tkinter.messagebox as mb
import tkinter.filedialog as fd
from tkinter.ttk import Progressbar
import os
import glob
import magic
from PIL import UnidentifiedImageError
from PIL import Image
from PIL import ImageTk
from proglog import ProgressBarLogger
from threading import Thread
from moviepy.editor import AudioFileClip, ImageClip, concatenate
from moviepy.video.fx import resize
from math import floor
import appdirs

DEFAULT_DIRS = {}

VIDEO_FORMATS_EXT = {'WebM'            : '.webm',
                    'Matroska'        : '.mkv',
                    'AVI'             : '.avi',
                    'MPEG-4 Part. 14' : '.mp4'}

VIDEO_RESOLUTIONS = (('DVD', 720, 576),
                    ('HIGH DEFINITON', 1920, 1080),
                    ('2K CINEMA', 2048, 1080),
                    ('4K CINEMA', 4096, 2160),
                    ('altro', 0, 0))

def make_recursive_dir(pathname):
   dir_name = os.path.basename(pathname)
   dir_mater = os.path.dirname(pathname)
   if os.path.exists(pathname) and os.path.isdir(pathname):
       return
   else:
       make_recursive_dir(dir_mater)
       os.mkdir(pathname)

def def_dirs():
   '''
   Definisce le directory da utilizzare nella applicazione,
   eventualmente non esistano le crea.'''
   global DEFAULT_DIRS
   app_name = 'movie_test'
   app_author = 'nuzzopippo'
   DEFAULT_DIRS['data'] = appdirs.user_data_dir(app_name, app_author)
   DEFAULT_DIRS['cache'] = appdirs.user_cache_dir(app_name, app_author)
   DEFAULT_DIRS['log'] = appdirs.user_log_dir(app_name, app_author)
   DEFAULT_DIRS['state'] = appdirs.user_state_dir(app_name, app_author)
   DEFAULT_DIRS['config'] = appdirs.user_config_dir(app_name, app_author)
   try:
       for key in DEFAULT_DIRS.keys():
           make_recursive_dir(DEFAULT_DIRS[key])
   except OSError as e:
       exit(1)

def center_win(w):
   '''Centra sullo schermo una finestra'''
   x = w.winfo_screenwidth()
   y = w.winfo_screenheight()
   wx = w.winfo_reqwidth()
   wy = w.winfo_reqheight()
   w.geometry('{}x{}+{}+{}'.format(wx, wy, (x-wx)//2, (y-wy)//2))

def get_tk_image(fname, wdg):
   try:
       image = Image.open(fname)
   except (FileNotFoundError, OSError,
           ValueError, TypeError):
       return None
   img_x, img_y = image.size
   r_img = img_x / img_y
   r_wdg = wdg.winfo_width() / wdg.winfo_height()
   if r_wdg <= r_img:
       f_scala = (wdg.winfo_width()-10) / img_x
   else:
       f_scala = (wdg.winfo_height()-10) / img_y
   fx = int(img_x * f_scala)
   fy = int(img_y * f_scala)
   image = image.resize((fx, fy))
   return ImageTk.PhotoImage(image)


class MyLogger(ProgressBarLogger):
   '''Oggetto per intercettare l'output di ffmpeg e comunicarlo alla finestra'''
   def __init__(self, lblitem, progritem):
       super().__init__(init_state=None, bars=None, ignored_bars=None,
                        logged_bars='all', min_time_interval=0, ignore_bars_under=0)
       self.lbl = lblitem
       self.progr = progritem
   
   def callback(self, **changes):
       ''' Qui legge lo stato di avanzamento del processo, un dizionario come segue:
           {'bars': OrderedDict([('chunk',
                                  {'title': 'chunk',
                                   'index': 4898,
                                   'total': 4898,
                                   'message': None,
                                   'indent': 0}),
                                 ('t',
                                  {'title': 't',
                                   'index': 5554,
                                   'total': 5554,
                                   'message': None,
                                   'indent': 2})]),
            'message': 'Moviepy - Writing video /home/nuzzopippo/test/leggo_state.mp4\n'}
       '''
       try:
           index = self.state['bars']['t']['index']
           total = self.state['bars']['t']['total']
           percent = index / total * 100
           if percent < 0:
               percent = 0
           if percent > 100:
               percent = 100
           self.progr['value'] = percent
           self.lbl['text'] = \
               f"{index} di {total} video frames completati... ({floor(percent)}%)"
       except KeyError as e:
           pass


class MyWorker:
   '''Esecutore della creazione del video'''
   def __init__(self, parent):
       self.parent = parent
   
   def make_video(self, kwargs):
       try:
           img_list = kwargs['img_list']
           au_f = kwargs['audio']
           size = kwargs['size']
           logger = kwargs['logger']
           name = kwargs['name']
           # codecs da https://ffmpeg.org/ffmpeg-codecs.html
           # libxvid da errore, forse per bug di moviepy, forse per mancanza di parametri
           # si applica, per i "contenitori" la sola libx264
           #if name.split('.')[-1] == 'avi':
           #    codec = 'libxvid'
           #elif  name.split('.')[-1] == 'mkv':
           #    codec = 'libx264'
           if name.split('.')[-1] in ['avi', 'mkv']:
               codec = 'libx264'
           else:
               codec = None
       except KeyError as e:
           print(repr(e))
           self.parent.message('Error')
           return
       au_clip = AudioFileClip(au_f)
       t_d = au_clip.duration / len(img_list)
       clips = []
       for f in img_list:
           clip = ImageClip(f).set_duration(t_d)
           clips.append(clip.resize(size))
       video = concatenate(clips)
       video.audio = au_clip
       if codec is None:
           video.write_videofile(name, fps=25, logger=logger)
       else:
           video.write_videofile(name, codec=codec, fps=25, logger=logger)
       self.parent.message('done')


class ImgAdapter:
   '''Dalle immagini originali produce nuove immagini ri-scalate alla dimensione
      finale voluta per il video.
   '''
   def __init__(self, parent, img_list, img_size, lblitem, prgitem):
       self.parent = parent
       self.source = img_list
       self.d_size = img_size
       self.l_msg = lblitem
       self.progress = prgitem
       self.result = []
       self.cache = DEFAULT_DIRS['cache']
   
   def resize_images(self):
       tot_img = len(self.source)
       for i in range(len(self.source)):
           f_bname = os.path.basename(self.source[i])
           msg = 'Ridimensionamento di %s' % f_bname
           self.l_msg['text'] = msg
           try:
               self._resize(self.source[i], i)
           except (FileNotFoundError,
                   UnidentifiedImageError,
                   ValueError,
                   TypeError,
                   OSError) as e:
               self.parent.message('error', repr(e))
               return
           perc = (i + 1) * 100 / tot_img
           self.progress['value'] = floor(perc)
       self.parent.message('image_resize_finished', self.result)
           
   def _resize(self, f_name, img_num):
       n_name = 'img%04d.png' % img_num
       new_fname = os.path.join(self.cache, n_name)
       bg_img = Image.new(mode= 'RGB', size=self.d_size,
                          color=(0, 0, 0))
       d_w, d_h = bg_img.size
       img = Image.open(f_name, 'r')
       img_w, img_h = img.size
       # calcola i fattori di scala
       r_img = img_w / img_h
       r_bg = d_w / d_h
       if r_bg <= r_img:
           f_scala = d_w / img_w
       else:
           f_scala = d_h / img_h
       fw = int(img_w * f_scala)
       fh = int(img_h * f_scala)
       img = img.resize((fw, fh))
       offset = ((d_w - fw) //2, (d_h - fh) // 2)
       bg_img.paste(img, offset)
       bg_img.save(new_fname)
       self.result.append(new_fname)


class MainWin(tk.Tk):
   def __init__(self):
       super().__init__()
       self.title('Test 01 moviepy : Imagini+audio')
       pn_data = tk.Frame(self)
       pn_data.grid(row=0, column=0, sticky='nsew')
       self.l_img = tk.Listbox(pn_data)
       self.l_img.grid(row=0, column=0, padx=(4,1), pady=4, sticky='nsew')
       v_scroll = tk.Scrollbar(pn_data, command=self.l_img.yview)
       self.l_img.configure(yscrollcommand=v_scroll.set)
       v_scroll.grid(row=0, column=1, sticky='ns')
       self.cnvimg = tk.Canvas(pn_data, bg='black')
       self.cnvimg.grid(row=0, column=2, rowspan=2, padx=4, pady=4, sticky='nsew')
       bt_remove = tk.Button(pn_data, text='Rimuovi immagine', command=self._on_remove)
       bt_remove.grid(row=1, column=0, padx=4, pady=4, sticky='ew')
       pn_conf = tk.LabelFrame(pn_data, text=' Opzioni ')
       pn_conf.grid(row=0, column=3, rowspan=2, sticky='ns')
       lbl = tk.Label(pn_conf, text='Tipi video :')
       lbl.grid(row=0, column=0, padx=4, pady=4, sticky='nsew')
       self.v_type = tk.StringVar()
       row = 1
       for key in sorted(VIDEO_FORMATS_EXT.keys()):
           text = key + ' (*' + VIDEO_FORMATS_EXT[key] + ')'
           rbt = tk.Radiobutton(pn_conf, text=text, variable=self.v_type,
                                value=VIDEO_FORMATS_EXT[key], command=self._on_video_type)
           rbt.grid(row=row, column=0, padx=(6,4), pady=1, sticky='w')
           row += 1
       initial = sorted(VIDEO_FORMATS_EXT.keys())[0]
       self.v_type.set(VIDEO_FORMATS_EXT[initial])
       lbl = tk.Label(pn_conf, text='Risoluzione :')
       lbl.grid(row=row, column=0, padx=4, pady=4, sticky='w')
       row += 1
       self.resolution = tk.StringVar()
       for i in range(len(VIDEO_RESOLUTIONS)):
           text = VIDEO_RESOLUTIONS[i][0]
           rbt = tk.Radiobutton(pn_conf, text=text, variable=self.resolution,
                                value=text, command=self._on_resolution)
           rbt.grid(row=row, column=0, padx=(6,4), pady=1, sticky='w')
           row += 1
       self.resolution.set(VIDEO_RESOLUTIONS[0][0])
       pn_res = tk.Frame(pn_conf)
       pn_res.grid(row=row, column=0, sticky='ew')
       self.v_x = tk.Entry(pn_res, width=5)
       self.v_x.grid(row=0, column=0, padx=4, pady=4, sticky='ew')
       lbl = tk.Label(pn_res, text='x')
       lbl.grid(row=0, column=1)
       self.v_y = tk.Entry(pn_res, width=5)
       self.v_y.grid(row=0, column=2, padx=4, pady=4, sticky='ew')
       pn_res.grid_columnconfigure(0, weight=1, uniform='b')
       pn_res.grid_columnconfigure(2, weight=1, uniform='b')
       pn_data.grid_rowconfigure(0, weight=1)
       pn_data.grid_columnconfigure(0, uniform='a')
       pn_data.grid_columnconfigure(2, weight=1)
       pn_data.grid_columnconfigure(3, uniform='a')
       self.lbl_mess = tk.Label(self, text='Ben venuto', justify='left')
       self.lbl_mess.grid(row=1, column=0, padx=4, pady=4, sticky='w')
       self.progr = Progressbar(self, orient='horizontal', mode='determinate')
       self.progr.grid(row=2, column=0, padx=4, pady=4, sticky='ew')
       pn_cmd = tk.Frame(self)
       pn_cmd.grid(row=3, column=0, sticky='nsew')
       self.bt_openf = tk.Button(pn_cmd, text='Carica file', command=self._open_file)
       self.bt_openf.grid(row=0, column=0, padx=4, pady=4, sticky='nsew')
       self.bt_opend = tk.Button(pn_cmd, text='Carica directory', command=self._open_dir)
       self.bt_opend.grid(row=0, column=1, padx=4, pady=4, sticky='nsew')
       self.bt_open_au = tk.Button(pn_cmd, text='Carica audio', command=self._open_music)
       self.bt_open_au.grid(row=0, column=2, padx=4, pady=4, sticky='nsew')
       self.bt_dest_d = tk.Button(pn_cmd, text='Destinazione', command=self._dest_dir)
       self.bt_dest_d.grid(row=0, column=3, padx=4, pady=4, sticky='nsew')
       self.bt_v_name = tk.Button(pn_cmd, text='Nome video', command=self._video_name)
       self.bt_v_name.grid(row=0, column=4, padx=4, pady=4, sticky='nsew')
       self.bt_make = tk.Button(pn_cmd, text='Crea video', command=self._make)
       self.bt_make.grid(row=0, column=5, padx=4, pady=4, sticky='nsew')
       self.bt_exit = tk.Button(pn_cmd, text='Chiudi programma', command=self.destroy)
       self.bt_exit.grid(row=0, column=6, padx=4, pady=4, sticky='nsew')
       for i in range(7): pn_cmd.grid_columnconfigure(i, weight=1, uniform='c')
       self.grid_columnconfigure(0, weight=1)
       self.grid_rowconfigure(0, weight=1)

       self.l_img.bind('<ButtonRelease-1>', self._on_image)
       self.l_img.bind('<Return>', self._on_image)

       self.update()
       wx = self.winfo_reqwidth()
       wy = self.winfo_reqheight()
       self.minsize(width=wx, height=wy)
       center_win(self)
       self._on_resolution()

       self.start_dir = os.path.expanduser('~')

       self.img_list = []
       self.audio_f = ''
       self.dir_dest = ''
       self.video_name = ''
       self._evaluate_self()
       self.img = None
   
   def _on_remove(self):
       indexes = self.l_img.curselection()
       if not indexes: return
       index = indexes[0]
       del self.img_list[index]
       self.l_img.delete(index)
       self.cnvimg.delete('all')
       msg = 'Elemento rimosso, restano %d immagini' % len(self.img_list)
       self._make_msg(msg)
       self._evaluate_self()

   def _on_video_type(self):
       pass

   def _on_resolution(self):
       self.update()
       key = self.resolution.get()
       match = False
       x = 0
       y = 0
       for i in range(len(VIDEO_RESOLUTIONS)):
           if VIDEO_RESOLUTIONS[i][0] == key:
               match = True
               x = VIDEO_RESOLUTIONS[i][1]
               y = VIDEO_RESOLUTIONS[i][2]
               break
       if not match: return
       self.v_x.configure(state='normal')
       self.v_y.configure(state='normal')
       self.v_x.delete(0, 'end')
       self.v_x.insert('end', str(x))
       self.v_y.delete(0, 'end')
       self.v_y.insert('end', str(y))
       if key != 'altro':
           self.v_x.configure(state='disabled')
           self.v_y.configure(state='disabled')
   
   def _open_file(self):
       tipology = [('Files immagine', ('*.png', '*.PNG',
                                       '*.gif', '*.GIF',
                                       '*.jpg', '*.JPG',
                                       '*.jpeg', '*.JPEG',
                                       '*.bmp', '*.BMP')),
                   ('Tutti i files', '*.*')]
       f_name = fd.askopenfilename(parent=self, initialdir=self.start_dir,
                                   title='Selezione immagine', defaultextension='*.png',
                                   filetypes=tipology)
       if not f_name: return
       self.start_dir = os.path.dirname(f_name)
       if 'image' in magic.from_file(f_name, mime=True):
           self.img_list.append(f_name)
           self.l_img.insert('end', os.path.basename(f_name))
       msg = 'Aggiunto %s, %d immagini ora presenti' % (os.path.basename(f_name),
                                                        len(self.img_list))
       self._make_msg(msg)
       self._evaluate_self()

   def _open_dir(self):
       d_o = fd.askdirectory(parent=self, initialdir=self.start_dir,
                             title='Caricamento da directory')
       if d_o:
           if os.path.exists(d_o) and os.path.isdir(d_o):
               self.start_dir = d_o
               msg = 'Esame directory %s' % d_o
               self._make_msg(msg)
               self._search_img()
               self._evaluate_self()

   def _open_music(self):
       tipology = [('Tutti i files', '*.*')]
       f_name = fd.askopenfilename(parent=self, initialdir=self.start_dir,
                                   title='Selezione musica', defaultextension='*.*',
                                   filetypes=tipology)
       if not f_name: return
       self.start_dir = os.path.dirname(f_name)
       if 'audio' in magic.from_file(f_name, mime=True):
           self.audio_f = f_name
       msg = 'Impostato %s quale audio' % os.path.basename(f_name)
       self._make_msg(msg)
       self._evaluate_self()

   def _dest_dir(self):
       d_d = fd.askdirectory(parent=self, initialdir=self.start_dir,
                             title='Definizione directory destinazione')
       if d_d:
           if os.path.exists(d_d) and os.path.isdir(d_d):
               self.dir_dest = d_d
               msg = 'Directory di destinazione impostata'
           else:
               msg = 'Directory non valida'
           self._make_msg(msg)
           self._evaluate_self()

   def _video_name(self):
       msg = 'Inserire il nome (senza estensione) del\nvideo da creare\n(evitare spazi e caratteri strani)'
       name = tk.simpledialog.askstring('Nome filamato', msg)
       if name:
           self.video_name = name
           msg = 'Nome video : %s' % self.video_name
           self._make_msg(msg)
           self._evaluate_self()

   def _make(self):
       # parent, img_list, img_size, lblitem, prgitem
       x = int(self.v_x.get())
       y = int(self.v_y.get())
       size = x, y
       self.progr['value'] = 0
       resizer = ImgAdapter(self, self.img_list, size,
                            self.lbl_mess, self.progr)
       t = Thread(target=resizer.resize_images)
       t.start()
   
   def _make_video(self, img_list):
       data = {}
       data['img_list'] = img_list
       data['audio'] = self.audio_f
       x = int(self.v_x.get())
       y = int(self.v_y.get())
       data['size'] = x, y
       ext = self.v_type.get()
       f_name = os.path.join(self.dir_dest, self.video_name + ext)
       data['name'] = f_name
       self.progr['value'] = 0
       logger = MyLogger(self.lbl_mess, self.progr)
       data['logger'] = logger
       worker = MyWorker(self)
       t = Thread(target=worker.make_video, args=(data,))
       t.start()


   def _on_image(self, evt=None):
       indexes = self.l_img.curselection()
       if not indexes: return
       index = indexes[0]
       f_name = self.img_list[index]
       self.cnvimg.delete('all')
       img = get_tk_image(f_name, self.cnvimg)
       if img is None: return
       self.img = img
       x = self.cnvimg.winfo_width() // 2
       y = self.cnvimg.winfo_height() // 2
       self.cnvimg.create_image(x, y, image=self.img, anchor='center')

   def _evaluate_self(self):
       if self.img_list and self.audio_f and self.dir_dest and self.video_name:
           self.bt_make.configure(state='normal')
       else:
           self.bt_make.configure(state='disabled')
   
   def _make_msg(self, msg):
       self.lbl_mess.configure(text=msg)
   
   def _search_img(self):
       '''Aggiunge tutti i file-immagine di una directory ordinandoli per nome'''
       tot_f = [f for f in os.listdir(self.start_dir) if
                os.path.isfile(os.path.join(self.start_dir, f))]
       if not tot_f:
           self._make_msg('Nessun file utile trovato')
           return
       new_f = []
       for f in tot_f:
           f_name = os.path.join(os.path.join(self.start_dir, f))
           if 'image' in magic.from_file(f_name, mime=True):
               if f_name not in self.img_list:
                   new_f.append(f_name)
       num_new_img = len(new_f)
       if not new_f:
           self._make_msg('Nessuna nuova immagine trovata')
           return
       new_f.sort()
       self.img_list += new_f
       msg = 'Trovate %d nuove immagini, ora %d immagini totali' % (num_new_img,
                                                                    len(self.img_list))
       self._make_msg(msg)
       self._reset_list()
   
   def _reset_list(self):
       self.l_img.delete(0, 'end')
       for f in self.img_list:
           f_name = os.path.basename(f)
           self.l_img.insert('end', f_name)
   
   def message(self, text, data=None):
       if text == 'Error':
           msg = 'Produzione video cessata con errore'
           if data:
               msg += '\n' + data
           mb.showerror('Produzione fallita', msg)
       elif text == 'image_resize_finished':
           self._make_video(data)
       elif text == 'done':
           msg = 'Video prodotto con successo'
           mb.showinfo('Video pronto', msg)
           self._clear_cache()
   
   def _clear_cache(self):
       files = glob.glob(DEFAULT_DIRS['cache'] + '/*.*')
       for f in files:
           try:
               os.remove(f)
           except OSError:
               pass


def main():
   def_dirs()
   app = MainWin()
   app.mainloop()    


       

if __name__ == '__main__':
   main()


Sono 535 righe ed è stato pensato per Linux e nello stesso s.o. implementato, non so se funzionerà nei sistemi windows (sistema operativo che proprio non mi interessa) comunque, avrà bisogno delle librerie che seguono:
CODICE
(moviepy_v) NzP:~$ python -m pip list
Package            Version  
------------------ ---------
appdirs            1.4.4    
certifi            2021.10.8
charset-normalizer 2.0.10  
decorator          4.4.2    
idna               3.3      
imageio            2.13.5  
imageio-ffmpeg     0.4.5    
moviepy            1.0.3    
numpy              1.22.1  
Pillow             9.0.0    
pip                20.0.2  
pkg-resources      0.0.0    
proglog            0.1.9    
pygame             2.1.2    
python-magic       0.4.24  
requests           2.27.1  
setuptools         44.0.0  
six                1.16.0  
tqdm               4.62.3  
urllib3            1.26.8  
(moviepy_v) NzP:~$


Questo post è a mio promemoria, comunque se a qualcuno capitasse di leggerlo e gli fosse utile mi farebbe piacere, magari, in futuro sperimento qualcosa d'altro.

:D

Edited by nuzzopippo - 29/1/2022, 10:48
 
Web  Top
0 replies since 28/1/2022, 10:58   415 views
  Share