BigDataPolyp/src/routes/api/save-crop-video/+server.ts
2025-10-22 00:05:25 +00:00

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