// 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 { 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 | 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' } }); };