285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
// src/routes/api/save-crop-video/+server.ts
|
|
import type { RequestHandler } from './$types';
|
|
import { error } from '@sveltejs/kit';
|
|
import { spawn, exec } from 'child_process';
|
|
import { promisify } from 'util';
|
|
import * as fs from 'fs/promises';
|
|
import * as path from 'path';
|
|
import pool from '$lib/server/database'; // 1. DB 풀
|
|
import { activeProcesses } from '$lib/server/processes'; // 2. 프로세스 매니저
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
// --- 경로 설정 ---
|
|
// (주의) 이 경로는 SvelteKit 서버가 실행되는 '호스트' 기준입니다. (컨테이너 내부가 아님)
|
|
// '/home/ssdoctors/project/BigDataPolyp/tmp/process.mp4' (이전 로그 기준)
|
|
const TEMP_FILE_PATH = path.resolve(process.cwd(), 'tmp', 'process.mp4');
|
|
const ORIGINAL_DIR = '/workspace/image/video/original';
|
|
const CROP_DIR = '/workspace/image/video/crop';
|
|
|
|
// --- 헬퍼 함수 ---
|
|
|
|
/**
|
|
* SSE(Server-Sent Events) 메시지 포맷으로 데이터를 전송합니다.
|
|
*/
|
|
function sendEvent(controller: ReadableStreamDefaultController, data: object) {
|
|
controller.enqueue(`data: ${JSON.stringify(data)}\n\n`);
|
|
}
|
|
|
|
/**
|
|
* FFmpeg의 시간 문자열(예: 00:01:30.50)을 초 단위로 변환합니다.
|
|
*/
|
|
function parseFFmpegTime(timeString: string): number {
|
|
const [hours, minutes, seconds] = timeString.split(':').map(parseFloat);
|
|
return hours * 3600 + minutes * 60 + seconds;
|
|
}
|
|
|
|
/**
|
|
* ffprobe를 실행하여 동영상의 총 길이를 초 단위로 반환합니다.
|
|
*/
|
|
async function getDuration(filePath: string): Promise<number> {
|
|
try {
|
|
const command = `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`;
|
|
const { stdout } = await execAsync(command);
|
|
return parseFloat(stdout);
|
|
} catch (e) {
|
|
console.error('ffprobe duration error:', e);
|
|
throw new Error('동영상 길이를 가져오는 데 실패했습니다.');
|
|
}
|
|
}
|
|
|
|
const formatDurationForDB = (timeInSeconds: number): string => {
|
|
if (isNaN(timeInSeconds) || timeInSeconds < 0) return '00:00:00.000';
|
|
|
|
const pad = (num: number, size: number) => num.toString().padStart(size, '0');
|
|
|
|
const hours = Math.floor(timeInSeconds / 3600);
|
|
const minutes = Math.floor((timeInSeconds % 3600) / 60);
|
|
const seconds = Math.floor(timeInSeconds % 60);
|
|
const milliseconds = Math.round((timeInSeconds - Math.floor(timeInSeconds)) * 1000);
|
|
|
|
return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)}.${pad(milliseconds, 3)}`;
|
|
};
|
|
|
|
/**
|
|
* TumerMovie 테이블에 비디오 정보를 등록합니다.
|
|
*/
|
|
|
|
async function registerInDB(filePath: string, movieType: 1 | 2, originalPath: string) {
|
|
const client = await pool.connect();
|
|
try {
|
|
// 1. [수정] 더 빠른 ffprobe 명령어 (duration과 fps 사용)
|
|
const ffprobeCommand = `ffprobe -v error -select_streams v:0 -show_entries stream=duration,avg_frame_rate -of default=noprint_wrappers=1:nokey=1 "${filePath}"`;
|
|
|
|
// stdout 예시:
|
|
// 30000/1001 (avg_frame_rate)
|
|
// 120.500000 (duration)
|
|
const { stdout } = await execAsync(ffprobeCommand);
|
|
const [fpsString, durationString] = stdout.trim().split('\n');
|
|
|
|
// '30000/1001' 같은 분수 파싱
|
|
const fpsParts = fpsString.split('/');
|
|
const avg_fps = parseFloat(fpsParts[0]) / (parseFloat(fpsParts[1] || '1'));
|
|
const duration = parseFloat(durationString);
|
|
|
|
const durationStartStr = formatDurationForDB(0);
|
|
const durationEndStr = formatDurationForDB(duration);
|
|
|
|
// 총 프레임 수 추정
|
|
const totalFrameCount = Math.round(duration * avg_fps);
|
|
|
|
if (isNaN(totalFrameCount)) {
|
|
throw new Error('TotalFrameCount 계산 실패');
|
|
}
|
|
|
|
const query = `
|
|
INSERT INTO "TumerMovie" ("LocationFile", "MovieType", "created_at", "original_path", "TotalFrameCount", "durationStart", "durationEnd")
|
|
VALUES ($1, $2, NOW(), $3, $4, $5, $6)
|
|
RETURNING "Index";
|
|
`;
|
|
const result = await client.query(query, [filePath, movieType, originalPath, totalFrameCount, durationStartStr, durationEndStr]);
|
|
console.log(`[DB] MovieType ${movieType} 저장 완료 (Index: ${result.rows[0].Index}) FrameCount: ${totalFrameCount} (추정)`);
|
|
} catch (e) {
|
|
console.error(`[DB] MovieType ${movieType} 저장 실패:`, e);
|
|
throw new Error(`DB 저장 실패 (Type: ${movieType})`);
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
/*
|
|
async function registerInDB(filePath: string, movieType: 1 | 2, originalPath: string) {
|
|
const client = await pool.connect();
|
|
try {
|
|
|
|
// 1. FFprobe로 TotalFrameCount 계산 및 TumerMovie에 INSERT
|
|
const ffprobeCommand = `ffprobe -v error -select_streams v:0 -count_frames -show_entries stream=nb_read_frames -of default=nokey=1:noprint_wrappers=1 "${filePath}"`;
|
|
const { stdout } = await execAsync(ffprobeCommand);
|
|
const totalFrameCount = parseInt(stdout.trim(), 10);
|
|
|
|
const query = `
|
|
INSERT INTO "TumerMovie" ("LocationFile", "MovieType", "created_at", "original_path", "TotalFrameCount")
|
|
VALUES ($1, $2, NOW(), $3, $4)
|
|
RETURNING "Index";
|
|
`;
|
|
const result = await client.query(query, [filePath, movieType, originalPath, totalFrameCount]);
|
|
console.log(`[DB] MovieType ${movieType} 저장 완료 (Index: ${result.rows[0].Index}) FrameCount: ${totalFrameCount}`);
|
|
} catch (e) {
|
|
console.error(`[DB] MovieType ${movieType} 저장 실패:`, e);
|
|
throw new Error(`DB 저장 실패 (Type: ${movieType})`);
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
*/
|
|
|
|
// --- API 핸들러 ---
|
|
|
|
export const GET: RequestHandler = ({ url }) => {
|
|
const cropParams = url.searchParams.get('crop');
|
|
const originalFileName = url.searchParams.get('fileName');
|
|
|
|
if (!cropParams || !originalFileName) {
|
|
throw error(400, 'crop 파라미터와 fileName이 필요합니다.');
|
|
}
|
|
|
|
let ffmpegProcess: ReturnType<typeof spawn> | null = null;
|
|
const processId = `crop-${Date.now()}`;
|
|
|
|
const stream = new ReadableStream({
|
|
async start(controller) {
|
|
sendEvent(controller, { processId });
|
|
|
|
try {
|
|
// 0. 대상 디렉토리 생성
|
|
await fs.mkdir(ORIGINAL_DIR, { recursive: true });
|
|
await fs.mkdir(CROP_DIR, { recursive: true });
|
|
|
|
// 1. 파일명 및 경로 정의
|
|
const baseName = path.parse(originalFileName).name;
|
|
const ext = path.parse(originalFileName).ext;
|
|
//const newOriginalFileName = `${baseName}_${processId}${ext}`;
|
|
|
|
// 현재 시각을 YYYYMMDD_HHmmss 형식으로 포맷
|
|
const now = new Date();
|
|
const yyyy = now.getFullYear();
|
|
const MM = String(now.getMonth() + 1).padStart(2, '0');
|
|
const dd = String(now.getDate()).padStart(2, '0');
|
|
const HH = String(now.getHours()).padStart(2, '0');
|
|
const mm = String(now.getMinutes()).padStart(2, '0');
|
|
const ss = String(now.getSeconds()).padStart(2, '0');
|
|
const timestamp = `${yyyy}${MM}${dd}_${HH}${mm}${ss}`;
|
|
|
|
// crop 파일 이름 변경
|
|
const newCropFileName = `${baseName}_crop-${timestamp}${ext}`;
|
|
|
|
const originalDestPath = path.join(ORIGINAL_DIR, originalFileName);
|
|
const cropDestPath = path.join(CROP_DIR, newCropFileName);
|
|
|
|
// 2. 동영상 총 길이 가져오기 (프로그레스 계산용)
|
|
const totalDuration = await getDuration(TEMP_FILE_PATH);
|
|
sendEvent(controller, { message: `동영상 총 길이: ${totalDuration.toFixed(2)}초` });
|
|
|
|
// 3. [작업 1] 원본 파일 복사 및 DB 등록 (MovieType: 2)
|
|
sendEvent(controller, { message: '원본 파일 저장 중...' });
|
|
await fs.copyFile(TEMP_FILE_PATH, originalDestPath);
|
|
await registerInDB(originalDestPath, 2, originalDestPath);
|
|
sendEvent(controller, { message: '원본 파일 저장 완료.' });
|
|
|
|
|
|
// 4. [작업 2] Crop 파일 생성 (MovieType: 1)
|
|
sendEvent(controller, { message: 'Crop 동영상 생성 시작...' });
|
|
/*
|
|
const args = [
|
|
'-i', originalDestPath,
|
|
'-vf', `crop=${cropParams}`,
|
|
'-c:a', 'copy', // 오디오는 재인코딩 없이 복사
|
|
'-y', // 덮어쓰기
|
|
cropDestPath
|
|
];
|
|
*/
|
|
|
|
const args = [
|
|
'-i', originalDestPath,
|
|
'-vf', `crop=${cropParams}`,
|
|
'-r', '60',
|
|
'-qp', '10',
|
|
'-c:v', 'h264_nvenc',
|
|
'-c:a', 'copy',
|
|
'-y', cropDestPath,
|
|
'-progress', 'pipe:1'
|
|
];
|
|
|
|
ffmpegProcess = spawn('ffmpeg', args);
|
|
activeProcesses.set(processId, ffmpegProcess);
|
|
|
|
// FFmpeg 진행률 파싱
|
|
ffmpegProcess.stderr.on('data', (data: Buffer) => {
|
|
const line = data.toString();
|
|
// 예: time=00:00:10.54
|
|
const timeMatch = line.match(/time=(\d{2}:\d{2}:\d{2}\.\d{2})/);
|
|
if (timeMatch) {
|
|
const currentTime = parseFFmpegTime(timeMatch[1]);
|
|
let progress = (currentTime / totalDuration) * 100;
|
|
progress = Math.min(Math.max(progress, 0), 100); // 0~100% 범위 보장
|
|
sendEvent(controller, { progress: progress });
|
|
}
|
|
});
|
|
|
|
// FFmpeg 종료 이벤트
|
|
ffmpegProcess.on('close', async (code) => {
|
|
activeProcesses.delete(processId);
|
|
if (code === 0) {
|
|
// 성공
|
|
sendEvent(controller, { progress: 100 });
|
|
await registerInDB(cropDestPath, 1, originalDestPath);
|
|
sendEvent(controller, { status: 'completed' });
|
|
} else {
|
|
// 0이 아닌 코드는 오류 또는 취소
|
|
console.error(`FFmpeg exited with code ${code}`);
|
|
if (code === 255) { // SIGTERM에 의한 종료 코드 (OS마다 다를 수 있음)
|
|
sendEvent(controller, { error: '작업이 사용자에 의해 취소되었습니다.' });
|
|
} else {
|
|
sendEvent(controller, { error: `FFmpeg 처리 실패 (Code: ${code})` });
|
|
}
|
|
}
|
|
// 5. [작업 3] 임시 파일 삭제
|
|
await fs.unlink(TEMP_FILE_PATH).catch(e => console.error("임시 파일 삭제 실패:", e));
|
|
controller.close();
|
|
});
|
|
|
|
// FFmpeg 스폰 오류
|
|
ffmpegProcess.on('error', (err) => {
|
|
activeProcesses.delete(processId);
|
|
console.error('FFmpeg spawn error:', err);
|
|
sendEvent(controller, { error: `FFmpeg 실행 오류: ${err.message}` });
|
|
controller.close();
|
|
});
|
|
|
|
} catch (err) {
|
|
const errorMessage = (err as Error).message;
|
|
console.error('save-crop-video API error:', errorMessage);
|
|
sendEvent(controller, { error: errorMessage });
|
|
controller.close();
|
|
// 오류 발생 시에도 임시 파일 삭제 시도
|
|
await fs.unlink(TEMP_FILE_PATH).catch(e => console.error("오류 후 임시 파일 삭제 실패:", e));
|
|
}
|
|
},
|
|
cancel() {
|
|
// 클라이언트가 EventSource.close()를 호출하거나 연결이 끊겼을 때
|
|
if (ffmpegProcess) {
|
|
console.log(`[${processId}] 클라이언트 연결 끊김. 프로세스 종료 중...`);
|
|
ffmpegProcess.kill('SIGTERM');
|
|
activeProcesses.delete(processId);
|
|
}
|
|
}
|
|
});
|
|
|
|
return new Response(stream, {
|
|
headers: {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive'
|
|
}
|
|
});
|
|
}; |