Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[NDD-361] 클라이언트 측에서 webm to mp4 인코딩 구현 (8h/8h) #195

Merged
merged 9 commits into from
Dec 12, 2023

Conversation

adultlee
Copy link
Collaborator

@adultlee adultlee commented Dec 11, 2023

NDD-361 Powered by Pull Request Badge

Why

클라이언트 측에서 인코딩을 구현해야함에는 크게 두가지 이유가 있었습니다.

  1. 모바일 iOS에 대해서 대응하기 위해서 webm 이 아닌 mp4 영상을 지원해야했습니다.
  2. 서버에서 해당 webm 영상을 mp4로 인코딩하는 과정을 구현하려 했으나, AWS 프리티어를 사용함에 따라 cpu 과부화 이슈가 발생
  3. 영상에 대한 화질과 bitrate를 낮춤으로 클라이언트측에서 wasm 기반인 ffmpeg를 통한 인코딩을 진행하려 함

How

ffmpeg.wasm 공식 사이트
해당 사이트의 document를 성실히 따라갔습니다.

다만 차이를 둔 지점이 일부 존재합니다.

공식문서에서 제공하는 코드 입니다. 모든 부분을 이해하시기 보단, 제가 주석으로 다는 부분에 집중하시는것이 좋습니다.

 import { FFmpeg } from '@ffmpeg/ffmpeg'; // ffmpeg에서 사용되는 core 한 기능을 담은 class 입니다. 해당 값은 그대로 사용합니다. 
 import { fetchFile } from '@ffmpeg/util'; // ffmpeg와 함께 사용하기에 적합한 util을 담고 있습니다. 하지만 전 fetchFile은 저희서비스에선  사용하지 않습니다. 

function() {
    const [loaded, setLoaded] = useState(false);
    const ffmpegRef = useRef(new FFmpeg()); // 첫 선언, ref로 해도 좋지만 저는 사용하지 않았습니다. 
    const videoRef = useRef(null);
    const messageRef = useRef(null);

   // ffmpeg는 라이브러리에서만 끝나는것이 아닌 외부 모듈이 필요합니다.  해당 모듈이 모두 불러진 후 동작이 수행되어야 합니다.
 // 저는 하나의 함수 내부에서 비동기로 받아서 해결했으며, memory leak을 처리하기 위해 함수 외부에서 class를 선언했습니다. 
    const load = async () => { // 최초로 ffmpeg에 대한 여러 모듈을 
        const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.4/dist/umd' // vite가 아니기에 umd를 써야만 합니다.
        const ffmpeg = ffmpegRef.current;

        ffmpeg.on('log', ({ message }) => { // ffmpeg에 이벤트를 등록합니다. log말고 여러 이벤트가 있지만 다른 이벤트는 달지 않았습니다.
            messageRef.current.innerHTML = message;
            console.log(message);
        });
        // CORS 오류를 해결하기 위해 도입되었습니다. baseURL을 기반으로 받아야 하는 여러 모듈들을 CORS 정책을 우회하여 fetch 받을 수 있습니다. 
        await ffmpeg.load({
            coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
            wasmURL: await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm'),
        });
        setLoaded(true);
    }

    const transcode = async () => {
        const ffmpeg = ffmpegRef.current;
// 지금부터 ffmpeg를 기반으로 인코딩이 진행됩니다. webm으로 작성시킨 파일을 외부의 위치에서 fetchFile을 통해서 받습니다.
// 하지만 저는 외부 파일을 fetchFile로 받지 않기 때문에, 이 방식이 아니라 blob 데이터를 file 데이터로 변환해서 사용합니다.

        await ffmpeg.writeFile('input.webm', await fetchFile('https://raw.githubusercontent.com/ffmpegwasm/testdata/master/Big_Buck_Bunny_180_10s.webm'));
        await ffmpeg.exec(['-i', 'input.webm', 'output.mp4']);
        const data = await ffmpeg.readFile('output.mp4');
        // 여기서 종료되는 것이 아니라 ffmpeg를 통해서 생성된 data를 다시  blob 데이터로 변경해서 정해진 로직(서버로 전송 혹은 로컬 저장) 을 수행합니다. 
        videoRef.current.src =
            URL.createObjectURL(new Blob([data.buffer], {type: 'video/mp4'}));
    }

    return (loaded
        ? (
            <>
                <video ref={videoRef} controls></video><br/>
                <button onClick={transcode}>Transcode webm to mp4</button>
                <p ref={messageRef}></p>
                <p>Open Developer Tools (Ctrl+Shift+I) to View Logs</p>
            </>
        )
        : (
            <button onClick={load}>Load ffmpeg-core (~31 MB)</button>
        )
    );
}

지금까지 ffmpeg에서 작성된 예제에 주석을 달아서 살펴보았습니다. 우리 코드에선 어떻게 구현되었는지 실제로 확인해봅니다.

FFmpeg 선언

image
Memory Leak 을 방지하기 위해 ffmpeg는 파일 로드시 최초 실행 후 다시 실행시키지 않습니다.

FFmpeg load

image
FFmpeg 를 실행시키기 위한 외부 모듈을 toBlobURL을 통해서 CORS 정책을 우회해서 받습니다.
이후 해당 로직은 라이브러리를 제외하고 함수로 전환이 가능할것으로 예상됩니다.

+) 본문에선 언급이 되지 않았지만, multi thread 를 지원하기 위해서 worker.js 모듈을 추가로 받아 사용합니다.
해당 모듈을 통해서 client에서 별도의 queue를 구현하지 않고 multi-thread로 인코딩이 진행됩니다.

사실 이과정 까지 진행되었다면 인코딩의 90퍼센트를 해결한것이나 다름없습니다.

input.webm 을 만들기 위한 파일데이터로 변경

image
해당 사진처럼 파일 데이터로 변경하기 위해 다음과 같이 file data를 변경합니다.

인코딩 진행

image

해당 사진에서처럼 별도로 인코딩을 진행합니다. 해당 로직이 종료 후 다시 blob 데이터로 변경해 반환합니다.

인코딩 과정 log 찍기

image

해당 사진에서 처럼 ffmpeg의 과정을 log 이벤트를 받아서 message를 출력할 수 있습니다.
다음 과정을 통해서 ffmpeg 과정중 toast 메세지를 통해서 진행상황을 반환할 수 있습니다.

Trouble Shooting

SharedArrayBuffer 보안 이슈 -> 해결

스크린샷 2023-12-11 오후 11 41 43

SharedArrayBuffer는 멀티스레딩 기능을 제공하는 고급 웹 API인데, 이는 Spectre와 같은 보안 취약점에 노출될 수 있습니다. 따라서, 최신 브라우저들은 이와 같은 API를 안전하게 사용하기 위해 COOP와 COEP 헤더 설정을 요구합니다.

Cross-Origin-Opener-Policy

same-origin 설정은 현재 페이지를 다른 출처의 페이지와 분리된 브라우징 컨텍스트 그룹에서 실행하도록 지시합니다.
same-origin 정책은 현재 페이지와 동일한 출처의 문서만이 현재 페이지와 같은 브라우징 컨텍스트 그룹에 속할 수 있음을 의미합니다.
이 설정은 특정 타입의 크로스-사이트 스크립팅 공격을 방지하는 데 도움이 됩니다.

Cross-Origin-Embedder-Policy

require-corp 설정은 웹 페이지가 모든 크로스 오리진 리소스에 대해 CORP (Cross-Origin Resource Policy) 헤더를 요구합니다.
require-corp (require a Cross-Origin Resource Policy) 정책은 페이지가 크로스 오리진 리소스를 로드하려면 해당 리소스가 명시적으로 크로스 오리진 사용을 허용해야 함을 의미합니다.
이 정책은 보안을 강화하며, SharedArrayBuffer와 같은 고급 API의 안전한 사용을 가능하게 합니다.

주의사항

이 헤더들을 설정하면 외부 리소스(예: CDN을 통해 제공되는 스크립트, 스타일시트, 이미지 등)의 로딩에 영향을 줄 수 있습니다. 따라서, 이러한 리소스가 CORP 헤더를 적절히 설정하고 있는지 확인해야 합니다.

Critical dependency: the request of a dependency is an expression -> 완벽한 해결은 못함, Pending

스크린샷 2023-12-11 오후 11 28 16

Critical dependency: the request of a dependency is an expression 경고는 Webpack이 모듈을 동적으로 로드하는 코드를 만났을 때 발생합니다. 이 경우, @ffmpeg/ffmpeg 라이브러리의 worker.js 파일 내에서 발생하는 것으로 보입니다. Webpack은 기본적으로 동적으로 해석되는 의존성(예: 변수를 사용하는 require 호출)을 처리하는 데 어려움을 겪습니다.

다음과 같은 구문을 포함해서 해결 하려 했습니다.

image
하지만 해당 방식으로는 ffmpeg 의 worker.js의 경로를 찾지 못하는 이슈가 발생하여, 당장의 에러는 발생시키지 않지만, "동작하지 않는" 문제가 발생합니다. 그래서 해당 방식은 바로 폐기하고 진행했습니다.

image
그래서 다음과 같이 해결했습니다. 해당 문제는 동적으로 생성되는 worker.js를 찾지 못할 수도 있다는 webpack의 경고로 인해서 시작합니다. 하지만 해당 파일은 내부의 toBlobUrl 을 통해서 import 된 모듈들로 인해 동적으로 잘 연결되고 있기에 현재로선 이 과정을 통해서 빌드 프로세싱 중에서 문제가 발생하지 않도록 합니다.

이 경우 더 이상의 문제는 발생하지 않습니다.

Memory leak 이슈 -> 해결

image
최초의 코드였습니다. 해당 코드는 함수가 실행될때마다 ffmpeg를 새롭게 정의해서 받아옵니다.

image

하지만 이 경우 해당 함수를 여러번 실행할 때마다, 새로운 ffmpeg를 받기 때문에 ffmpeg.loaded가 동작하지 않고 매번 새로운 모듈을 받게 됩니다.
이를 해결하기 위해서 다음과 같이 선언부에서 ffmpeg 객체를 선언합니다.
image

아래는 그 결과입니다.

image
더 이상 여러번 호출하더라도 메모리가 Leak 되지 않음을 확인하였습니다.

Result

image mp4로 출력됨을 확인할 수 있습니다.
2023-12-12.12.13.11.mov

To Reviewer

PR이 짧지만 많은 내용이 들어가 있습니다. 해당 로직에서 여러 에러처리들도 겸해서 함께 진행되어 있으니, 함께 봐주시면 좋을것 같습니다!
모두들 즐거운 하루 되시길 😊

@adultlee adultlee self-assigned this Dec 11, 2023
@adultlee adultlee added FE 프론트엔드 코드 변경사항 feature 새로운 기능이 추가 된 경우 labels Dec 11, 2023
Copy link
Collaborator

@Yoon-Hae-Min Yoon-Hae-Min left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

Comment on lines +138 to +146
const convertTimeToSeconds = (timeStr: string) => {
const [hours, minutes, seconds] = timeStr.split(':').map(Number);
return hours * 3600 + minutes * 60 + seconds;
};

const convertTimeToMinutes = (timeStr: string) => {
const [minutes, seconds] = timeStr.split(':').map(Number);
return minutes * 60 + seconds;
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[p-5] dayJs에 요걸 처리해주는게 없으려나요..?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dayjs.extend(duration);

const convertTimeToSecondsWithDayjs = (timeStr) => {
  const [hours = 0, minutes = 0, seconds = 0] = timeStr.split(':').map(Number);
  return dayjs.duration({ hours, minutes, seconds }).asSeconds();
};

const convertTimeToMinutesWithDayjs = (timeStr) => {
  const [minutes = 0, seconds = 0] = timeStr.split(':').map(Number);
  return dayjs.duration({ minutes, seconds }).asMinutes();
};

gpt에서 물어본 결과인데 아무래도 이정도는 그냥 js로 처리해도 괜찮다 싶더라구요!

Copy link
Collaborator

@milk717 milk717 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

메모리 누수까지 해결되고 아주 완벽하군요!!
mp4 인코딩이 돼서 정말 다행입니다!
수고하셨습니다~~~~!

await ffmpeg.exec(['-i', 'input.webm', 'output.mp4']);
const data = await ffmpeg.readFile('output.mp4');
const newBlob = new Blob([data], { type: 'video/mp4' });
toast.info('성공적으로 Mp4 인코딩이 성공했습니다😊');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[p-4] 성공했다는 말이 중복돼서 약간 어색한데 이렇게 바꾸는건 어떤가용??

Suggested change
toast.info('성공적으로 Mp4 인코딩이 성공했습니다😊');
toast.info('성공적으로 Mp4 인코딩이 완료되었습니다😊');

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0fcccc6

반영완료!

Copy link

cloudflare-workers-and-pages bot commented Dec 12, 2023

Deploying with  Cloudflare Pages  Cloudflare Pages

Latest commit: 4c95998
Status: ✅  Deploy successful!
Preview URL: https://30ea73c3.gomterview.pages.dev
Branch Preview URL: https://feature-ndd-361.gomterview.pages.dev

View logs

@adultlee adultlee merged commit de86cc3 into dev Dec 12, 2023
2 checks passed
@delete-merged-branch delete-merged-branch bot deleted the feature/NDD-361 branch December 12, 2023 04:41
@adultlee adultlee changed the title [NDD-361] 🎉 클라이언트 측에서 webm to mp4 인코딩 구현 🎉 (8h/8h) [NDD-361] 클라이언트 측에서 webm to mp4 인코딩 구현 (8h/8h) Feb 19, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
FE 프론트엔드 코드 변경사항 feature 새로운 기능이 추가 된 경우
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants