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
33 changes: 31 additions & 2 deletions FE/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions FE/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
"@emotion/babel-preset-css-prop": "^11.11.0",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@ffmpeg/ffmpeg": "^0.12.7",
"@ffmpeg/util": "^0.12.1",
"@sentry/react": "^7.81.1",
"@tanstack/react-query": "^5.8.1",
"@tanstack/react-query-devtools": "^5.8.1",
Expand Down
4 changes: 3 additions & 1 deletion FE/src/hooks/pages/Interview/useInterview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,15 @@ const useInterview = () => {

const handleDownload = useCallback(() => {
const blob = new Blob(recordedBlobs, { type: selectedMimeType });

const recordTime = calculateDuration();

switch (method) {
case 'idrive':
void uploadToDrive({ blob, currentQuestion, recordTime });
break;
case 'local':
localDownload(blob, currentQuestion);
void localDownload(blob, currentQuestion, recordTime);
break;
}
setRecordedBlobs([]);
Expand Down
5 changes: 4 additions & 1 deletion FE/src/hooks/useUploadToIdrive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import useGetPreSignedUrlMutation from '@/hooks/apis/mutations/useGetPreSignedUr
import { putVideoToIdrive } from '@/apis/idrive';
import useAddVideoMutation from '@/hooks/apis/mutations/useAddVideoMutation';
import { toast } from '@foundation/Toast/toast';
import { EncodingWebmToMp4 } from '@/utils/record';

type UploadParams = {
blob: Blob;
Expand All @@ -20,13 +21,15 @@ export const useUploadToIDrive = () => {
recordTime,
}: UploadParams): Promise<void> => {
try {
const mp4Blob = await EncodingWebmToMp4(blob, recordTime);

toast.success('성공적으로 서버에 업로드를 준비합니다.');
const preSignedResponse = await getPreSignedUrl();
// response를 받습니다

await putVideoToIdrive({
url: preSignedResponse?.preSignedUrl,
blob: blob,
blob: mp4Blob,
});

videoToServer({
Expand Down
4 changes: 2 additions & 2 deletions FE/src/utils/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ export const getMedia = async (): Promise<MediaStream | null> => {
echoCancellation: { exact: true },
},
video: {
width: 1280,
height: 720,
width: 640, // 1280
height: 360, // 720
},
});

Expand Down
90 changes: 86 additions & 4 deletions FE/src/utils/record.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { SelectedQuestion } from '@/atoms/interviewSetting';
import React, { MutableRefObject } from 'react';
import { toast } from '@foundation/Toast/toast';
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { toBlobURL } from '@ffmpeg/util';

const ffmpeg = new FFmpeg();

type StartRecordingProps = {
media: MediaStream | null;
Expand All @@ -23,6 +27,7 @@ export const startRecording = ({
try {
mediaRecorderRef.current = new MediaRecorder(media, {
mimeType: selectedMimeType,
videoBitsPerSecond: 300000,
});

mediaRecorderRef.current.ondataavailable = (event: BlobEvent) => {
Expand All @@ -47,19 +52,96 @@ export const stopRecording = (
}
};

export const localDownload = (
export const localDownload = async (
blob: Blob,
currentQuestion: SelectedQuestion
currentQuestion: SelectedQuestion,
recordTime: string
) => {
const url = window.URL.createObjectURL(blob);
const mp4Blob = await EncodingWebmToMp4(blob, recordTime);
const url = window.URL.createObjectURL(mp4Blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = `${currentQuestion.questionContent}.webm`;
a.download = `${currentQuestion.questionContent}.mp4`;

document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success('성공적으로 컴퓨터에 저장되었습니다.');
};

export const EncodingWebmToMp4 = async (blob: Blob, recordTime: string) => {
const baseURL = 'https://unpkg.com/@ffmpeg/core-mt@0.12.4/dist/umd';
toast.info(
'영상 인코딩을 시작합니다. 새로고침 혹은 화면을 종료시 데이터가 소실될 수 있습니다.'
);

let lastLogTime = 0;
const logInterval = 10000; // 10초 간격 (밀리초 단위)

ffmpeg.on('log', ({ message }) => {
const currentTime = Date.now();

if (currentTime - lastLogTime > logInterval) {
lastLogTime = currentTime;
const curProgressMessage = compareProgress(message, recordTime);
if (curProgressMessage)
toast.info(curProgressMessage, { autoClose: 5000 });
}
});

if (!ffmpeg.loaded) {
await ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(
`${baseURL}/ffmpeg-core.wasm`,
'application/wasm'
),
workerURL: await toBlobURL(
`${baseURL}/ffmpeg-core.worker.js`,
'text/javascript'
),
});
}

const arrayBuffer = await blob.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
// ffmpeg의 파일 시스템에 파일 작성
await ffmpeg.writeFile('input.webm', uint8Array);

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 인코딩이 완료되었습니다😊');

return newBlob;
};

const compareProgress = (logMessage: string, recordTime: string) => {
const timeMatch = logMessage.match(
/time=([0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{2})/
);
if (!timeMatch) return null;

const currentTimeStr = timeMatch[1];
const currentTime = convertTimeToSeconds(currentTimeStr);
const targetTime = convertTimeToMinutes(recordTime);

if (currentTime >= targetTime) {
return '녹화가 완료되었습니다.';
} else {
const progressPercent = ((currentTime / targetTime) * 100).toFixed(2);
return `인코딩 ${progressPercent}% 진행중`;
}
};

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;
};
Comment on lines +139 to +147
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로 처리해도 괜찮다 싶더라구요!

5 changes: 5 additions & 0 deletions FE/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ module.exports = (env) => {
historyApiFallback: true,
port: 3000,
hot: true,
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
static: path.resolve(__dirname, 'dist'),
proxy: {
'/api': {
Expand Down Expand Up @@ -83,5 +87,6 @@ module.exports = (env) => {
},
],
},
ignoreWarnings: [/Critical dependency:/],
};
};
Loading