Fix git merge error
This commit is contained in:
parent
868dde7d94
commit
7227e70383
84
+layout.svelte
Executable file
84
+layout.svelte
Executable 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
22
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
@ -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);
|
||||
|
||||
@ -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)
|
||||
};
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
};
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user