MokaByte 90- 9mbre 2004 
Transcodifiche audio/video con JMF
di
Vincenzo Viola

Questo mese vedremo come transcodificare un file audio o video in un formato diverso da quello originale con l'ausilio delle JMF API. Potremo in questo modo passare da un video AVI ad uno MOV, oppure da un MP3 ad un WAV

Introduzione
Per l'ennesima volta (i fedeli lettori di questa serie di articoli l'avranno ormai memorizzato) l'elaborazione che vogliamo realizzare utilizzando JMF è perfettamente modelizzata dallo schema illustrato nella Figura 1: l'input è il file audio/video in formato A, l'output è il file audio/video nel formato B:.



Figura 1 - Modello di elaborazione di dati multimediali

Supporremo ormai assodata la conoscenza degli oggetti di base di JMF, come il Processor, il DataSource o il DataSink e delle operazioni elementari ad essi connesse. Per chi desiderasse un approfondimento in tal senso può leggere gli articoli precedenti di questa serie.

 

Formati audio e video.
I tipi di transcodifiche effettuabili sono di tre: audio, video, audio e video.
A seconda del tipo di transcodifica desiderata l'utente specificherà le informazioni relative ai formati audio e/o video.
Nel caso di transcodifica audio la prima cosa da fare è costruire un AudioFormat a partire dal formato audio richiesto. Di questo si occupa il metodo parseAudioFormat(String fmtStr). Un AudioFormat valido contiene le seguenti informazioni:

  • encoding (tipo di codifica);
  • sampleRate (sample rate);
  • sampleSizeInBits (numero di bit per campione);
  • channels (numero di canali)
  • endian (ordinamento big/little endian: AudioFormat.BIG_ENDIAN o AudioFormat.LITTLE_ENDIAN);
  • signed (indica se i campioni sono memorizzati in formato signed o unsigned: sarà true nel caso signed, false altrimenti);

Se questi attributi non sono forniti dall'utente, si assumono non specificati utilizzando il campo AudioFormat.NOT_SPECIFIED e resteranno gli stessi del file in ingesso.
Analogamente, nel caso di transcodifica video il metodo parseVideoFormat(String fmtStr) si occupa di generare un VideoFormat a partire dal formato video richiesto. Il VideoFormat può essere costruito in due modi: il più semplice prevede che gli sia passato il tipo di codifica (encoding), l'altro prevede invece che gli siano passati i seguenti parametri:

  • encoding (tipo di codifica);
  • size (dimensione di un video frame);
  • maxDataLength (lunghezza massima di un chunk);
  • dataType (tipo di dati, ad esempio byte array);
  • frameRate (il frame rate);

L'utente può specificare soltanto i primi due parametri. Pertanto maxDataLength e frameRare saranno VideoFormat.NOT_SPECIFIED, dataType sarà null, ovvero dopo la transcodifica questi attributi saranno gli stessi del file in ingesso.

 

L'algoritmo di transcodifica
L'algoritmo di transcodifica è costituito dai seguenti 10 passi:

  1. Creare un Processor per la url di input specificata (corrispondente al file da transcodificare).
  2. Configurare il Processor.
  3. Analizzare il media locator di output (corrispondente alla url desiderata del file trancodificato) e basandosi sull'estensione del file, determinarne il tipo (content descriptor) dei dati (es.: .mov = QuickTime) usando MimeManager.
  4. Settare il content descriptor sul Processor.
  5. Per ogni traccia componente il media stream:
  6. Confrontare il formato richiesto con i formati supportati.
  7. Se c'è riscontro impostare tale formato.
  8. Realizzare il Processor.
  9. Ricavare il DataSource di output ed usarlo insieme con il media locator di output per creare un DataSink.
  10. Startare il Processor e il DataSink.
  11. Aspettare che il DataSink termini la scrittura del file.
  12. Chiudere il Processor e il DataSink.

Dopo aver creato e configurato il Processor, utilizziamo un metodo per ricavare dall'estensione del file il content descriptor. Questo metodo, fileExtToCD(String name), utilizza MimeManager per ottenere il Mime Type e da esso il content descriptor:

String type = com.sun.media.MimeManager.getMimeType(ext);
FileTypeDescriptor ftp = new FileTypeDescriptor(ContentDescriptor.mimeTypeToPackageName(type));

Per completezza apriamo una parentesi per spiegare cos'è MIME. MIME (Multipart Internet Mail Extension) è un sistema di comunicazione pensato per permettere la spedizione tramite E-mail (e, per estensione, la circolazione sulla rete) di dati binari codificati in modo diverso, in modo che a ciascun flusso di dati venga associata una intestazione che specifica sostanzialmente il tipo di oggetto codificato (immagine, testo, programma...) e il formato con cui è stato memorizzato.
Molti tipi di trasmissioni di dati, tra cui la posta elettronica e il protocollo HTTP usato per il World Wide Web, prevedono quindi che il contenuto vero e proprio sia preceduto, all'interno delle righe di intestazione, da una indicazione del tipo
Content-type: oggetto/formato
dove al posto di oggetto vi è una parola chiave che specifica il tipo di oggetto (es. text, image...) e al posto di formato vi è una parola chiave che specifica il formato (ad esempio, se l'oggetto è un testo, plain, html...). Ogni coppia oggetto/formato costituisce un tipo MIME (MIME type o content-type).
Una volta ottenuto il content descriptor dell'uscita lo si imposta sul Processor. A questo scopo utilizziamo il metodo setContentDescriptor(Processor p, MediaLocator outML). Nel caso in cui il Processor non supportasse il tipo dell'uscita, settiamo il content descriptor a RAW e vediamo se invece lo supporta il DataSink.
A questo punto andiamo ad analizzare il formato delle tracce componenti il media stream. Il metodo setEachTrackFormat(Processor p, TrackControl tcs[], Format fmt) utilizzando il TrackControl ricavato dal Processor, controlla se il formato desiderato è tra quelli supportati e in questo caso lo imposta come formato della traccia:

TrackControl tcs[] = p.getTrackControls();
Format supported[];
Format f;

for (int i = 0; i < tcs.length; i++) {

supported = tcs[i].getSupportedFormats();

for (int j = 0; j < supported.length; j++) {
fmt.matches(supported[j]);
f=fmt.intersects(supported[j]);
tcs[i].setFormat(f);
}

Un'altra funzionalità che si può implementare è quella di decidere la "porzione" temporale del file che si vuole codificare. Ad esempio potremmo essere interessati ad una transcodifica dei primi 10 sec. di un video. Allora, una volta che l'utente abbia inserito gli estremi temporali di questo intervallo, dopo aver realizzato il Processor e creato il DataSink, andiamo a settare il media time e lo stop time del Processor:

int start = 0;
int end = 10;
p.setMediaTime(new Time((double)start));
p.setStopTime(new Time((double)end));

Non rimare ora che startare il Processor e il DataSink, aspettare che questo abbia terminato la scrittura su file e rilasciare le risorse.

 

Implementazione
Riportiamo di seguito, il codice che esegue la transcodifica audio/video. Per l'esecuzione corretta bisogna passare come argomenti la url del file in ingresso, quella del file in uscita e opzionalmente gli attributi delle codifiche audio e video, lo start time e l'end time:

java Transcode -o <output> -a <audio format> -v <video format> -s <start time> -e <end time> <input>

dove:

<audio format>: [encoding]:[rate]:[sizeInBits]:[channels]:[big|little]:[signed|unsigned]
<video format>: [encoding]:[widthXheight]

Esempio:

java Transcode -o file:/c:/video/prova.mov -a linear/8000 -v 320x240 -s 0 -e 10 file:/c:/movies/movie.avi


import java.awt.*;
import java.util.Vector;
import java.io.File;
import javax.media.*;
import javax.media.control.TrackControl;
import javax.media.Format;
import javax.media.format.*;
import javax.media.datasink.*;
import javax.media.protocol.*;
import javax.media.protocol.DataSource;
import java.io.IOException;


/**
* Programma per transcodifiche audio-video
*/
public class Transcode implements ControllerListener, DataSinkListener {

public boolean doIt(MediaLocator inML, MediaLocator outML, Format fmts[],
int start, int end) {

Processor p;

try {
System.err.println("Crezione del processor per " + inML);
p = Manager.createProcessor(inML);
} catch (Exception e) {
System.err.println("Impossibile creare il processor!" + e);
return false;
}

p.addControllerListener(this);

// Mettiamo il Processor nello stato configurato
p.configure();
if (!waitForState(p, p.Configured)) {
System.err.println("Configurazione del Processor fallita.");
return false;
}

// Settiamo il tipo di uscita del Processor
setContentDescriptor(p, outML);

// Settiamo il formato della traccia
if (!setTrackFormats(p, fmts))
return false;

// Realizziamo il Processor
p.realize();
if (!waitForState(p, p.Realized)) {
System.err.println("Realizzazione del Processor fallita.");
return false;
}

// Creiamo il DataSink
DataSink dsink;
if ((dsink = createDataSink(p, outML)) == null) {
System.err.println("Creazione del DataSink fallita per il
MediaLocator: " + outML);
return false;
}

dsink.addDataSinkListener(this);
fileDone = false;

// Settiamo l'istante iniziale del video da transcodificare
// se è stato impostato
if (start > 0)
p.setMediaTime(new Time((double)start));

// Settiamo l'istante finale del video da transcodificare
// se è stato impostato
if (end > 0)
p.setStopTime(new Time((double)end));

System.err.println("inizio transcodifica...");

try {
p.start();
dsink.start();
} catch (IOException e) {
System.err.println("IO error durante la transcodifica.");
return false;
}

// Attendiamo un EndOfStream event.
waitForFileDone();

// Rilasciamo le risore
try {
dsink.close();
} catch (Exception e) {}
p.removeControllerListener(this);

System.err.println("...transcodifica terminata.");

return true;
}


/**
* Settaggio dell tipo dell'uscita
*/
void setContentDescriptor(Processor p, MediaLocator outML) {

ContentDescriptor cd;

// Il file di output è di un tipo supportato

if ((cd = fileExtToCD(outML.getRemainder())) != null) {

System.err.println("Settato content descriptor a: " + cd);

if ((p.setContentDescriptor(cd)) == null) {

// Se il processor non supporta il tipo dell'uscita
// lo settiamo a RAW e vediamo se invece lo supporta
// il DataSink

p.setContentDescriptor(new
ContentDescriptor(ContentDescriptor.RAW));
}
}
}


/**
* Settaggio del formato delle tracce sul processor
*/
boolean setTrackFormats(Processor p, Format fmts[]) {

if (fmts.length == 0)
return true;

TrackControl tcs[];

if ((tcs = p.getTrackControls()) == null) {
// Il processor non supporta alcun controllo sulle tracce
System.err.println("Il processor non può transcodificare le
tracce nel formato richiesto");
return false;
}

for (int i = 0; i < fmts.length; i++) {

System.err.println("Settato formato di traccia: " + fmts[i]);

if (!setEachTrackFormat(p, tcs, fmts[i])) {
System.err.println("Impossibile transcodificare la
traccia nel formato: " + fmts[i]);
return false;
}
}

return true;
}


/**
* Cerchiamo tra tutte le traccie quelle compatibili col
* formato di transcodifica richiesto
*/
boolean setEachTrackFormat(Processor p, TrackControl tcs[], Format fmt) {

Format supported[];
Format f;

for (int i = 0; i < tcs.length; i++) {

supported = tcs[i].getSupportedFormats();

if (supported == null)
continue;

for (int j = 0; j < supported.length; j++) {

if (fmt.matches(supported[j]) &&
(f = fmt.intersects(supported[j])) != null
&& tcs[i].setFormat(f) != null) {

return true;
}
}
}

return false;
}

/**
* Creazione del DataSink
*/
DataSink createDataSink(Processor p, MediaLocator outML) {

DataSource ds;

if ((ds = p.getDataOutput()) == null) {
System.err.println("Il Processor non ha un DataSource di
output.");
return null;
}

DataSink dsink;

try {
System.err.println("DataSink creato per il MediaLocator: " +
outML);
dsink = Manager.createDataSink(ds, outML);
dsink.open();
} catch (Exception e) {
System.err.println("Impossibile creare il DataSink: " + e);
return null;
}

return dsink;
}


Object waitSync = new Object();
boolean stateTransitionOK = true;

/**
* Aspetta finché il Processor non è transitato nello stato desiderato.
* Ritorna false se la transizione è fallita.
*/
boolean waitForState(Processor p, int state) {
synchronized (waitSync) {
try {
while (p.getState() < state && stateTransitionOK)
waitSync.wait();
} catch (Exception e) {}
}
return stateTransitionOK;
}


/**
* Controller Listener.
*/
public void controllerUpdate(ControllerEvent evt) {

if (evt instanceof ConfigureCompleteEvent ||
evt instanceof RealizeCompleteEvent ||
evt instanceof PrefetchCompleteEvent) {
synchronized (waitSync) {
stateTransitionOK = true;
waitSync.notifyAll();
}
} else if (evt instanceof ResourceUnavailableEvent) {
synchronized (waitSync) {
stateTransitionOK = false;
waitSync.notifyAll();
}
} else if (evt instanceof EndOfMediaEvent) {
evt.getSourceController().close();
} else if (evt instanceof MediaTimeSetEvent) {
((MediaTimeSetEvent)evt).getMediaTime().getSeconds();
} else if (evt instanceof StopAtTimeEvent) {
((StopAtTimeEvent)evt).getMediaTime().getSeconds();
evt.getSourceController().close();
}
}


Object waitFileSync = new Object();
boolean fileDone = false;
boolean fileSuccess = true;

/**
* Aspetta finché non è terminata la scrittura del file.
*/
boolean waitForFileDone() {
System.err.print(" ");
synchronized (waitFileSync) {
try {
while (!fileDone) {
waitFileSync.wait(1000);
System.err.print(".");
}
} catch (Exception e) {}
}
System.err.println("");
return fileSuccess;
}


/**
* Gestore degli eventi del file writer.
*/
public void dataSinkUpdate(DataSinkEvent evt) {

if (evt instanceof EndOfStreamEvent) {
synchronized (waitFileSync) {
fileDone = true;
waitFileSync.notifyAll();
}
} else if (evt instanceof DataSinkErrorEvent) {
synchronized (waitFileSync) {
fileDone = true;
fileSuccess = false;
waitFileSync.notifyAll();
}
}
}


/**
* Converte il nome del file in un content type andando a parsificare
* l'estensione del file.
*/
ContentDescriptor fileExtToCD(String name) {

String ext;
int p;

// Estrae l'estensione del file
if ((p = name.lastIndexOf('.')) < 0)
return null;

ext = (name.substring(p + 1)).toLowerCase();

String type;

// Usiamo il MimeManager per ottenere il mime type dall'estensione
// del file
if ( ext.equals("mp3")) {
type = FileTypeDescriptor.MPEG_AUDIO;
} else {
if ((type = com.sun.media.MimeManager.getMimeType(ext)) ==
null)
return null;
type = ContentDescriptor.mimeTypeToPackageName(type);
}

return new FileTypeDescriptor(type);
}


/**
* Main program
*/
public static void main(String [] args) {

String inputURL = null, outputURL = null;
int mediaStart = -1, mediaEnd = -1;
Vector audFmt = new Vector(), vidFmt = new Vector();

if (args.length == 0)
prUsage();

// Parsifichiamo gli argomenti
int i = 0;
while (i < args.length) {

if (args[i].equals("-v")) {
i++;
if (i >= args.length)
prUsage();
vidFmt.addElement(args[i]);
} else if (args[i].equals("-a")) {
i++;
if (i >= args.length)
prUsage();
audFmt.addElement(args[i]);
} else if (args[i].equals("-o")) {
i++;
if (i >= args.length)
prUsage();
outputURL = args[i];
} else if (args[i].equals("-s")) {
i++;
if (i >= args.length)
prUsage();
Integer integer = Integer.valueOf(args[i]);
if (integer != null)
mediaStart = integer.intValue();
} else if (args[i].equals("-e")) {
i++;
if (i >= args.length)
prUsage();
Integer integer = Integer.valueOf(args[i]);
if (integer != null)
mediaEnd = integer.intValue();
} else {
inputURL = args[i];
}
i++;
}

if (inputURL == null) {
System.err.println("Non è stata specificata la url del file in
ingresso");
prUsage();
}

if (outputURL == null) {
System.err.println("Non è stata specificata la url del file in
uscita");
prUsage();
}

int j = 0;
Format fmts[] = new Format[audFmt.size() + vidFmt.size()];
Format fmt;

// Verifichiamo che il formato audio specificato esista
for (i = 0; i < audFmt.size(); i++) {

if ((fmt = parseAudioFormat((String)audFmt.elementAt(i))) ==
null) {
System.err.println("Il formato audio specificato non è
valido: " +
(String)audFmt.elementAt(i));
prUsage();
}
fmts[j++] = fmt;
}

// Verifichiamo che il formato audio specificato esista
for (i = 0; i < vidFmt.size(); i++) {

if ((fmt = parseVideoFormat((String)vidFmt.elementAt(i))) ==
null) {
System.err.println("Il formato video specificato non è
valido: "
+ (String)vidFmt.elementAt(i));
prUsage();
}
fmts[j++] = fmt;
}

// Creazionde dei media locator di input e output
MediaLocator iml, oml;

if ((iml = createMediaLocator(inputURL)) == null) {
System.err.println("Impossibile creare il Media Locator: " +
inputURL);
System.exit(0);
}

if ((oml = createMediaLocator(outputURL)) == null) {
System.err.println("Impossibile creare il Media Locator: " +
outputURL);
System.exit(0);
}

Transcode transcode = new Transcode();

if (!transcode.doIt(iml, oml, fmts, mediaStart, mediaEnd)) {
System.err.println("Transcodifica fallita!");
}

System.exit(0);
}


/**
* Create a media locator from the given string.
*/
static MediaLocator createMediaLocator(String url) {

MediaLocator ml;

if (url.indexOf(":") > 0 && (ml = new MediaLocator(url)) != null)
return ml;

if (url.startsWith(File.separator)) {
if ((ml = new MediaLocator("file:" + url)) != null)
return ml;
} else {
String file = "file:" + System.getProperty("user.dir") +
File.separator + url;
if ((ml = new MediaLocator(file)) != null)
return ml;
}

return null;
}


/**
* Analizza il formato audio specificato e genera un AudioFormat.
* Un formato audio valido ha la seguente forma:
* [encoding]:[rate]:[sizeInBits]:[channels]:[big|little]:[signed|unsigned]
*/
static Format parseAudioFormat(String fmtStr) {

int rate, bits, channels, endian, signed;

String encodeStr = null, rateStr = null,
bitsStr = null, channelsStr = null,
endianStr = null, signedStr = null;

// Analizza il media locator per estrarre il formato richiesto.

if (fmtStr != null && fmtStr.length() > 0) {
while (fmtStr.length() > 1 && fmtStr.charAt(0) == ':')
fmtStr = fmtStr.substring(1);

// Vediamo se è specificato il tipo di codifica (encoding)
int off = fmtStr.indexOf(':');
if (off == -1) {
if (!fmtStr.equals(""))
encodeStr = fmtStr;
} else {
encodeStr = fmtStr.substring(0, off);
fmtStr = fmtStr.substring(off + 1);
// Vediamo se è specificato il sample rate
off = fmtStr.indexOf(':');
if (off == -1) {
if (!fmtStr.equals(""))
rateStr = fmtStr;
} else {
rateStr = fmtStr.substring(0, off);
fmtStr = fmtStr.substring(off + 1);
// Vediamo se è specificato il numero di bit per
// campione
off = fmtStr.indexOf(':');
if (off == -1) {
if (!fmtStr.equals(""))
bitsStr = fmtStr;
} else {
bitsStr = fmtStr.substring(0, off);
fmtStr = fmtStr.substring(off + 1);
// Vediamo se è specificato il numero di
// canali
off = fmtStr.indexOf(':');
if (off == -1) {
if (!fmtStr.equals(""))
channelsStr = fmtStr;
} else {
channelsStr = fmtStr.substring(0, off);
fmtStr = fmtStr.substring(off + 1);
// Vediamo se è specificato l'endian
off = fmtStr.indexOf(':');
if (off == -1) {
if (!fmtStr.equals(""))
endianStr =
fmtStr.substring(off + 1);
} else {
endianStr = fmtStr.substring(0,
off);
if (!fmtStr.equals(""))
signedStr =
fmtStr.substring(off + 1);
}
}
}
}
}
}

// Sample Rate
rate = AudioFormat.NOT_SPECIFIED;
if (rateStr != null) {
try {
Integer integer = Integer.valueOf(rateStr);
if (integer != null)
rate = integer.intValue();
} catch (Throwable t) { }
}

// Sample Size
bits = AudioFormat.NOT_SPECIFIED;
if (bitsStr != null) {
try {
Integer integer = Integer.valueOf(bitsStr);
if (integer != null)
bits = integer.intValue();
} catch (Throwable t) { }
}

// # of channels
channels = AudioFormat.NOT_SPECIFIED;
if (channelsStr != null) {
try {
Integer integer = Integer.valueOf(channelsStr);
if (integer != null)
channels = integer.intValue();
} catch (Throwable t) { }
}

// Endian
endian = AudioFormat.NOT_SPECIFIED;
if (endianStr != null) {
if (endianStr.equalsIgnoreCase("big"))
endian = AudioFormat.BIG_ENDIAN;
else if (endianStr.equalsIgnoreCase("little"))
endian = AudioFormat.LITTLE_ENDIAN;
}

// Signed
signed = AudioFormat.NOT_SPECIFIED;
if (signedStr != null) {
if (signedStr.equalsIgnoreCase("signed"))
signed = AudioFormat.SIGNED;
else if (signedStr.equalsIgnoreCase("unsigned"))
signed = AudioFormat.UNSIGNED;
}

return new AudioFormat(encodeStr, rate, bits, channels, endian,
signed);
}


/**
* Analizza il formato video specificato e genera un VideoFormat.
* Un formato video valido ha la seguente forma:
* [encoding]:[widthXheight]
*/
static Format parseVideoFormat(String fmtStr) {

String encodeStr = null, sizeStr = null;

// Analizza il media locator per estrarre il formato richiesto.

if (fmtStr != null && fmtStr.length() > 0) {
while (fmtStr.length() > 1 && fmtStr.charAt(0) == ':')
fmtStr = fmtStr.substring(1);

// Vediamo se è specificato il tipo di codifica (encoding)
int off = fmtStr.indexOf(':');
if (off == -1) {
if (!fmtStr.equals(""))
encodeStr = fmtStr;
} else {
encodeStr = fmtStr.substring(0, off);
sizeStr = fmtStr.substring(off + 1);
}
}

if (encodeStr == null || encodeStr.equals(""))
prUsage();

if (sizeStr == null)
return new VideoFormat(encodeStr);

int width = 320, height = 240;

int off = sizeStr.indexOf('X');
if (off == -1)
off = sizeStr.indexOf('x');

if (off == -1) {
System.err.println("La dimensione del video non è
correttamente specificata: " + sizeStr);
prUsage();
} else {
String widthStr = sizeStr.substring(0, off);
String heightStr = sizeStr.substring(off + 1);

try {
Integer integer = Integer.valueOf(widthStr);
if (integer != null)
width = integer.intValue();
integer = Integer.valueOf(heightStr);
if (integer != null)
height = integer.intValue();
} catch (Throwable t) {
prUsage();
}

return new VideoFormat(encodeStr,
new Dimension(width, height),
VideoFormat.NOT_SPECIFIED, // maxDataLen
null, // data class
VideoFormat.NOT_SPECIFIED // FrameRate
);
}

return null;
}


static void prUsage() {
System.err.println("Usage: java Transcode -o <output> -a <audio
format> -v <video format> -s <start time> -e <end time> <input>");
System.err.println(" <output>: output URL o nome del file");
System.err.println(" <input>: input URL o nome del file");
System.err.println(" <audio format>:
[encoding]:[rate]:[sizeInBits]:[channels]:[big|lit tle]:[signed|unsigned]");
System.err.println(" <video format>:
[encoding]:[widthXheight]");
System.err.println(" <start time>: start time in secondi");
System.err.println(" <end time>: end time in secondi");
System.exit(0);
}
}


Conclusioni
In questo articolo abbiamo visto come poter effettuare transcodifiche di formato sia per audio e video, per porzioni di file o file interi.
Nonostante esistano un numero impressionante di software, anche free, che eseguono operazioni di questo tipo, è sicuramente utile e talvolta performante utilizzare delle librerie da poter inglobare nelle proprie applicazioni, senza dover lanciare programmi esterni e senza doversi preoccupare del sistema operativo e della piattaforma che si utilizzano.

 

Bibliografia
[1] "JMF API Specification", java.sun.com/products/java-media/jmf/index.jsp


MokaByte® è un marchio registrato da MokaByte s.r.l. 
Java®, Jini® e tutti i nomi derivati sono marchi registrati da Sun Microsystems.
Tutti i diritti riservati. E' vietata la riproduzione anche parziale.
Per comunicazioni inviare una mail a info@mokabyte.it