Menu

Jak si napsat vlastní ASCII přehrávač videí

9. 7. 2020 - Daniel Bulant - Javascript
Jak si napsat vlastní ASCII přehrávač videí

Sledujete-li můj YouTube kanál, všimli jste si nedávného videa pojmenovaném „Bad Apple in ASCII art“ (pojmenováno podle původního videa – Bad Apple). Pokud ne, podívejte se jak ASCII přehrávač vypadá při použití: https://www.youtube.com/watch?v=q6CFbYVdflE

Použité nástroje:

Samotný přehrávač je rozdělen do dvou souborů – extract.ts který slouží k extraktování souborů a play.ts který již extraktované soubory zobrazí.

Extraktování obrázku z videa – Extract.ts

Tento script má na starosti vytvoření potřebných souborů k přehrání videa – spustí FFMPEG aby konvertoval video na sérii JPG obrázků (které budou později přehrány pomocí play.ts a spuštění samotného přehrávače (play.ts)

Samotný kód je opravdu krátký – spustí ffmpeg s parametry -i <zdroj> fps=fps=<fps> -vsync 0 output/<typ>/%d.jpg které nastaví <zdroj>, <fps> (snímky za vteřinu, některé terminály nestíhají vysoké FPS) a <typ> (ten je použit aby mohlo být připraveno více videí k přehrání naráz). Poté jen spustí druhý script (play.ts) – deno run --allow-read --allow-write --unstable --allow-run play.ts <zdroj> <typ> --fps=<fps> --width=<width> kde nastaví hodnoty <zdroj>, <typ> a <fps> (které jsou stejné) a <width> které slouží k nastavení šířky výsledku ve znacích (je získat z šířky terminálu).

Může se napsat třeba takto:

const source = Deno.args[0];
const type = Deno.args[1];
const fps = 25;
const width = 170;

var process = Deno.run({
    cmd: ["ffmpeg", "-i", source.toString(), "-filter:v", "fps=fps=" + (parseInt(fps) || 25), "-vsync", "0", "output/" + type + "/%d.jpg"],
    stdout: "null",
    stdin: "null",
    stderr: "null"
});

var status = await process.status(); // počká na dokončení
var playback = Deno.run({
        cmd: ["deno", "run", "--allow-read", "--allow-write", "--unstable", "--allow-run", "play.ts", source.toString(), type.toString(), "--fps=" + fps, "--width=" + width]
    });

await playback.status();

Já jsem ovšem chtěl aby to bylo lehce nastavitelné (a nemusel jsem upravovat kód kvůli změně nastavení). Přidal jsem proto možnost nastavení přes příkazovou řádku pomocí vlajek.

Ty mohu lehce extractovat pomocí standartního modulu flags:

import { parse } from "https://deno.land/std/flags/mod.ts";

Konečný kód tak vypadá takto:

import { parse } from "https://deno.land/std/flags/mod.ts"; //importuje parser argumentů/vlajek
const args = parse(Deno.args, {
    alias: {
        o: "onlyExtract"
    }
});
// nastaví potřebné konstanty
const source = args._[0];
const type = args._[1];
const fps = args.fps || 25;
const width = args.width || 170;

if(!type || !source) {
    console.log("Please specify source and output");
    Deno.exit(1);
}

console.log("Please wait, converting video...");
// zkonvertuje video na obrázky
var process = Deno.run({
    cmd: ["ffmpeg", "-i", source.toString(), "-filter:v", "fps=fps=" + (parseInt(fps) || 25), "-vsync", "0", "output/" + type + "/%d.jpg"],
    stdout: "null",
    stdin: "null",
    stderr: "null"
});

var status = await process.status();  //počká na dokončení konvertování

if(status.code) {
    console.error("An error with extracting images occured.");
} else if(!args.onlyExtract) {
    var playback = Deno.run({
        cmd: ["deno", "run", "--allow-read", "--allow-write", "--unstable", "--allow-run", "play.ts", source.toString(), type.toString(), "--fps=" + fps, "--width=" + width]
    });

    await playback.status(); // spustí přehrávač
}

Přehrávání ASCII art videí – Play.ts

Přehrávač je už o trochu složitejší – musí správně načasovat snímky (s tím jsem měl největší problém), spouštět hudbu a (není zcela potřeba, ale i tak jsem to přidal) umět pozastavit přehrávání.

Nejdříve si program načte názvy souborů ve složce a seřadí je podle čísel:

var frames: string[] = [];

for await (const frame of Deno.readDir("./output/" + type)) {
    if(frame.isDirectory) {
        console.log("Ignoring unexpected directory");
        continue;
    }
    frames.push(frame.name);
}

frames = frames.sort((a, b) => (parseInt(a) > parseInt(b) ? 1 : -1));

Pro ulehčení práce jsem si taky vytvořil jednoduchou funkci sleep která vrátí Promise které se dokončí po době určené parametrem:

function sleep(ms: number): Promise<void> {
    return new Promise((resolve) => {
        if(ms < 1) return resolve();
        setTimeout(resolve, ms);
    });
}

A spočítal jak rychle musím zobrazovat snímky:

var next = 1000 / (parseInt(fps) || 25);

Tady končí příprava na přehrávání a začíná kód pro spuštění hudby:

    var audio = Deno.run({
        cmd: ["ffplay", source.toString(), "-nodisp"],
        stdout: "null",
        stderr: "null"
    });

A zobrazení snímků:

    for(const frame of frames) {
        var current = performance.now();
        var process = Deno.run({
            cmd: ["jp2a", (args.width ? "--width=" + args.width : (args.height ? "--height=" + args.height : "")), "./output/" + type + "/" + frame],
            stdout: "inherit",
            stdin: "null",
            stderr: "null"
        });
        await process.status();
        process.close(); // tohle je důležité - když se nespustí, bude uložen ukazovatel na stdout procesu na obrázky dokud script běží (a celkem brzo se dostane na limit)
        await sleep(next - (performance.now() - current) - (next * 0.15 * (args.height ? args.height / 48 : (args.width ? args.width / 170 : 0)))); // tenhle kód fungoval na synchronizaci hudby na mém počítači. Pokud video a hudba není synchronizována, zkuste některé hodnoty upravit
    }

Možná jste si všimli že jsem některé řádky vynechal. Ty obsahují kód k získání nastavení (podobné jako u první skriptu) a kód k pozastavení. K ulehčení zde je plný kód přehrávače:

import { parse } from "https://deno.land/std/flags/mod.ts";
const args = parse(Deno.args);
const source = args._[0];
const type = args._[1];
const fps = args.fps || 25

if(!type) {
    console.log("Please specify source and input");
    Deno.exit(1);
}

var frames: string[] = [];

for await (const frame of Deno.readDir("./output/" + type)) {
    if(frame.isDirectory) {
        console.log("Ignoring unexpected directory");
        continue;
    }
    frames.push(frame.name);
}

frames = frames.sort((a, b) => (parseInt(a) > parseInt(b) ? 1 : -1));

function sleep(ms: number): Promise<void> {
    return new Promise((resolve) => {
        if(ms < 1) return resolve();
        setTimeout(resolve, ms);
    });
}

var next = 1000 / (parseInt(fps) || 25);

while(true) {
    var audio = Deno.run({
        cmd: ["ffplay", source.toString(), "-nodisp"],
        stdout: "null",
        stderr: "null"
    });
    
    var offset = 0;
    var started = Date.now();
    var paused = Date.now();
    var isPaused = false;

    function waitForUnpause(): Promise<boolean> {
        return new Promise(resolve => {
            if(!isPaused) return resolve(false);
            var i: number;
            i = setInterval(async() => {
                if(!isPaused) {
                    if(i) clearInterval(i);
                    resolve(true);
                }
            }, 10);
        });
    }


    Deno.setRaw(0, true);


    // Handles pausing
    async function handleInput() {
        const decoder = new TextDecoder();
        const file = Deno.stdin;
        while (true) {
            const c = new Uint8Array(1)
            await file.read(c)
            const char = decoder.decode(c);
            if(char === " ") {
                isPaused = !isPaused;
                if(isPaused) {
                    paused = Date.now();
                    audio.kill(2);
                } else {
                    offset += paused - started;
                    paused = Date.now();
                    started = Date.now();
                    // FFPlay doesn't support interactive commands form stdin, so we must restart process
                    audio = Deno.run({
                        cmd: ["ffplay", source.toString(), "-nodisp", "-ss", offset.toString() + "ms"],
                        stderr: "null",
                        stdout: "null"
                    });
                }
            } else {
                if(char.charCodeAt(0) === 3) { // break, ctrl+c
                    audio.kill(2);
                    Deno.exit(0);
                }
            }
        }
    }

    handleInput();

    var shouldSkip = 0;

    for(const frame of frames) {
        if(shouldSkip) {
            shouldSkip--;
            continue;
        }
        var current = performance.now();
        var process = Deno.run({
            cmd: ["jp2a", (args.width ? "--width=" + args.width : (args.height ? "--height=" + args.height : "")), "./output/" + type + "/" + frame],
            stdout: "inherit",
            stdin: "null",
            stderr: "null"
        });
        await process.status();
        process.close();
        await sleep(next - (performance.now() - current) - (next * 0.15 * (args.height ? args.height / 48 : (args.width ? args.width / 170 : 0)))); // give a bit of time for jp2a to render, so audio is synced with it
        await waitForUnpause();
    }

    if(!args.loop)
        break;
}

console.log("Hope you enjoyed ascii TV. If you did, consider supporting author via patreon (https://patreon.com/iceproductions).")

Deno.exit(0);

Tohle je vše. Ke spuštění stačí v terminálu (příkazové řádce) spustit deno run --allow-run extract.ts <zdroj> <typ> kde <zdroj> je název videa které je k přehrání a <typ> je prakticky jakýkoli název či zkratka (musí být validní název složky, jinak je to jedno).

Štítky: ,

Napsat komentář