배너 이미지

브라우저에서 GIF / MP4 변환하기

최종 수정일 : (1년 전)

Github
Live Demo


웹 어셈블리를 통해 브라우저에서 Javascript뿐 아니라 C, C++ 등의 언어로 작성된 프로그램도 돌릴 수 있는 시대가 도래한 지도 꽤 되었습니다.
고로, 서버에 파일 업로드 => 파일 변환 => 변환된 파일 다운로드라는 번거로운 과정을 거쳤던 파일 변환을 FFmpeg을 통해 클라이언트 혼자서 해낼 수 있게 되었습니다.

앱 생성

npx create-snowpack-app gifconverter --template @snowpack/app-template-react-typescript

snowpack으로 typescript를 이용한 react 앱 템플릿을 생성해줍니다.

물론 굳이 snowpack, react, typescript를 사용하셔야 하는 건 아닙니다.

npm i @ffmpeg/ffmpeg @ffmpeg/core

그다음, 이 앱의 핵심인 ffmpeg를 설치해줍니다.

npm start

위 커맨드를 입력하시면 snowpack 기준 8080번 포트에서 앱이 실행됩니다.

FFmpeg 초기 설정

import React, { useStateuseEffect } from "react";
import { createFFmpegfetchFileFFmpeg } from "@ffmpeg/ffmpeg";
import "./App.css";

const ffmpegFFmpeg = createFFmpeg({ logtrue });

function App() {
    const loadable = !!window.SharedArrayBuffer;
    const [readysetReady= useState<boolean>(false);

    const load = async () => {
        await ffmpeg.load();
        setReady(true);
    };

    useEffect(() => {
        load();
    }, []);

    return (
        <>
            {ready ? (
                <></>
            ) : loadable ? (
                <div>Loading FFmpeg</div>
            ) : (
                <div>지원되지 않는 브라우저입니다.</div>
            )}
        </>
    );
}

export default App;

App.tsx의 보일러 플레이트를 제거하고, FFmpeg를 비동기적으로 불러오게 업데이트했습니다.

FFmpeg를 사용하려면 브라우저에서 window.SharedArrayBuffer(MDN 참고)를 이용할 수 있어야 합니다만, 애석하게도 제대로 지원하는 브라우저가 많지 않아 대부분 브라우저에서 지원되지 않는다는 메시지가 보일 겁니다.

파일 업로드

const [inputsetInput= useState<{
    fileFile;
    typestring;
}>();

const handleChange = (eventReact.ChangeEvent<HTMLInputElement>) => {
    const { files } = event.target;

    if (files !== null && files[0]) {
        const file = files[0];
        const { type } = file;

        if (type === "image/gif" || type === "video/mp4") {
            setInput({
                file,
                type,
            });
        }
    }
};

업로드한 파일이 gif인지, mp4인지 알아야 하기에 file과 type을 저장하는 state를 추가했습니다.

input[type="file"]의 변화를 감지해 상태를 업데이트할 거라, input[type="file"]의 변화를 setInput을 통해 input에 저장하는 함수도 만들었습니다.
만약 서버에서 돌아가는 스크립트였다면 파일을 훨씬 까다롭게 검사했겠지만, 파일을 업로드한 사용자의 메모리에 올라갈 파일이라 파일 유형만 검사해 gif나 mp4 파일이면 저장하도록 했습니다.

<input type="file" onChange={handleChange} />

이제 readytrue일 때 input[type="file"]을 렌더링하도록 App의 return 값을 수정해주면 됩니다.

파일 변환

const [outputsetOutput= useState<string>("");

const convertFile = async () => {
    if (input === undefinedreturn;
    const { filetype } = input;

    if (type === "image/gif") {
        // convert gif to mp4

        ffmpeg.FS("writeFile""input.gif"await fetchFile(file));
        await ffmpeg.run(
            "-f",
            "gif",
            "-i",
            "input.gif",
            "-movflags",
            "+faststart",
            "-pix_fmt",
            "yuv420p",
            "-vf",
            "scale=trunc(iw/2)*2:trunc(ih/2)*2",
            "output.mp4"
        );

        const data = ffmpeg.FS("readFile""output.mp4");

        const url = URL.createObjectURL(
            new Blob([data.buffer], { type"video/mp4" })
        );

        setOutput(url);
    } else {
        // convert mp4 to gif

        ffmpeg.FS("writeFile""input.mp4"await fetchFile(file));
        await ffmpeg.run(
            "-f",
            "mp4",
            "-i",
            "input.mp4",
            "-t",
            "10",
            "-loop",
            "0",
            "-filter_complex",
            "[0:v] split [a][b];[a] palettegen [p];[b][p] paletteuse",
            "output.gif"
        );

        const data = ffmpeg.FS("readFile""output.gif");

        const url = URL.createObjectURL(
            new Blob([data.buffer], { type"image/gif" })
        );

        setOutput(url);
    }
};


useEffect(() => {
    convertFile();
}, [input]);

변환된 파일을 저장할 output도 만들어주고, useEffect를 통해 input에 변화가 있을 때마다 파일 변환을 진행하도록 했습니다.

ffmpeg.FS를 통해 메모리에 파일을 읽고 씁니다(window.SharedArrayBuffer가 필요했던 이유입니다).

ffmpeg.run에는 보시다시피 FFmpeg 커맨드를 옵션 하나하나씩 적어주시면 됩니다. 커맨드는 ffmpeg gif to mp4등의 키워드로 검색하시면 여기저기 많이 돌아다닙니다. FFmpeg의 문서와 구글링으로 본인의 커맨드를 완성해가시길 바랍니다.

참고로 mp4를 gif로 변환하는 부분에 들어간 -t 숫자는 gif의 시간제한입니다.
gif는 나이가 나이다 보니 최적화가 많이 안 좋아 시간제한을 늘리면 메모리가 터져나가 Aw, snap!이 반겨줄지도 몰라 10초로 제한시간을 걸어뒀습니다.

Aw, snap!
앱 제작하면서도 자주 볼 화면
return (
    <>
        {ready ? (
            <>
                <input type="file" onChange={handleChange} />

                {input && output && (
                    <div>
                        {input.type === "image/gif" ? (
                            <video
                                src={output}
                                autoPlay
                                muted
                                playsInline
                                loop
                            ></video>
                        ) : (
                            <img src={output} />
                        )}
                    </div>
                )}
            </>
        ) : loadable ? (
            <div>Loading FFmpeg</div>
        ) : (
            <div>지원되지 않는 브라우저입니다.</div>
        )}
    </>
);

파일 변환이 완료되면 output 파일이 출력되게 했습니다.

지금까지 작성된 코드

import React, { useStateuseEffect } from "react";
import { createFFmpegfetchFileFFmpeg } from "@ffmpeg/ffmpeg";
import "./App.css";

const ffmpegFFmpeg = createFFmpeg({ logtrue });

function App() {
    const loadable = !!window.SharedArrayBuffer;
    const [readysetReady= useState<boolean>(false);
    const [inputsetInput= useState<{
        fileFile;
        typestring;
    }>();
    const [outputsetOutput= useState<string>("");

    const load = async () => {
        await ffmpeg.load();
        setReady(true);
    };

    const handleChange = (eventReact.ChangeEvent<HTMLInputElement>) => {
        const { files } = event.target;

        if (files !== null && files[0]) {
            const file = files[0];
            const { type } = file;

            if (type === "image/gif" || type === "video/mp4") {
                setInput({
                    file,
                    type,
                });
            }
        }
    };

    const convertFile = async () => {
        if (input === undefinedreturn;
        const { filetype } = input;

        if (type === "image/gif") {
            // convert gif to mp4

            ffmpeg.FS("writeFile""input.gif"await fetchFile(file));
            await ffmpeg.run(
                "-f",
                "gif",
                "-i",
                "input.gif",
                "-movflags",
                "+faststart",
                "-pix_fmt",
                "yuv420p",
                "-vf",
                "scale=trunc(iw/2)*2:trunc(ih/2)*2",
                "output.mp4"
            );

            const data = ffmpeg.FS("readFile""output.mp4");

            const url = URL.createObjectURL(
                new Blob([data.buffer], { type"video/mp4" })
            );

            setOutput(url);
        } else {
            // convert mp4 to gif

            ffmpeg.FS("writeFile""input.mp4"await fetchFile(file));
            await ffmpeg.run(
                "-f",
                "mp4",
                "-i",
                "input.mp4",
                "-t",
                "10",
                "-loop",
                "0",
                "-filter_complex",
                "[0:v] split [a][b];[a] palettegen [p];[b][p] paletteuse",
                "output.gif"
            );

            const data = ffmpeg.FS("readFile""output.gif");

            const url = URL.createObjectURL(
                new Blob([data.buffer], { type"image/gif" })
            );

            setOutput(url);
        }
    };

    useEffect(() => {
        load();
    }, []);

    useEffect(() => {
        convertFile();
    }, [input]);

    return (
        <>
            {ready ? (
                <>
                    <input type="file" onChange={handleChange} />

                    {input && output && (
                        <div>
                            {input.type === "image/gif" ? (
                                <video
                                    src={output}
                                    autoPlay
                                    muted
                                    playsInline
                                    loop
                                ></video>
                            ) : (
                                <img src={output} />
                            )}
                        </div>
                    )}
                </>
            ) : loadable ? (
                <div>Loading FFmpeg</div>
            ) : (
                <div>지원되지 않는 브라우저입니다.</div>
            )}
        </>
    );
}

export default App;

여기까지만 하셔도 기본적인 파일 변환은 끝났습니다.

드래그 & 드랍 업로드, 상황에 맞는 화면(결과 화면 / 업로드 화면 등) 표시, 다운로드 버튼 및 초기화 버튼 추가, 올바르지 않은 파일 알림 등은 Github을 참고해주세요.

보너스 : 진행상태 표시하기

const ffmpegFFmpeg = createFFmpeg({ logtrueprogressprogressRatio });

function progressRatio(status: { rationumber }) {
    console.log(status.ratio);
}

createFFmpeg에 progress란 옵션을 추가하면 FFmpeg의 진행 과정을 0에서 1 사이 값으로 넘겨줍니다.

모양이 이렇다 보니 컴포넌트 밖에서 컴포넌트의 상태를 업데이트해줘야 해서 어떻게 할지 고민을 많이 했습니다.

import React from "react";

declare global {
    interface Window {
        displayProgressReact.Component;
    }
}

interface DisplayProgressProps {}

export default class DisplayProgress extends React.Component<
    DisplayProgressProps,
    { rationumber }
> {
    constructor(propsDisplayProgressProps) {
        super(props);
        window.displayProgress = this;
        this.state = {
            ratio0,
        };
    }

    render() {
        const { ratio } = this.state;

        return (
            <div className="progress">
                <div>{Math.round(ratio * 100)} %</div>
            </div>
        );
    }
}

DisplayProgress.tsx를 따로 생성했습니다.

DisplayProgress란 컴포넌트를 만들고 window.displayProgress에 이를 할당해줬습니다.

function progressRatio(status: { rationumber }) {
    window.displayProgress.setState({
        ratiostatus.ratio,
    });
}

이제 App.tsxprogressRatio에서 window.displayProgress의 state를 업데이트해주면 됩니다.

Reference

WASM + React... Easily build video editing software with JS & FFmpeg


profile

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

주의 : 비밀 댓글 사용 시 수정 기능을 이용할 수 있는 시간이 지나면 작성자도 내용 확인이 불가능합니다.