Fix git merge error

This commit is contained in:
birdhead 2025-10-22 00:22:33 +00:00
parent 868dde7d94
commit 7227e70383
12 changed files with 155 additions and 1404 deletions

84
+layout.svelte Executable file
View File

@ -0,0 +1,84 @@
<script lang="ts">
import '../app.css';
import type { LayoutData } from './$types'; // data 타입 (user와 menuItems 포함)
import { goto } from '$app/navigation';
import { page } from '$app/stores';
export let data: LayoutData; // 서버로부터 user와 menuItems를 받음
let selectedPage = '';
// [삭제] Svelte 파일에 있던 하드코딩된 menuItems 배열을 삭제합니다.
// const menuItems = [ ... ]; // <-- 부분 삭제
// [수정] data.menuItems를 사용하도록 변경
$: {
if (data.menuItems && data.menuItems.length > 0) {
const currentItem = data.menuItems.find((item) => $page.url.pathname.startsWith(item.path));
selectedPage = currentItem ? currentItem.path : '';
} else {
selectedPage = '';
}
}
function handleNavigate(event: Event) {
const target = event.target as HTMLSelectElement;
if (target.value && target.value !== selectedPage) {
// [선택적] 현재 페이지와 다를 때만 이동
goto(target.value);
}
}
// 로그아웃 함수 (동일)
async function handleLogout() {
await fetch('/api/logout', {
method: 'POST'
});
window.location.href = '/login';
}
</script>
<div class="flex flex-col h-screen">
<header
class="w-full bg-slate-800 text-white p-3 flex justify-between items-center shadow-md z-10"
>
<div class="flex items-center space-x-4">
<h1 class="text-xl font-bold">BigData Server</h1>
{#if data.user}
<select
value={selectedPage} on:change={handleNavigate}
class="bg-slate-700 text-white text-sm rounded-md py-1.5 px-3 border border-slate-600
focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer"
>
<option value="" disabled>-- 페이지 선택 --</option>
{#each data.menuItems as item (item.path)}
<option value={item.path}>{item.title}</option>
{/each}
</select>
{/if}
</div>
<div class="flex items-center space-x-2">
{#if data.user}
<span class="text-xs uppercase text-cyan-400">[{data.user.role}]</span>
<span>{data.user.name}</span>
<button
on:click={handleLogout}
type="button"
class="text-sm text-gray-300 hover:text-white cursor-pointer bg-transparent border-none p-0"
>
(로그아웃)
</button>
{:else}
<a href="/login" class="text-sm hover:text-white">로그인</a>
{/if}
</div>
</header>
<main class="flex-1 overflow-auto bg-gray-100">
<slot />
</main>
</div>

22
package-lock.json generated
View File

@ -11,15 +11,11 @@
"@ffmpeg/core": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
<<<<<<< HEAD
"canvas": "^3.2.0",
=======
"bcrypt": "^6.0.0",
"canvas": "^3.2.0",
"jsonwebtoken": "^9.0.2",
"mime": "^4.1.0",
"mime-types": "^3.0.1",
>>>>>>> 0bab142 (add login/logout)
"pg": "^8.16.3"
},
"devDependencies": {
@ -2447,8 +2443,6 @@
],
"license": "MIT"
},
<<<<<<< HEAD
=======
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
@ -2472,7 +2466,6 @@
"node": "^18 || ^20 || >= 21"
}
},
>>>>>>> 0bab142 (add login/logout)
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@ -2532,15 +2525,12 @@
"ieee754": "^1.1.13"
}
},
<<<<<<< HEAD
=======
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
>>>>>>> 0bab142 (add login/logout)
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@ -2829,8 +2819,6 @@
"dev": true,
"license": "MIT"
},
<<<<<<< HEAD
=======
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
@ -2840,7 +2828,6 @@
"safe-buffer": "^5.0.1"
}
},
>>>>>>> 0bab142 (add login/logout)
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@ -3969,8 +3956,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
<<<<<<< HEAD
=======
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@ -4007,7 +3992,6 @@
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
>>>>>>> 0bab142 (add login/logout)
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -4085,8 +4069,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
<<<<<<< HEAD
=======
"node_modules/mime": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz",
@ -4123,7 +4105,6 @@
"node": ">= 0.6"
}
},
>>>>>>> 0bab142 (add login/logout)
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
@ -4273,8 +4254,6 @@
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
<<<<<<< HEAD
=======
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
@ -4286,7 +4265,6 @@
"node-gyp-build-test": "build-test.js"
}
},
>>>>>>> 0bab142 (add login/logout)
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",

View File

@ -51,15 +51,11 @@
"@ffmpeg/core": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
<<<<<<< HEAD
"canvas": "^3.2.0",
=======
"bcrypt": "^6.0.0",
"canvas": "^3.2.0",
"jsonwebtoken": "^9.0.2",
"mime": "^4.1.0",
"mime-types": "^3.0.1",
>>>>>>> 0bab142 (add login/logout)
"pg": "^8.16.3"
}
}

View File

@ -1,17 +1,5 @@
// src/hooks.server.ts
<<<<<<< HEAD
import type { Handle } from '@sveltejs/kit';
/** @type {import('@sveltejs/kit').Handle} */
export const handle: Handle = async ({ event, resolve }) => {
const response = await resolve(event);
// ffmpeg.wasm이 SharedArrayBuffer를 사용하기 위해 필요한 헤더 설정
response.headers.set('Cross-Origin-Opener-Policy', 'same-origin');
response.headers.set('Cross-Origin-Embedder-Policy', 'require-corp');
return response;
};
=======
import type { Handle } from '@sveltejs/kit';
import jwt from 'jsonwebtoken';
@ -38,4 +26,4 @@ export const handle: Handle = async ({ event, resolve }) => {
response.headers.set('Cross-Origin-Embedder-Policy', 'require-corp');
return response;
};
>>>>>>> 0bab142 (add login/logout)

View File

@ -39,18 +39,7 @@
}
</script>
<<<<<<< HEAD
<div class="flex flex-col h-svh font-sans bg-gray-100">
<header class="flex-shrink-0 bg-gray-800 text-white shadow-md z-10">
<div class="container mx-auto px-4">
<h1 class="text-lg md:text-xl font-bold h-12 flex items-center">BigData Server</h1>
</div>
</header>
<main class="flex-grow flex overflow-hidden min-h-0">
<slot />
</main>
=======
<div class="flex flex-col h-screen">
<header
class="w-full bg-slate-800 text-white p-3 flex justify-between items-center shadow-md z-10"
@ -93,5 +82,6 @@
<main class="flex-1 overflow-auto bg-gray-100">
<slot />
</main>
>>>>>>> 0bab142 (add login/logout)
</div>
</div>

View File

@ -1,734 +0,0 @@
<script context="module" lang="ts">
// Declare global cv for TypeScript
declare let cv: any;
</script>
<script lang="ts">
type PageData = any;
import { tick, onMount } from 'svelte';
import Icon from '@iconify/svelte';
// --- 상태 변수 ---
export let data: PageData;
let dataNumbers: DataNumberInfo[] = [];
let rectangles: Rectangle[] = [];
let selectedMovie: number | null = null;
let selectedDataNumberInfo: DataNumberInfo | null = null;
let isLoadingDataNumbers = false;
let isLoadingImage = false;
let imageHasError = false;
// --- Undo/Redo 및 OF 결과 저장을 위한 상태 변수 ---
let originalRectsFromDB: Rectangle[] = [];
let stabilizedRectsResult: {
stabilizedRects: Rectangle[],
flowLines: any[]
} | null = null;
// --- UI 제어 상태 변수 ---
let isCvReady = false;
let opticalFlowCanvas: HTMLCanvasElement;
let showOpticalFlowVectors = true;
let showPrevRects = true;
let showCurrentRects = true;
let prevRectsForDisplay: Rectangle[] = [];
// --- 상호작용 상태 변수 ---
let selectedRectIds: number[] = [];
let imageElement: HTMLImageElement;
let svgElement: SVGSVGElement;
let viewBox = '0 0 100 100';
let activeInteraction: {
type: 'move' | 'resize';
handle?: string;
startX: number;
startY: number;
initialRects?: Rectangle[];
} | null = null;
let dataNumberPanel: HTMLDivElement;
let clipboard: Omit<Rectangle, 'id'>[] | null = null;
// --- 반응형 로직 ---
let selectedRectangles: Rectangle[] = [];
$: selectedRectangles = rectangles.filter(r => selectedRectIds.includes(r.id));
$: if (imageElement) {
const updateViewBox = () => {
if (imageElement.naturalWidth > 0 && imageElement.naturalHeight > 0) {
viewBox = `0 0 ${imageElement.naturalWidth} ${imageElement.naturalHeight}`;
}
};
if (imageElement.complete) {
updateViewBox();
} else {
imageElement.onload = updateViewBox;
}
}
$: if (selectedDataNumberInfo) {
imageHasError = false;
selectedRectIds = [];
if (dataNumberPanel) {
const selectedElement = dataNumberPanel.querySelector(`[data-index="${selectedDataNumberInfo.Index}"]`);
if (selectedElement) {
const containerRect = dataNumberPanel.getBoundingClientRect();
const elementRect = selectedElement.getBoundingClientRect();
if (elementRect.top < containerRect.top || elementRect.bottom > containerRect.bottom) {
//selectedElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
selectedElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
}
}
$: if (opticalFlowCanvas) {
showOpticalFlowVectors, stabilizedRectsResult, redrawCanvas();
}
$: crudActions = [
{ label: 'Add', icon: 'mdi:plus-box-outline', onClick: addNewRectangle, disabled: !selectedDataNumberInfo, color: 'bg-blue-600 hover:bg-blue-700' },
{ label: 'Copy', icon: 'mdi:content-copy', onClick: copySelectedRect, disabled: selectedRectangles.length === 0, color: 'bg-indigo-600 hover:bg-indigo-700' },
{ label: 'Paste', icon: 'mdi:content-paste', onClick: pasteRect, disabled: !clipboard || clipboard.length === 0, color: 'bg-purple-600 hover:bg-purple-700' },
{ label: 'Delete', icon: 'mdi:delete-outline', onClick: deleteSelectedRect, disabled: selectedRectangles.length === 0, color: 'bg-red-600 hover:bg-red-700' },
{ label: 'Save', icon: 'mdi:content-save-outline', onClick: () => saveRectangles(rectangles), disabled: rectangles.length === 0, color: 'bg-teal-600 hover:bg-teal-700' }
];
$: flowActions = [
{ label: 'Apply Flow', icon: 'mdi:arrow-right-bold-box-outline', onClick: applyOpticalFlowResult, disabled: !stabilizedRectsResult, color: 'bg-green-600 hover:bg-green-700' },
{ label: 'Undo', icon: 'mdi:undo', onClick: undoOpticalFlowResult, disabled: rectangles === originalRectsFromDB, color: 'bg-gray-500 hover:bg-gray-600' }
];
onMount(() => {
const initialize = () => {
if (cv && cv.Mat) { isCvReady = true; return; }
if (cv) { cv.onRuntimeInitialized = () => { isCvReady = true; }; }
};
const intervalId = setInterval(() => {
if (typeof cv !== 'undefined') { clearInterval(intervalId); initialize(); }
}, 50);
});
// (이하 모든 script 로직은 이전과 동일)
// ... calculateOpticalFlow, redrawCanvas, selectMovie, selectDataNumber 등 모든 함수 ...
// --- 계산만 담당하는 함수 ---
async function calculateOpticalFlow(prevImgSrc: string, currentImgSrc: string, prevRects: Rectangle[]): Promise<{ stabilizedRects: Rectangle[], flowLines: any[] }> {
const emptyResult = { stabilizedRects: prevRects, flowLines: [] };
if (!isCvReady) return emptyResult;
const loadImage = (src: string): Promise<HTMLImageElement> => new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "Anonymous";
img.onload = () => resolve(img);
img.onerror = (err) => reject(new Error(`Failed to load image: ${src}, error: ${err}`));
img.src = src;
});
try {
const [prevImg, currentImg] = await Promise.all([loadImage(prevImgSrc), loadImage(currentImgSrc)]);
const stabilizedRects: Rectangle[] = [];
let allGoodFlowLines: any[] = [];
let src1 = cv.imread(prevImg);
let src2 = cv.imread(currentImg);
let gray1 = new cv.Mat();
let gray2 = new cv.Mat();
cv.cvtColor(src1, gray1, cv.COLOR_RGBA2GRAY);
cv.cvtColor(src2, gray2, cv.COLOR_RGBA2GRAY);
for (const prevRect of prevRects) {
let allFlowLines: any[] = [];
let points1 = new cv.Mat();
let points2 = new cv.Mat();
let status = new cv.Mat();
let err = new cv.Mat();
const roiRect = new cv.Rect(prevRect.x, prevRect.y, prevRect.width, prevRect.height);
let roiGray1 = gray1.roi(roiRect);
cv.goodFeaturesToTrack(roiGray1, points1, 200, 0.01, 5);
if (points1.rows === 0) {
stabilizedRects.push({ ...prevRect });
roiGray1.delete(); points1.delete(); points2.delete(); status.delete(); err.delete();
continue;
}
for(let i = 0; i < points1.rows; i++) {
points1.data32F[i * 2] += prevRect.x;
points1.data32F[i * 2 + 1] += prevRect.y;
}
cv.calcOpticalFlowPyrLK(gray1, gray2, points1, points2, status, err);
let initial_avg_dx = 0, initial_avg_dy = 0, tracked_count = 0;
for (let i = 0; i < status.rows; ++i) {
if (status.data[i] === 1) {
const p1 = new cv.Point(points1.data32F[i * 2], points1.data32F[i * 2 + 1]);
const p2 = new cv.Point(points2.data32F[i * 2], points2.data32F[i * 2 + 1]);
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
allFlowLines.push({ p1, p2, dx, dy, length: Math.sqrt(dx*dx + dy*dy) });
initial_avg_dx += dx;
initial_avg_dy += dy;
tracked_count++;
}
}
if (tracked_count > 0) {
initial_avg_dx /= tracked_count;
initial_avg_dy /= tracked_count;
}
const maxLength = 40;
const goodFlowLines = allFlowLines.filter(line => {
if (line.length > maxLength) return false;
const dotProduct = (line.dx * initial_avg_dx) + (line.dy * initial_avg_dy);
if (dotProduct < 0) return false;
return true;
});
allGoodFlowLines.push(...goodFlowLines);
let final_avg_dx = 0, final_avg_dy = 0;
if (goodFlowLines.length > 0) {
goodFlowLines.forEach(line => { final_avg_dx += line.dx; final_avg_dy += line.dy; });
final_avg_dx /= goodFlowLines.length;
final_avg_dy /= goodFlowLines.length;
}
stabilizedRects.push({ ...prevRect, x: prevRect.x + final_avg_dx, y: prevRect.y + final_avg_dy });
points1.delete(); points2.delete(); status.delete(); err.delete(); roiGray1.delete();
}
src1.delete(); src2.delete(); gray1.delete(); gray2.delete();
return { stabilizedRects, flowLines: allGoodFlowLines };
} catch (error) {
console.error("Optical Flow 처리 중 에러:", error);
return emptyResult;
}
}
// 그리기를 전담하는 함수
function redrawCanvas() {
const ctx = opticalFlowCanvas.getContext('2d');
if (!ctx || !imageElement) return;
const canvasWidth = opticalFlowCanvas.clientWidth;
const canvasHeight = opticalFlowCanvas.clientHeight;
opticalFlowCanvas.width = canvasWidth;
opticalFlowCanvas.height = canvasHeight;
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
if (showOpticalFlowVectors && stabilizedRectsResult) {
// --- 1. 모바일 환경을 감지하는 로직 추가 ---
const isMobile = canvasWidth < 768; // 화면 너비가 768px 미만이면 모바일로 간주
// --- 2. 모바일/데스크톱에 따라 화살표 스타일 동적 변경 ---
const lineWidth = isMobile ? 1 : 2; // 모바일이면 선 굵기 1px
const headlen = isMobile ? 3 : 5; // 모바일이면 화살표 머리 크기 3
const arrowColor = 'rgba(0, 255, 255, 0.8)'; // 눈에 잘 띄는 청록색(Cyan)으로 변경
const maxFlowLines = isMobile ? 75 : 500; // 모바일이면 최대 75개 화살표만 표시
const { stabilizedRects, flowLines } = stabilizedRectsResult;
// --- 3. 표시할 화살표 개수를 제한하는 로직 추가 ---
let linesToDraw = flowLines;
if (flowLines.length > maxFlowLines) {
// 화살표가 너무 많으면, 무작위로 샘플링하여 개수를 줄임
linesToDraw = flowLines.sort(() => 0.5 - Math.random()).slice(0, maxFlowLines);
}
const imgNaturalWidth = imageElement.naturalWidth;
const imgNaturalHeight = imageElement.naturalHeight;
const scale = Math.min(canvasWidth / imgNaturalWidth, canvasHeight / imgNaturalHeight);
const renderedImgWidth = imgNaturalWidth * scale;
const renderedImgHeight = imgNaturalHeight * scale;
const offsetX = (canvasWidth - renderedImgWidth) / 2;
const offsetY = (canvasHeight - renderedImgHeight) / 2;
// 화살표 그리기 (수정된 스타일 변수 사용)
ctx.lineWidth = lineWidth;
ctx.strokeStyle = arrowColor;
ctx.fillStyle = arrowColor;
linesToDraw.forEach(line => { // <-- 전체 flowLines 대신 개수가 조절된 linesToDraw 사용
const startX = (line.p1.x * scale) + offsetX;
const startY = (line.p1.y * scale) + offsetY;
const endX = (line.p2.x * scale) + offsetX;
const endY = (line.p2.y * scale) + offsetY;
if (Math.abs(startX - endX) < 0.5 && Math.abs(startY - endY) < 0.5) return;
const angle = Math.atan2(endY - startY, endX - startX);
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(endX, endY);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(endX, endY);
ctx.lineTo(endX - headlen * Math.cos(angle - Math.PI / 6), endY - headlen * Math.sin(angle - Math.PI / 6));
ctx.lineTo(endX - headlen * Math.cos(angle + Math.PI / 6), endY - headlen * Math.sin(angle + Math.PI / 6));
ctx.lineTo(endX, endY);
ctx.fill();
});
// 예측 사각형 그리기 (이 부분은 변경 없음)
stabilizedRects.forEach(rect => {
const rectX = (rect.x * scale) + offsetX;
const rectY = (rect.y * scale) + offsetY;
const rectWidth = rect.width * scale;
const rectHeight = rect.height * scale;
ctx.strokeStyle = 'rgba(0, 255, 0, 0.9)';
ctx.lineWidth = 2; // 예측 사각형은 원래 굵기 유지
ctx.strokeRect(rectX, rectY, rectWidth, rectHeight);
});
}
}
async function selectMovie(movieIndex: number) {
if (selectedMovie === movieIndex) return;
selectedMovie = movieIndex;
selectedDataNumberInfo = null;
rectangles = [];
dataNumbers = [];
isLoadingDataNumbers = true;
try {
const response = await fetch(`/api/datanumbers/${movieIndex}`);
if (!response.ok) throw new Error('Failed to fetch data numbers');
dataNumbers = await response.json();
} catch (error) {
console.error(error);
alert((error as Error).message);
} finally {
isLoadingDataNumbers = false;
}
}
async function selectDataNumber(dataInfo: DataNumberInfo) {
if (selectedDataNumberInfo?.Index === dataInfo.Index || isLoadingImage) return;
const prevRects = JSON.parse(JSON.stringify(rectangles));
const prevImgSrc = selectedDataNumberInfo?.LocationFile ? `/api/images/${selectedDataNumberInfo.LocationFile}` : null;
prevRectsForDisplay = prevRects;
isLoadingImage = true;
selectedRectIds = [];
stabilizedRectsResult = null;
try {
const newImgSrc = `/api/images/${dataInfo.LocationFile}`;
const preloadImagePromise = new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(true);
img.onerror = () => reject(new Error('Image preloading failed'));
img.src = newImgSrc;
});
const fetchRectsPromise = fetch(`/api/rectangles/by-ref/${dataInfo.Index}`);
const [_, response] = await Promise.all([preloadImagePromise, fetchRectsPromise]);
if (!response.ok) throw new Error('Failed to fetch rectangles');
const newRectsFromAPI = await response.json();
originalRectsFromDB = newRectsFromAPI;
selectedDataNumberInfo = dataInfo;
await tick();
if (prevImgSrc && prevRects.length > 0 && isCvReady) {
stabilizedRectsResult = await calculateOpticalFlow(prevImgSrc, newImgSrc, prevRects);
}
rectangles = newRectsFromAPI;
const newButton = dataNumberPanel?.querySelector(`li[data-index="${dataInfo.Index}"] button`) as HTMLElement;
newButton?.focus();
} catch (error) {
console.error(error);
alert((error as Error).message);
} finally {
isLoadingImage = false;
}
}
function applyOpticalFlowResult() {
if (!stabilizedRectsResult || !originalRectsFromDB) return;
const stabilizedRects = stabilizedRectsResult.stabilizedRects;
if (stabilizedRects.length !== originalRectsFromDB.length) {
alert("The number of rectangles does not match. Cannot apply flow.");
return;
}
const updatedRects = originalRectsFromDB.map((originalRect, index) => {
const stabilizedRect = stabilizedRects[index];
return {
id: originalRect.id,
x: stabilizedRect.x,
y: stabilizedRect.y,
width: stabilizedRect.width,
height: stabilizedRect.height,
};
});
rectangles = updatedRects;
saveRectangles(rectangles);
}
function undoOpticalFlowResult() {
rectangles = JSON.parse(JSON.stringify(originalRectsFromDB));
saveRectangles(rectangles);
}
async function addNewRectangle() {
if (!selectedDataNumberInfo) return;
const defaultRect = { x: 10, y: 10, width: 100, height: 100 };
try {
const url = `/api/rectangles/by-ref/${selectedDataNumberInfo.Index}`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(defaultRect),
});
if (!response.ok) throw new Error(await response.text());
const newRectFromServer = await response.json();
const finalNewRect = { ...defaultRect, id: newRectFromServer.id };
rectangles = [...rectangles, finalNewRect];
originalRectsFromDB = [...originalRectsFromDB, finalNewRect];
selectedRectIds = [finalNewRect.id];
} catch (error) {
console.error(error);
alert((error as Error).message);
}
}
async function saveRectangles(rects: Rectangle[]) {
if (!rects || rects.length === 0) return;
const url = `/api/rectangles/bulk-update`;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rects),
});
if (!response.ok) throw new Error('Failed to save rectangles');
} catch (error) {
console.error(error);
alert((error as Error).message);
}
}
async function deleteSelectedRect() {
if (selectedRectIds.length === 0) return;
const idsToDelete = [...selectedRectIds];
rectangles = rectangles.filter(r => !idsToDelete.includes(r.id));
selectedRectIds = [];
try {
await Promise.all(
idsToDelete.map(id =>
fetch(`/api/rectangles/by-id/${id}`, { method: 'DELETE' }).then(res => {
if (!res.ok) throw new Error(`Failed to delete rectangle ${id}`);
})
)
);
} catch (error) {
console.error(error);
}
}
function copySelectedRect() {
if (selectedRectangles.length === 0) {
clipboard = null;
return;
}
clipboard = selectedRectangles.map(({ id, ...rest }) => rest);
}
async function pasteRect() {
if (!clipboard || clipboard.length === 0 || !selectedDataNumberInfo) return;
const newRectsData = clipboard.map(rect => ({
x: rect.x + 10,
y: rect.y + 10,
width: rect.width,
height: rect.height,
}));
try {
const url = `/api/rectangles/by-ref/${selectedDataNumberInfo.Index}`;
const createdRectsFromServer = await Promise.all(
newRectsData.map(rectData =>
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rectData),
}).then(res => res.ok ? res.json() : Promise.reject('Failed to create'))
)
);
const finalNewRects = newRectsData.map((rectData, index) => ({
...rectData,
id: createdRectsFromServer[index].id,
}));
rectangles = [...rectangles, ...finalNewRects];
selectedRectIds = finalNewRects.map(r => r.id);
} catch (error) {
console.error(error);
alert((error as Error).message);
}
}
function getMousePosition(event: MouseEvent) {
if (!imageElement || !svgElement) return { x: 0, y: 0 };
const svgRect = svgElement.getBoundingClientRect();
const { naturalWidth, naturalHeight, clientWidth, clientHeight } = imageElement;
const scale = Math.min(clientWidth / naturalWidth, clientHeight / naturalHeight);
const renderedWidth = naturalWidth * scale;
const renderedHeight = naturalHeight * scale;
const offsetX = (clientWidth - renderedWidth) / 2;
const offsetY = (clientHeight - renderedHeight) / 2;
const mouseX = (event.clientX - svgRect.left - offsetX) / scale;
const mouseY = (event.clientY - svgRect.top - offsetY) / scale;
return { x: mouseX, y: mouseY };
}
function handleInteractionStart(event: MouseEvent, rect: Rectangle, type: 'move' | 'resize', handle: string) {
event.stopPropagation();
const alreadySelected = selectedRectIds.includes(rect.id);
if (event.shiftKey) {
selectedRectIds = alreadySelected ? selectedRectIds.filter(id => id !== rect.id) : [...selectedRectIds, rect.id];
} else if (!alreadySelected) {
selectedRectIds = [rect.id];
}
if (type === 'resize' && selectedRectIds.length > 1) return;
const pos = getMousePosition(event);
activeInteraction = {
type,
handle,
startX: pos.x,
startY: pos.y,
initialRects: rectangles
.filter(r => selectedRectIds.includes(r.id))
.map(r => ({ ...r })),
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
}
function handleMouseMove(event: MouseEvent) {
if (!activeInteraction?.initialRects?.length) return;
const pos = getMousePosition(event);
const dx = pos.x - activeInteraction.startX;
const dy = pos.y - activeInteraction.startY;
if (activeInteraction.type === 'resize') {
const initialRect = activeInteraction.initialRects[0];
const i = rectangles.findIndex(r => r.id === initialRect.id);
if (i === -1) return;
const { handle } = activeInteraction;
let { x, y, width, height } = initialRect;
if (handle!.includes('e')) width += dx;
if (handle!.includes('w')) { width -= dx; x += dx; }
if (handle!.includes('s')) height += dy;
if (handle!.includes('n')) { height -= dy; y += dy; }
rectangles[i] = { ...rectangles[i], x, y, width: Math.max(10, width), height: Math.max(10, height) };
} else if (activeInteraction.type === 'move') {
activeInteraction.initialRects.forEach(initialRect => {
const i = rectangles.findIndex(r => r.id === initialRect.id);
if (i !== -1) {
rectangles[i] = { ...rectangles[i], x: initialRect.x + dx, y: initialRect.y + dy };
}
});
}
}
function handleMouseUp() {
if (activeInteraction) saveRectangles(rectangles);
activeInteraction = null;
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
}
function deselectRect(event: Event) {
if (event.target === svgElement) selectedRectIds = [];
}
function handleKeyDown(event: KeyboardEvent) {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return;
if (event.ctrlKey || event.metaKey) {
if (event.key.toLowerCase() === 'c') { event.preventDefault(); copySelectedRect(); return; }
if (event.key.toLowerCase() === 'v') { event.preventDefault(); pasteRect(); return; }
}
if (!selectedDataNumberInfo || dataNumbers.length <= 1) return;
const currentIndex = dataNumbers.findIndex(d => d.Index === selectedDataNumberInfo!.Index);
if (currentIndex === -1) return;
let nextIndex = -1;
const keyMap = { ArrowDown: 1, ArrowRight: 1, PageDown: 1, ArrowUp: -1, ArrowLeft: -1, PageUp: -1 };
const step = keyMap[event.key as keyof typeof keyMap];
if (step) {
event.preventDefault();
const newIndex = currentIndex + step;
if (newIndex >= 0 && newIndex < dataNumbers.length) {
selectDataNumber(dataNumbers[newIndex]);
}
}
}
const handles = ['n', 's', 'e', 'w', 'nw', 'ne', 'sw', 'se'];
</script>
<svelte:window on:keydown={handleKeyDown} />
<div class="flex flex-col landscape:flex-row w-full h-full overflow-hidden bg-gray-100">
<div class="w-full flex-shrink-0 flex flex-row landscape:flex-col lg:flex-col gap-2 p-2
h-[25vh] landscape:h-full
landscape:w-[30%] landscape:max-w-xs
lg:w-[15%] lg:min-w-[200px] lg:max-w-xs">
<div class="w-1/2 landscape:w-full lg:w-full h-full landscape:h-1/2 lg:h-1/2 flex flex-col gap-1">
<h2 class="flex-shrink-0 text-sm font-bold text-gray-600 uppercase tracking-wider px-1">Movie Index</h2>
<div class="nav-section border bg-white border-gray-200 rounded-lg p-2 flex-grow overflow-y-auto min-h-0">
<ul class="space-y-1">
{#if data.movies && data.movies.length > 0}
{#each data.movies as movie}
<li>
<button
on:click={() => selectMovie(movie)}
class="w-full text-left px-3 py-2 rounded-md text-gray-700 transition-colors duration-150 text-sm"
class:bg-blue-500={selectedMovie === movie}
class:text-white={selectedMovie === movie}
class:hover:bg-gray-100={selectedMovie !== movie}
>
{movie}
</button>
</li>
{/each}
{:else if data.error}
<p class="text-sm text-red-500 px-3">{data.error}</p>
{:else}
<p class="text-sm text-gray-500 px-3">No movies found.</p>
{/if}
</ul>
</div>
</div>
<div class="w-1/2 landscape:w-full lg:w-full h-full landscape:h-1/2 lg:h-1/2 flex flex-col gap-1">
<h2 class="flex-shrink-0 text-sm font-bold text-gray-600 uppercase tracking-wider px-1">Data Number</h2>
<div bind:this={dataNumberPanel} class="nav-section border bg-gray-50 border-gray-200 rounded-lg p-2 flex-grow overflow-y-auto min-h-0">
{#if selectedMovie}
{#if isLoadingDataNumbers}
<p class="text-sm text-gray-500 px-3">Loading...</p>
{:else if dataNumbers.length > 0}
<ul class="space-y-1">
{#each dataNumbers as dataInfo (dataInfo.Index)}
<li data-index={dataInfo.Index}>
<button
on:click={() => selectDataNumber(dataInfo)}
class="w-full text-left px-3 py-2 rounded-md text-gray-700 transition-colors duration-150 text-sm"
class:bg-green-500={selectedDataNumberInfo?.Index === dataInfo.Index}
class:text-white={selectedDataNumberInfo?.Index === dataInfo.Index}
class:hover:bg-gray-100={selectedDataNumberInfo?.Index !== dataInfo.Index}
>
{dataInfo.DataNumber}
</button>
</li>
{/each}
</ul>
{:else}
<p class="text-sm text-gray-500 px-3">No data found.</p>
{/if}
{:else}
<div class="text-center text-gray-400 text-sm mt-4">
<p>Select a Movie Index.</p>
</div>
{/if}
</div>
</div>
</div>
<div class="w-full flex flex-col bg-gray-200 flex-grow min-h-0">
<div class="flex-shrink-0 p-2 bg-white border-b border-gray-300 shadow-sm flex flex-col md:flex-row md:items-center md:justify-between gap-2">
<div class="flex items-center gap-1.5 flex-wrap justify-center md:justify-start">
{#each crudActions as action}
<button
on:click={action.onClick}
disabled={action.disabled}
title={action.label}
class="p-2 text-white rounded-md disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors {action.color}"
>
<Icon icon={action.icon} width="20" height="20" />
</button>
{/each}
<div class="flex items-center gap-1.5 border-l-2 border-gray-200 ml-2 pl-2">
{#each flowActions as action}
<button
on:click={action.onClick}
disabled={action.disabled}
title={action.label}
class="p-2 text-white rounded-md disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors {action.color}"
>
<Icon icon={action.icon} width="20" height="20" />
</button>
{/each}
</div>
</div>
<div class="flex items-center justify-center md:justify-end gap-4 text-sm text-gray-600 pr-2">
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" bind:checked={showOpticalFlowVectors} class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
<span>Flow</span>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" bind:checked={showPrevRects} class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
<span>Prev</span>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" bind:checked={showCurrentRects} class="h-4 w-4 rounded border-gray-300 text-yellow-500 focus:ring-yellow-400" />
<span>Current</span>
</label>
</div>
</div>
<div class="flex-grow p-4 lg:p-6 overflow-auto">
{#if !selectedDataNumberInfo}
<div class="h-full flex justify-center items-center text-gray-500"><p>Select a data number to view the image.</p></div>
{:else}
<div class="relative w-full h-full shadow-lg bg-white image-viewer-container">
{#if !imageHasError}
<img
bind:this={imageElement}
src={`/api/images/${selectedDataNumberInfo.LocationFile}`}
alt={`Data ${selectedDataNumberInfo.DataNumber}`}
class="block w-full h-full object-contain"
on:error={() => imageHasError = true}
/>
{/if}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<svg
bind:this={svgElement}
viewBox={viewBox}
class="absolute top-0 left-0 w-full h-full"
on:mousedown|self={deselectRect}
tabindex="0"
on:keydown={(e) => { if (e.key === 'Escape') deselectRect(e); }}
role="region"
aria-label="Image annotation area">
{#if !imageHasError}
{#if showPrevRects}
{#each prevRectsForDisplay as rect (rect.id)}
<rect
x={rect.x} y={rect.y} width={rect.width} height={rect.height}
class="fill-transparent stroke-blue-500 stroke-2"
style="stroke-dasharray: 6 3;"
/>
{/each}
{/if}
{#if showCurrentRects}
{#each rectangles as rect (rect.id)}
{@const isSelected = selectedRectIds.includes(rect.id)}
<rect
x={rect.x} y={rect.y} width={rect.width} height={rect.height}
role="button"
tabindex="0"
aria-label={`Annotation ${rect.id}`}
class="fill-yellow-400/20 stroke-yellow-400 stroke-2"
class:selected={isSelected} class:cursor-move={isSelected}
on:mousedown|stopPropagation={(e) => handleInteractionStart(e, rect, 'move', 'body')}
/>
{#if selectedRectangles.length === 1 && selectedRectangles[0].id === rect.id}
{#each handles as handle}
{@const handleX = handle.includes('w') ? rect.x : (handle.includes('e') ? rect.x + rect.width : rect.x + rect.width / 2)}
{@const handleY = handle.includes('n') ? rect.y : (handle.includes('s') ? rect.y + rect.height : rect.y + rect.height / 2)}
<rect
x={handleX - 4} y={handleY - 4} width="8" height="8"
role="button"
tabindex="0"
aria-label={`Resize handle ${handle}`}
class="fill-white stroke-blue-600 stroke-2 cursor-{handle}-resize"
on:mousedown|stopPropagation={(e) => handleInteractionStart(e, rect, 'resize', handle)}
/>
{/each}
{/if}
{/each}
{/if}
{/if}
</svg>
<canvas bind:this={opticalFlowCanvas} class="absolute top-0 left-0 w-full h-full pointer-events-none"></canvas>
</div>
{/if}
</div>
</div>
</div>

View File

@ -1,15 +1,10 @@
import { json, type RequestHandler } from '@sveltejs/kit';
<<<<<<< HEAD
import fs from 'fs/promises';
import path from 'path';
import { exec } from 'child_process';
import util from 'util';
=======
import * as fs from 'fs/promises';
import * as path from 'path';
import { exec } from 'child_process';
import * as util from 'util';
>>>>>>> 0bab142 (add login/logout)
import { createCanvas, loadImage } from 'canvas';
const execPromise = util.promisify(exec);

View File

@ -1,43 +1,3 @@
<<<<<<< HEAD
import { json, type RequestHandler } from '@sveltejs/kit';
import fs from 'fs/promises';
import path from 'path';
export const POST: RequestHandler = async ({ request }) => {
try {
const data = await request.formData();
const videoFile = data.get('video') as File | null;
if (!videoFile) {
return json({ success: false, error: '업로드된 파일이 없습니다.' }, { status: 400 });
}
// 1. 오늘 날짜를 YYYYMMDD 형식으로 생성
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
const dateFolder = `${year}${month}${day}`;
// 2. 최종 저장 경로 설정
const uploadDir = path.join('/workspace/image/movie', dateFolder);
const filePath = path.join(uploadDir, videoFile.name);
// 3. 날짜 폴더가 없으면 생성
await fs.mkdir(uploadDir, { recursive: true });
// 4. 파일 저장
const buffer = Buffer.from(await videoFile.arrayBuffer());
await fs.writeFile(filePath, buffer);
console.log(`파일 저장 완료: ${filePath}`);
return json({ success: true, filePath });
} catch (error) {
console.error('파일 업로드 처리 중 오류:', error);
return json({ success: false, error: '서버에서 파일 처리 중 오류가 발생했습니다.' }, { status: 500 });
}
=======
// src/routes/api/upload-cut/+server.ts
import { error, json } from '@sveltejs/kit';
@ -165,5 +125,4 @@ export const POST: import('@sveltejs/kit').RequestHandler = async ({ request })
} finally {
client.release(); // 클라이언트 연결 해제
}
>>>>>>> 0bab142 (add login/logout)
};

View File

@ -1,13 +1,10 @@
// src/routes/api/upload-original/+server.ts
import { json, type RequestHandler } from '@sveltejs/kit';
<<<<<<< HEAD
import fs from 'fs';
import path from 'path';
=======
import * as fs from 'fs';
import * as path from 'path';
>>>>>>> 0bab142 (add login/logout)
export const POST: RequestHandler = async ({ request }) => {
try {

View File

@ -1,32 +1,3 @@
<<<<<<< HEAD
import { type RequestHandler } from '@sveltejs/kit';
import fs from 'fs';
import path from 'path';
import { promisify } from 'util';
const stat = promisify(fs.stat);
export const GET: RequestHandler = async ({ setHeaders }) => {
const videoPath = path.join(process.cwd(), 'tmp', 'process.mp4');
try {
const stats = await stat(videoPath);
const videoSize = stats.size;
// 브라우저가 영상을 스트리밍할 수 있도록 헤더 설정
setHeaders({
'Content-Type': 'video/mp4',
'Content-Length': videoSize.toString(),
'Accept-Ranges': 'bytes'
});
const videoStream = fs.createReadStream(videoPath);
return new Response(videoStream);
} catch (error) {
return new Response('영상을 찾을 수 없습니다.', { status: 404 });
}
=======
// ✅ 수정된 /api/video-preview/+server.ts (SvelteKit 2.x / Kit 1.x 호환)
import { error } from '@sveltejs/kit';
import * as fs from 'fs';
@ -84,5 +55,4 @@ export const GET: import('@sveltejs/kit').RequestHandler = ({ request }) => {
const fileStream = fs.createReadStream(videoPath);
return new Response(Readable.toWeb(fileStream) as ReadableStream, { status: 200, headers: responseHeaders }); // 200 OK
}
>>>>>>> 0bab142 (add login/logout)
};

View File

@ -1,529 +1,5 @@
<script lang="ts">
<<<<<<< HEAD
// --- 상태 변수 선언 ---
let videoURL: string = '';
let videoElement: HTMLVideoElement;
let originalFileName: string = '';
let message: string = '동영상 파일을 선택하세요.';
let startTime: number = 0;
let endTime: number = 0;
let stopAtTime: number | null = null; // 미리보기 자동 정지를 위한 변수
// --- 작업 진행 상태 변수 ---
let isUploadingOriginal: boolean = false;
let uploadProgress: number = 0;
let isDetectingCrop: boolean = false;
let isCutting: boolean = false;
let isUploadingSegments: boolean = false;
// --- Crop 및 구간 정보 ---
let cropX: number = 0;
let cropY: number = 0;
let cropWidth: number = 0;
let cropHeight: number = 0;
interface TimeSegment {
id: number;
start: number;
end: number;
resultURL?: string;
isUploading?: boolean;
isUploaded?: boolean;
}
let segments: TimeSegment[] = [];
let nextSegmentId = 0;
// --- 반응형 변수 ---
$: allProcessed = segments.length > 0 && segments.every((s) => s.resultURL);
$: isLoading = isUploadingOriginal || isDetectingCrop || isCutting || isUploadingSegments;
$: detectedCropParams = `${cropWidth}:${cropHeight}:${cropX}:${cropY}`;
$: segmentsReadyToUpload = segments.filter((s) => s.resultURL && !s.isUploaded).length > 0;
// --- 핵심 기능 함수 ---
const uploadOriginalVideo = async (file: File): Promise<void> => {
return new Promise((resolve, reject) => {
isUploadingOriginal = true;
uploadProgress = 0;
message = '원본 동영상을 서버로 업로드하는 중...';
const formData = new FormData();
formData.append('video', file);
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
uploadProgress = (event.loaded / event.total) * 100;
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
isUploadingOriginal = false;
resolve();
} else {
isUploadingOriginal = false;
message = '원본 파일 업로드 중 오류가 발생했습니다.';
reject(new Error(xhr.statusText));
}
};
xhr.onerror = () => {
isUploadingOriginal = false;
message = '원본 파일 업로드 중 네트워크 오류가 발생했습니다.';
reject(new Error('네트워크 오류'));
};
xhr.open('POST', '/api/upload-original', true);
xhr.send(formData);
});
};
const handleExecuteCrop = async () => {
if (!(cropWidth > 0 && cropHeight > 0)) {
message = 'Crop 영역의 너비와 높이는 0보다 커야 합니다.';
return;
}
isCutting = true;
message = '서버에서 전체 동영상을 Crop하는 중...';
try {
const response = await fetch('/api/process-video', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'crop-full-video', cropParams: detectedCropParams })
});
if (!response.ok) {
const errorResult = await response.json();
throw new Error(errorResult.error || 'Crop 작업에 실패했습니다.');
}
const result = await response.json();
if (result.success) {
videoURL = `/api/video-preview?t=${Date.now()}`;
message = '✅ Crop 적용이 완료되었습니다. 변경된 미리보기를 확인하세요.';
} else {
throw new Error(result.message || '서버에서 Crop 작업을 완료하지 못했습니다.');
}
} catch (error) {
console.error('Crop 적용 중 오류:', error);
message = `Crop 적용 중 오류가 발생했습니다: ${
error instanceof Error ? error.message : '알 수 없는 오류'
}`;
} finally {
isCutting = false;
}
};
const handleFileSelect = async (e: Event) => {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
videoURL = URL.createObjectURL(file);
originalFileName = file.name;
segments = [];
cropX = 0;
cropY = 0;
cropWidth = 0;
cropHeight = 0;
try {
await uploadOriginalVideo(file);
message = '서버에서 최적의 Crop 영역을 계산하는 중...';
isDetectingCrop = true;
const response = await fetch('/api/process-video', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'detect-crop' })
});
if (!response.ok) throw new Error('Crop 영역 계산 요청 실패');
const result = await response.json();
if (result.success && result.cropParams) {
const [width, height, x, y] = result.cropParams.split(':').map(Number);
cropWidth = width;
cropHeight = height;
cropX = x;
cropY = y;
message = '자동 Crop 영역 계산 완료!';
} else {
message = '자동 Crop 영역을 찾지 못했습니다. 수동으로 잘라내기를 진행합니다.';
}
} catch (error) {
console.error('처리 중 오류:', error);
message = '오류가 발생했습니다. 콘솔을 확인해주세요.';
} finally {
isDetectingCrop = false;
}
}
};
const cutAllSegments = async () => {
if (segments.length === 0) {
message = '먼저 구간을 추가해주세요.';
return;
}
isCutting = true;
message = '서버에서 동영상 잘라내기 작업 중...';
try {
const response = await fetch('/api/process-video', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'cut-segments',
segments: segments,
cropParams: cropWidth > 0 && cropHeight > 0 ? detectedCropParams : ''
})
});
if (!response.ok) throw new Error('잘라내기 작업 실패');
const result = await response.json();
if (result.success && result.outputUrls) {
let updatedSegments = [...segments];
for (const output of result.outputUrls) {
updatedSegments = updatedSegments.map((s) =>
s.id === output.id ? { ...s, resultURL: output.url } : s
);
}
segments = updatedSegments;
message = '모든 구간 처리 완료!';
} else {
throw new Error(result.error || '알 수 없는 서버 오류');
}
} catch (error) {
console.error('잘라내기 중 오류:', error);
message = '잘라내기 중 오류가 발생했습니다.';
} finally {
isCutting = false;
}
};
const uploadSegment = async (segmentId: number) => {
const segment = segments.find((s) => s.id === segmentId);
if (!segment || !segment.resultURL || segment.isUploading) return;
segments = segments.map((s) => (s.id === segmentId ? { ...s, isUploading: true } : s));
message = `[${segment.id}번] 구간 업로드 중...`;
try {
const response = await fetch(segment.resultURL);
const videoBlob = await response.blob();
const formData = new FormData();
const baseName = originalFileName.split('.').slice(0, -1).join('.') || originalFileName;
const fileName = `${baseName}_cut-${segment.id}-${segment.start.toFixed(0)}s-${segment.end.toFixed(0)}s.mp4`;
formData.append('video', videoBlob, fileName);
formData.append('originalFileName', originalFileName);
const uploadResponse = await fetch('/api/upload-cut', {
method: 'POST',
body: formData
});
if (!uploadResponse.ok) {
throw new Error('서버 업로드 실패');
}
segments = segments.map((s) =>
s.id === segmentId ? { ...s, isUploading: false, isUploaded: true } : s
);
message = `[${segment.id}번] 구간 업로드 성공!`;
} catch (error) {
console.error(`Segment ${segmentId} 업로드 오류:`, error);
message = `[${segment.id}번] 구간 업로드 중 오류 발생.`;
segments = segments.map((s) => (s.id === segmentId ? { ...s, isUploading: false } : s));
}
};
const uploadAllSegments = async () => {
const segmentsToUpload = segments.filter((s) => s.resultURL && !s.isUploaded && !s.isUploading);
if (segmentsToUpload.length === 0) {
message = '업로드할 새로운 동영상이 없습니다.';
return;
}
isUploadingSegments = true;
message = `총 ${segmentsToUpload.length}개의 동영상 업로드를 시작합니다...`;
for (const segment of segmentsToUpload) {
await uploadSegment(segment.id);
}
isUploadingSegments = false;
message = '모든 구간에 대한 업로드 작업이 완료되었습니다.';
};
// --- UI 헬퍼 함수들 ---
const setStartTime = () => {
if (videoElement) startTime = videoElement.currentTime;
};
const setEndTime = () => {
if (videoElement) endTime = videoElement.currentTime;
};
const addSegment = () => {
if (startTime >= endTime) {
alert('시작 시간은 종료 시간보다 빨라야 합니다.');
return;
}
segments = [
...segments,
{ id: nextSegmentId++, start: startTime, end: endTime, isUploaded: false, isUploading: false }
];
};
const removeSegment = (id: number) => {
segments = segments.filter((segment) => segment.id !== id);
};
const downloadAllSegments = () => {
if (!allProcessed) return;
segments.forEach((segment) => {
if (segment.resultURL) {
const link = document.createElement('a');
link.href = segment.resultURL;
const baseName = originalFileName.split('.').slice(0, -1).join('.') || originalFileName;
const fileName = `${baseName}_cut-${segment.id}-${segment.start.toFixed(
0
)}s-${segment.end.toFixed(0)}s.mp4`;
link.download = fileName;
link.click();
}
});
};
// ### START: 추가된 함수 (구간 미리보기 및 자동 정지) ###
/** 구간을 클릭하면 해당 구간을 재생합니다. */
const previewSegment = (segment: TimeSegment) => {
if (!videoElement) return;
// 다른 구간 재생 중이면 정지 후 시작
videoElement.pause();
// 정지할 시간 설정
stopAtTime = segment.end;
// 시작 시간으로 이동
videoElement.currentTime = segment.start;
// 재생
videoElement.play();
};
/** 비디오 재생 시간을 감지하여 자동 정지시킵니다. */
const handleTimeUpdate = () => {
if (videoElement && stopAtTime !== null && videoElement.currentTime >= stopAtTime) {
videoElement.pause();
stopAtTime = null; // 자동 정지 완료 후 초기화
}
};
// ### END: 추가된 함수 ###
</script>
<main class="mx-auto max-w-screen-2xl p-4 sm:p-6 lg:p-8">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl">
🎬 내시경 BigData 동영상 편집기 (Server-Side)
</h1>
</div>
<p class="p-4 mb-6 bg-blue-50 text-blue-700 border border-blue-200 rounded-lg text-center">
{message}
</p>
<div class="flex flex-col lg:flex-row gap-6 items-start">
{#if videoURL}
<div class="w-full lg:w-1/2">
<section class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl p-6">
<h2 class="text-xl font-semibold text-gray-800 mb-4">동영상 미리보기</h2>
{#key videoURL}
<video
bind:this={videoElement}
class="w-full rounded-lg"
controls
autoplay
on:timeupdate={handleTimeUpdate}
>
<source src={videoURL} type="video/mp4" />
브라우저가 비디오 태그를 지원하지 않습니다.
</video>
{/key}
</section>
</div>
{/if}
<div class="w-full lg:w-1/4 flex flex-col gap-6">
<section class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl p-6">
<h2 class="text-xl font-semibold text-gray-800 mb-4">1. 동영상 파일 선택</h2>
<label
class="w-full text-center cursor-pointer rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50"
>
동영상 파일 불러오기
<input
type="file"
accept="video/*"
on:change={handleFileSelect}
class="sr-only"
disabled={isLoading}
/>
</label>
<div class="mt-4 space-y-2 text-sm">
{#if isUploadingOriginal}
<div>
<p class="font-medium text-gray-900 mb-1">서버로 업로드 중...</p>
<progress class="w-full" max="100" value={uploadProgress}></progress>
<p class="text-right font-mono text-gray-700">{uploadProgress.toFixed(0)}%</p>
</div>
{/if}
{#if isDetectingCrop}
<p class="text-gray-500 animate-pulse">자동 Crop 영역 계산 중...</p>
{:else if cropWidth > 0 && !isUploadingOriginal}
<div>
<p class="font-medium text-gray-900 mb-2">자동 Crop 영역 (수정 가능):</p>
<div class="grid grid-cols-2 gap-x-4 gap-y-2">
<div>
<label for="cropX" class="block text-xs font-medium text-gray-500">X 시작점</label>
<input
type="number"
id="cropX"
bind:value={cropX}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm p-1.5"
/>
</div>
<div>
<label for="cropY" class="block text-xs font-medium text-gray-500">Y 시작점</label>
<input
type="number"
id="cropY"
bind:value={cropY}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm p-1.5"
/>
</div>
<div>
<label for="cropWidth" class="block text-xs font-medium text-gray-500">너비</label>
<input
type="number"
id="cropWidth"
bind:value={cropWidth}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm p-1.5"
/>
</div>
<div>
<label for="cropHeight" class="block text-xs font-medium text-gray-500"
>높이</label
>
<input
type="number"
id="cropHeight"
bind:value={cropHeight}
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm p-1.5"
/>
</div>
</div>
</div>
<button
on:click={handleExecuteCrop}
disabled={isLoading || !(cropWidth > 0 && cropHeight > 0)}
class="mt-4 w-full text-center cursor-pointer rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Crop 적용하기
</button>
{/if}
</div>
</section>
{#if videoURL}
<section class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">2. 구간 추가</h3>
<div class="flex gap-2 mb-4">
<button
on:click={setStartTime}
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50"
>시작 시간 설정</button
>
<button
on:click={setEndTime}
class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50"
>종료 시간 설정</button
>
</div>
<div class="space-y-1 text-sm mb-4">
<p><strong class="font-medium text-gray-900">시작:</strong> {startTime.toFixed(2)}</p>
<p><strong class="font-medium text-gray-900">종료:</strong> {endTime.toFixed(2)}</p>
</div>
<button
on:click={addSegment}
class="w-full rounded-md border border-transparent bg-green-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-green-700"
>
구간 추가하기
</button>
</section>
{/if}
</div>
{#if videoURL}
<div class="w-full lg:w-1/4">
<section
class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl p-6 flex flex-col h-full"
>
<h2 class="text-xl font-semibold text-gray-800 mb-4">
3. 자르기 목록 ({segments.length}개)
</h2>
<div class="space-y-4 mb-6 flex-1 overflow-y-auto pr-2">
{#each segments as segment (segment.id)}
<div
class="p-3 border rounded-lg flex items-center justify-between"
class:bg-green-50={segment.isUploaded}
class:bg-blue-50={segment.isUploading}
>
<div class="flex-1 overflow-hidden">
<p
class="font-medium text-gray-800 truncate cursor-pointer hover:text-indigo-600"
on:click={() => previewSegment(segment)}
title="클릭하여 구간 미리보기"
>
{segment.start.toFixed(2)}초 ~ {segment.end.toFixed(2)}
</p>
{#if segment.resultURL}
<div class="text-sm mt-1 flex items-center gap-4">
<a
href={segment.resultURL}
download={`${originalFileName.split('.').slice(0, -1).join('.')}_cut-${
segment.id
}.mp4`}
class="text-indigo-600 hover:underline">다운로드</a
>
{#if segment.isUploading}
<span class="text-gray-500 animate-pulse">업로드 중...</span>
{:else if segment.isUploaded}
<span class="font-medium text-green-600">✓ 업로드 완료</span>
{:else}
<button
on:click={() => uploadSegment(segment.id)}
class="text-blue-600 hover:underline"
disabled={isLoading}>업로드</button
>
{/if}
</div>
{/if}
</div>
<button
on:click={() => removeSegment(segment.id)}
class="text-red-500 hover:text-red-700 font-bold px-2 ml-2 self-start">X</button
>
</div>
{:else}
<p class="text-gray-500 text-center py-4">추가된 구간이 없습니다.</p>
{/each}
</div>
<div class="mt-auto grid grid-cols-1 md:grid-cols-3 gap-2">
<button
on:click={cutAllSegments}
disabled={isLoading || segments.length === 0}
class="w-full rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 disabled:opacity-50"
>
✂️ 전체 잘라내기
</button>
<button
on:click={uploadAllSegments}
disabled={isLoading || !segmentsReadyToUpload}
class="w-full rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 disabled:opacity-50"
>
📤 전체 업로드
</button>
<button
on:click={downloadAllSegments}
disabled={isLoading || !allProcessed}
class="w-full rounded-md border border-transparent bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-700 disabled:opacity-50"
>
📦 전체 다운로드
</button>
</div>
</section>
</div>
{/if}
</div>
</main>
=======
// --- 상태 변수 선언 ---
let videoURL: string = '';
let videoElement: HTMLVideoElement;
@ -1186,4 +662,4 @@
{/if}
</div>
</main>
>>>>>>> 0bab142 (add login/logout)

View File

@ -1,12 +1,64 @@
{
"compilerOptions": {
"target": "es2017",
"module": "esnext",
"moduleResolution": "node",
"lib": ["es2017", "dom"],
"skipLibCheck": true,
"strict": true
// "types"
},
"include": ["src/**/*"]
"compilerOptions": {
"paths": {
"$components": [
"../src/lib/components"
],
"$components/*": [
"../src/lib/components/*"
],
"$utils": [
"../src/lib/utils"
],
"$utils/*": [
"../src/lib/utils/*"
],
"$lib": [
"../src/lib"
],
"$lib/*": [
"../src/lib/*"
],
"$app/types": [
"./types/index.d.ts"
]
},
"rootDirs": [
"..",
"./types"
],
"verbatimModuleSyntax": true,
"isolatedModules": true,
"lib": [
"esnext",
"DOM",
"DOM.Iterable"
],
"moduleResolution": "bundler",
"module": "esnext",
"noEmit": true,
"target": "esnext"
},
"include": [
"ambient.d.ts",
"non-ambient.d.ts",
"./types/**/$types.d.ts",
"../vite.config.js",
"../vite.config.ts",
"../src/**/*.js",
"../src/**/*.ts",
"../src/**/*.svelte",
"../tests/**/*.js",
"../tests/**/*.ts",
"../tests/**/*.svelte"
],
"exclude": [
"../node_modules/**",
"../src/service-worker.js",
"../src/service-worker/**/*.js",
"../src/service-worker.ts",
"../src/service-worker/**/*.ts",
"../src/service-worker.d.ts",
"../src/service-worker/**/*.d.ts"
]
}