First Initialize source

This commit is contained in:
birdhead 2025-10-11 15:07:48 +00:00
parent bcdd46c452
commit 8e7e06c4f1
51 changed files with 11748 additions and 0 deletions

3
.gitignore vendored
View File

@ -3,6 +3,7 @@
build/
dist/
node_modules/
tmp/
# Emscripten/WebAssembly 관련
*.wasm
@ -22,6 +23,8 @@ pnpm-debug.log*
.env.local
.env.*.local
*.mp4
# IDE 및 에디터 설정 파일
.vscode/
.idea/

38
README.md Executable file
View File

@ -0,0 +1,38 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

6
e2e/demo.test.ts Executable file
View File

@ -0,0 +1,6 @@
import { expect, test } from '@playwright/test';
test('home page has expected h1', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toBeVisible();
});

40
eslint.config.js Executable file
View File

@ -0,0 +1,40 @@
import prettier from 'eslint-config-prettier';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import { fileURLToPath } from 'node:url';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default ts.config(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
}
);

5900
package-lock.json generated Executable file

File diff suppressed because it is too large Load Diff

57
package.json Executable file
View File

@ -0,0 +1,57 @@
{
"name": "bigdatapolyp",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev --host",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"test:unit": "vitest",
"test": "npm run test:unit -- --run && npm run test:e2e",
"test:e2e": "playwright test"
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@iconify/svelte": "^5.0.1",
"@playwright/test": "^1.49.1",
"@sveltejs/adapter-node": "^5.3.3",
"@sveltejs/kit": "^2.46.2",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
"@types/pg": "^8.15.5",
"@vitest/browser": "^3.2.3",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"playwright": "^1.53.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.20.0",
"vite": "^7.0.4",
"vite-plugin-devtools-json": "^0.2.0",
"vitest": "^3.2.3",
"vitest-browser-svelte": "^0.1.0"
},
"dependencies": {
"@ffmpeg/core": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"canvas": "^3.2.0",
"pg": "^8.16.3"
}
}

9
playwright.config.ts Executable file
View File

@ -0,0 +1,9 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
webServer: {
command: 'npm run build && npm run preview',
port: 4173
},
testDir: 'e2e'
});

3
src/app.css Executable file
View File

@ -0,0 +1,3 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';

33
src/app.d.ts vendored Executable file
View File

@ -0,0 +1,33 @@
// HINT: To tell the SvelteKit + TypeScript app how to handle type imports,
// create a global.d.ts file containing the following line:
/// <reference types="@sveltejs/kit" />
// =======================================================================
// src/app.d.ts (타입 정의를 위해 새로 생성)
// =======================================================================
declare namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Platform {}
}
// 애플리케이션 전반에서 사용할 데이터 구조 정의
interface DataNumberInfo {
Index: number;
DataNumber: number;
LocationFile: string;
}
interface Rectangle {
id: number; // 고유 ID 추가
x: number;
y: number;
width: number;
height: number;
}
declare module '@iconify/svelte' {
import type { SvelteComponentTyped } from 'svelte';
export class Icon extends SvelteComponentTyped<any> {}
}

12
src/app.html Executable file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
<script src="/opencv.js" async></script>
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

7
src/demo.spec.ts Executable file
View File

@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});

12
src/hooks.server.ts Executable file
View File

@ -0,0 +1,12 @@
// src/hooks.server.ts
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;
};

1
src/lib/assets/favicon.svg Executable file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

1
src/lib/index.ts Executable file
View File

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

17
src/lib/server/database.ts Executable file
View File

@ -0,0 +1,17 @@
// =======================================================================
// src/lib/server/database.ts (확장자 변경)
// =======================================================================
import pg from 'pg';
import { DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_DATABASE } from '$env/static/private';
const { Pool } = pg;
const pool = new Pool({
host: DB_HOST,
port: Number(DB_PORT),
user: DB_USER,
password: DB_PASSWORD,
database: DB_DATABASE,
});
export default pool;

2
src/lib/stores.ts Executable file
View File

@ -0,0 +1,2 @@
import { writable } from 'svelte/store';
export const progressStream = writable(0);

15
src/routes/+layout.svelte Executable file
View File

@ -0,0 +1,15 @@
<script lang="ts">
import '../app.css';
</script>
<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>

22
src/routes/+page.server.ts Executable file
View File

@ -0,0 +1,22 @@
// =======================================================================
// src/routes/+page.server.ts (확장자 변경 및 타입 추가)
// =======================================================================
import pool from '$lib/server/database';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
try {
const result = await pool.query<{ IndexMovie: number }>(
'SELECT DISTINCT "IndexMovie" FROM public."TumerDatasetRef" ORDER BY "IndexMovie" ASC'
);
return {
movies: result.rows.map(row => row.IndexMovie)
};
} catch (error) {
console.error("Failed to load initial movies", error);
return {
movies: [],
error: "데이터베이스에서 영화 목록을 가져오는 데 실패했습니다."
};
}
};

734
src/routes/+page.svelte Executable file
View File

@ -0,0 +1,734 @@
<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

@ -0,0 +1,25 @@
// =======================================================================
// src/routes/api/datanumbers/[movieIndex]/+server.ts (확장자 변경 및 타입 추가)
// =======================================================================
import { json } from '@sveltejs/kit';
import pool from '$lib/server/database';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params }) => {
const { movieIndex } = params;
try {
const result = await pool.query<DataNumberInfo>(
'SELECT "Index", "DataNumber", "LocationFile" FROM public."TumerDatasetRef" WHERE "IndexMovie" = $1 ORDER BY "DataNumber" ASC',
[movieIndex]
);
const dataNumbers = result.rows.map(row => ({
...row,
LocationFile: row.LocationFile.replace(/\\/g, '/')
}));
return json(dataNumbers);
} catch (err) {
console.error(`API Error fetching data numbers for ${movieIndex}:`, err);
return json({ error: 'Failed to fetch data numbers' }, { status: 500 });
}
};

View File

@ -0,0 +1,37 @@
import { error } from '@sveltejs/kit';
import fs from 'node:fs';
import path from 'node:path';
import type { RequestHandler } from './$types';
const IMAGE_BASE_DIRECTORY = '/workspace';
export const GET: RequestHandler = ({ params }) => {
const imagePath = params.path;
if (imagePath.includes('..')) {
throw error(403, 'Forbidden');
}
const fullPath = path.join(IMAGE_BASE_DIRECTORY, imagePath);
if (!fs.existsSync(fullPath)) {
throw error(404, 'Not Found');
}
try {
const fileBuffer = fs.readFileSync(fullPath);
const fileExtension = path.extname(fullPath).toLowerCase();
let mimeType = 'application/octet-stream';
switch (fileExtension) {
case '.png': mimeType = 'image/png'; break;
case '.jpg': case '.jpeg': mimeType = 'image/jpeg'; break;
case '.bmp': mimeType = 'image/bmp'; break;
case '.gif': mimeType = 'image/gif'; break;
}
return new Response(fileBuffer, {
headers: {
'Content-Type': mimeType,
'Content-Length': fileBuffer.length.toString(),
},
});
} catch (e) {
console.error(`Failed to read file: ${fullPath}`, e);
throw error(500, 'Internal Server Error');
}
};

View File

@ -0,0 +1,18 @@
// =======================================================================
// src/routes/api/movies/+server.ts (확장자 변경 및 타입 추가)
// =======================================================================
import { json } from '@sveltejs/kit';
import pool from '$lib/server/database';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
try {
const result = await pool.query<{ IndexMovie: number }>(
'SELECT DISTINCT "IndexMovie" FROM public."TumerDatasetRef" ORDER BY "IndexMovie" ASC'
);
return json(result.rows.map(row => row.IndexMovie));
} catch (err) {
console.error('API Error fetching movies:', err);
return json({ error: 'Failed to fetch movie list' }, { status: 500 });
}
};

View File

@ -0,0 +1,207 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import fs from 'fs/promises';
import path from 'path';
import { exec } from 'child_process';
import util from 'util';
import { createCanvas, loadImage } from 'canvas';
const execPromise = util.promisify(exec);
const detectCropAreaOnServer = async (inputPath: string): Promise<string> => {
// --- START: 수정된 부분 (다중 프레임 처리) ---
const tempFrameDir = path.join(process.cwd(), 'tmp', `frames-${Date.now()}`);
try {
// 1. 임시 프레임 폴더 생성
await fs.mkdir(tempFrameDir, { recursive: true });
// 2. ffprobe로 동영상 총 길이를 가져옵니다.
const ffprobeOutput = await execPromise(
`ffprobe -v error -show_entries stream=duration -of default=noprint_wrappers=1:nokey=1 ${inputPath}`
);
const duration = parseFloat(ffprobeOutput.stdout);
const startTimeForFrames = duration > 10 ? duration / 2 : 1; // 영상 중간 또는 1초 지점
// 3. 영상 중간부터 30개의 프레임을 png 파일들로 추출합니다.
await execPromise(
`ffmpeg -ss ${startTimeForFrames} -i ${inputPath} -vframes 30 -q:v 2 -y ${path.join(
tempFrameDir,
'frame_%03d.png'
)}`
);
const frameFiles = await fs.readdir(tempFrameDir);
const allBounds: { x1: number; y1: number; x2: number; y2: number }[] = [];
// 4. 추출된 각 프레임을 분석합니다.
for (const frameFile of frameFiles) {
const framePath = path.join(tempFrameDir, frameFile);
const img = await loadImage(framePath);
const canvas = createCanvas(img.width, img.height);
const ctx = canvas.getContext('2d');
const { width: canvasWidth, height: canvasHeight } = canvas;
ctx.drawImage(img, 0, 0, img.width, img.height);
const imageDataObj = ctx.getImageData(0, 0, canvasWidth, canvasHeight);
const imageData = imageDataObj.data;
const blackoutAreas = [
{ x: 0, y: 0, width: 154, height: 94 },
{ x: 1766, y: 0, width: 154, height: 94 },
{ x: 0, y: 995, width: 1920, height: 85 }
];
for (const area of blackoutAreas) {
for (let y = area.y; y < area.y + area.height; y++) {
if (y >= canvasHeight) continue;
for (let x = area.x; x < area.x + area.width; x++) {
if (x >= canvasWidth) continue;
const i = (y * canvasWidth + x) * 4;
imageData[i] = 0;
imageData[i + 1] = 0;
imageData[i + 2] = 0;
}
}
}
const verticalProfile = new Array(canvasHeight).fill(0);
const horizontalProfile = new Array(canvasWidth).fill(0);
for (let y = 0; y < canvasHeight; y++) {
for (let x = 0; x < canvasWidth; x++) {
const i = (y * canvasWidth + x) * 4;
const brightness = (imageData[i] + imageData[i + 1] + imageData[i + 2]) / 3;
verticalProfile[y] += brightness;
horizontalProfile[x] += brightness;
}
}
const totalVerticalBrightness = verticalProfile.reduce((sum, val) => sum + val, 0);
const avgVerticalBrightness = totalVerticalBrightness / canvasHeight;
const verticalThreshold = avgVerticalBrightness * 0.15;
const totalHorizontalBrightness = horizontalProfile.reduce((sum, val) => sum + val, 0);
const avgHorizontalBrightness = totalHorizontalBrightness / canvasWidth;
const horizontalThreshold = avgHorizontalBrightness * 0.15;
let y1 = 0, y2 = canvasHeight - 1, x1 = 0, x2 = canvasWidth - 1;
for (let i = 0; i < canvasHeight; i++) { if (verticalProfile[i] > verticalThreshold) { y1 = i; break; } }
for (let i = canvasHeight - 1; i >= 0; i--) { if (verticalProfile[i] > verticalThreshold) { y2 = i; break; } }
for (let i = 0; i < canvasWidth; i++) { if (horizontalProfile[i] > horizontalThreshold) { x1 = i; break; } }
for (let i = canvasWidth - 1; i >= 0; i--) { if (horizontalProfile[i] > horizontalThreshold) { x2 = i; break; } }
if (x2 > x1 && y2 > y1) {
allBounds.push({ x1, y1, x2, y2 });
}
}
if (allBounds.length === 0) return '';
// 5. 모든 프레임의 경계값들 중에서 가장 안쪽 영역(안전 영역)을 찾습니다.
const finalX1 = Math.max(...allBounds.map((b) => b.x1));
const finalY1 = Math.max(...allBounds.map((b) => b.y1));
const finalX2 = Math.min(...allBounds.map((b) => b.x2));
const finalY2 = Math.min(...allBounds.map((b) => b.y2));
const width = finalX2 - finalX1 + 1;
const height = finalY2 - finalY1 + 1;
if (width <= 0 || height <= 0) return '';
const cropParams = `${width}:${height}:${finalX1}:${finalY1}`;
console.log(`서버(다중 프레임)에서 찾은 최종 Crop 영역: ${cropParams}`);
return cropParams;
// --- END: 수정된 부분 ---
} catch (error) {
console.error('node-canvas 픽셀 처리 중 오류:', error);
throw new Error('Image processing with node-canvas failed');
} finally {
// 6. 임시 프레임 폴더와 그 안의 모든 파일을 삭제합니다.
await fs.rm(tempFrameDir, { recursive: true, force: true });
}
};
// --- API 요청 핸들러 (이하 동일) ---
export const POST: RequestHandler = async ({ request }) => {
const data = await request.json();
const { action, segments } = data;
const inputPath = path.join(process.cwd(), 'tmp', 'process.mp4');
try {
if (action === 'detect-crop') {
const cropParams = await detectCropAreaOnServer(inputPath);
return json({ success: true, cropParams });
}
if (action === 'crop-full-video') {
const { cropParams } = data;
if (!cropParams || !/^\d+:\d+:\d+:\d+$/.test(cropParams)) {
return json({ success: false, error: '잘못된 Crop 파라미터입니다.' }, { status: 400 });
}
const tempOutputPath = path.join(process.cwd(), 'tmp', `process_crop_${Date.now()}.mp4`);
try {
const command = `ffmpeg -i ${inputPath} -vf "crop=${cropParams}" -qp 10 -c:v h264_nvenc -c:a copy -y ${tempOutputPath}`;
console.log('실행할 FFmpeg Crop 명령어:', command);
await execPromise(command);
// ▼▼▼ 아래 로그가 터미널에 출력되는지 확인하세요. ▼▼▼
console.log('✅ FFmpeg Crop 작업 성공! 이제 파일을 교체합니다.');
// 원본 파일을 Crop된 파일로 교체
await fs.rename(tempOutputPath, inputPath);
console.log(`파일 교체 완료: ${tempOutputPath} -> ${inputPath}`);
// 교체된 새 비디오 파일을 클라이언트로 전송하여 미리보기 업데이트
/*
const videoBuffer = await fs.readFile(inputPath);
return new Response(videoBuffer, {
status: 200,
headers: { 'Content-Type': 'video/mp4' }
});
*/
return json({ success: true, message: 'Crop 작업이 성공적으로 완료되었습니다.' });
} catch (error) {
console.error('전체 비디오 Crop 처리 중 오류:', error);
await fs.rm(tempOutputPath, { force: true }).catch(() => {}); // 임시 파일 삭제
return json({ success: false, error: '비디오 Crop 처리 중 오류가 발생했습니다.' }, { status: 500 });
}
}
if (action === 'cut-segments') {
const { cropParams } = data;
const outputUrls: { id: number; url: string }[] = [];
const outputDir = path.join(process.cwd(), 'static', 'cuts');
await fs.mkdir(outputDir, { recursive: true });
for (const segment of segments) {
const outputFileName = `cut-${segment.id}-${Date.now()}.mp4`;
const outputPath = path.join(outputDir, outputFileName);
const duration = segment.end - segment.start;
const vfOption = cropParams ? `-vf "crop=${cropParams}"` : '';
const command = `ffmpeg -ss ${segment.start} -t ${duration} -i ${inputPath} ${vfOption} -c:v h264_nvenc -c:a copy -y ${outputPath}`;
console.log('실행할 FFmpeg 명령어:', command);
await execPromise(command);
outputUrls.push({ id: segment.id, url: `/cuts/${outputFileName}` });
}
return json({ success: true, outputUrls });
}
return json({ success: false, error: '알 수 없는 작업입니다.' }, { status: 400 });
} catch (error) {
console.error('서버 처리 중 오류:', error);
return json({ success: false, error: '서버 처리 중 오류가 발생했습니다.' }, { status: 500 });
}
};

View File

@ -0,0 +1,24 @@
import { progressStream } from '$lib/stores';
import { render } from 'svelte/server';
export function GET() {
const stream = new ReadableStream({
start(controller) {
const unsubscribe = progressStream.subscribe((value) => {
controller.enqueue(`data: ${JSON.stringify({ progress: value })}\n\n`);
});
return () => {
unsubscribe();
};
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive'
}
});
}

View File

@ -0,0 +1,66 @@
import { json } from '@sveltejs/kit';
import pool from '$lib/server/database';
import type { RequestHandler } from './$types';
interface Rectangle {
id: number;
x: number;
y: number;
width: number;
height: number;
}
// POST handler for bulk updates
export const POST: RequestHandler = async ({ request }) => {
try {
const rectangles = await request.json() as Rectangle[];
if (!rectangles || rectangles.length === 0) {
return json({ error: 'No rectangles data provided' }, { status: 400 });
}
// --- 효율적인 Bulk Update를 위한 SQL 쿼리 생성 ---
// 1. 업데이트할 ID 목록을 만듭니다. (예: [1, 2, 3])
const ids = rectangles.map(rect => rect.id);
// 2. 파라미터화된 쿼리를 위한 값 배열을 준비합니다.
// [x1, y1, x1+w1, y1+h1, x2, y2, x2+w2, y2+h2, ...]
const tumerPositionValues = rectangles.flatMap(rect => [rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]);
// 3. CASE 문을 동적으로 생성합니다.
// 예: WHEN "Index" = $5 THEN ARRAY[$1,$2,$3,$4] WHEN "Index" = $10 THEN ARRAY[$6,$7,$8,$9]
let caseStatement = '';
rectangles.forEach((rect, index) => {
const baseIndex = index * 4;
// 각 사각형의 TumerPosition 배열을 위한 파라미터 인덱스
const p1 = baseIndex + 1;
const p2 = baseIndex + 2;
const p3 = baseIndex + 3;
const p4 = baseIndex + 4;
// 각 사각형의 ID를 위한 파라미터 인덱스
const idParamIndex = tumerPositionValues.length + index + 1;
caseStatement += `WHEN "Index" = $${idParamIndex} THEN ARRAY[$${p1}::double precision, $${p2}::double precision, $${p3}::double precision, $${p4}::double precision] `;
});
// 4. 최종 SQL 쿼리를 조합합니다.
const query = `
UPDATE public."TumerDataset"
SET "TumerPosition" = CASE ${caseStatement} END
WHERE "Index" IN (${ids.map((_, i) => `$${tumerPositionValues.length + i + 1}`).join(', ')})
`;
// 5. 쿼리에 필요한 모든 값을 하나의 배열로 합칩니다.
const allValues = [...tumerPositionValues, ...ids];
// 6. 데이터베이스에 단 한 번의 쿼리를 실행합니다.
await pool.query(query, allValues);
return json({ success: true, message: `${rectangles.length} rectangles updated.` });
} catch (err) {
console.error('API Error during bulk update:', err);
return json({ error: 'Failed to bulk update rectangles' }, { status: 500 });
}
};

View File

@ -0,0 +1,34 @@
import { json } from '@sveltejs/kit';
import pool from '$lib/server/database';
import type { RequestHandler } from './$types';
// PUT handler
export const PUT: RequestHandler = async ({ params, request }) => {
const { id } = params;
const { x, y, width, height } = await request.json();
const tumerPosition = [x, y, x + width, y + height];
try {
await pool.query(
'UPDATE public."TumerDataset" SET "TumerPosition" = $1 WHERE "Index" = $2',
[tumerPosition, id]
);
return json({ success: true });
} catch (err) {
console.error(`API Error updating rectangle ${id}:`, err);
return json({ error: 'Failed to update rectangle' }, { status: 500 });
}
};
// DELETE handler
export const DELETE: RequestHandler = async ({ params }) => {
const { id } = params;
try {
await pool.query('DELETE FROM public."TumerDataset" WHERE "Index" = $1', [id]);
return json({ success: true });
} catch (err) {
console.error(`API Error deleting rectangle ${id}:`, err);
return json({ error: 'Failed to delete rectangle' }, { status: 500 });
}
};

View File

@ -0,0 +1,47 @@
import { json } from '@sveltejs/kit';
import pool from '$lib/server/database';
import type { RequestHandler } from './$types';
// GET handler (existing)
export const GET: RequestHandler = async ({ params }) => {
const { refIndex } = params;
try {
const result = await pool.query<{ Index: number; TumerPosition: number[] }>(
'SELECT "Index", "TumerPosition" FROM public."TumerDataset" WHERE "IndexRef" = $1',
[refIndex]
);
const rectangles: Rectangle[] = result.rows.map(row => {
const [x1, y1, x2, y2] = row.TumerPosition;
const x = Math.min(x1, x2);
const y = Math.min(y1, y2);
const width = Math.abs(x2 - x1);
const height = Math.abs(y2 - y1);
return { id: row.Index, x, y, width, height };
});
return json(rectangles);
} catch (err) {
console.error(`API Error fetching rectangles for ${refIndex}:`, err);
return json({ error: 'Failed to fetch rectangles' }, { status: 500 });
}
};
// POST handler (new)
export const POST: RequestHandler = async ({ params, request }) => {
const { refIndex } = params;
const { x, y, width, height } = await request.json();
const tumerPosition = [x, y, x + width, y + height];
try {
const result = await pool.query(
`INSERT INTO public."TumerDataset" ("IndexRef", "DataNumber", "TumerPosition", "LocationFile")
SELECT $1, "DataNumber", $2, "LocationFile" FROM public."TumerDatasetRef" WHERE "Index" = $1
RETURNING "Index"`,
[refIndex, tumerPosition]
);
return json({ id: result.rows[0].Index }, { status: 201 });
} catch (err) {
console.error('API Error creating rectangle:', err);
return json({ error: 'Failed to create rectangle' }, { status: 500 });
}
};

View File

@ -0,0 +1,39 @@
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 });
}
};

View File

@ -0,0 +1,37 @@
// src/routes/api/upload-original/+server.ts
import { json, type RequestHandler } from '@sveltejs/kit';
import fs from 'fs';
import path from 'path';
export const POST: RequestHandler = async ({ request }) => {
try {
const formData = await request.formData();
const file = formData.get('video') as File | null;
if (!file) {
return json({ success: false, error: '파일이 없습니다.' }, { status: 400 });
}
// 'tmp' 폴더가 없으면 생성합니다.
const tmpDir = path.join(process.cwd(), 'tmp');
if (!fs.existsSync(tmpDir)) {
fs.mkdirSync(tmpDir, { recursive: true });
}
// 파일 저장 경로 설정
const filePath = path.join(tmpDir, 'process.mp4');
// 파일 데이터를 버퍼로 변환하여 저장
const buffer = Buffer.from(await file.arrayBuffer());
fs.writeFileSync(filePath, buffer);
console.log(`파일이 성공적으로 저장되었습니다: ${filePath}`);
return json({ success: true, message: '파일 업로드 성공' }, { status: 200 });
} catch (error) {
console.error('파일 업로드 중 오류 발생:', error);
return json({ success: false, error: '서버 처리 중 오류가 발생했습니다.' }, { status: 500 });
}
};

130
src/routes/api/upload/+server.ts Executable file
View File

@ -0,0 +1,130 @@
// src/routes/api/upload/+server.ts
import { error, json } from '@sveltejs/kit';
import * as fs from 'fs/promises';
import * as path from 'path';
import { promisify } from 'util';
import { exec } from 'child_process';
import pool from '../../../lib/server/database';
const execAsync = promisify(exec);
export const POST: import('@sveltejs/kit').RequestHandler = async ({ request }) => {
// --- 1. 파일 업로드 및 저장 (기존 로직) ---
const formData = await request.formData();
const file = formData.get('video') as File | null;
const originalFileName = formData.get('fileName') as string | null;
if (!file || !originalFileName) {
throw error(400, '필수 데이터가 누락되었습니다.');
}
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}`;
const movieBaseDir = '/workspace/image/movie';
const movieUploadDir = path.join(movieBaseDir, dateFolder);
await fs.mkdir(movieUploadDir, { recursive: true });
const movieFilePath = path.join(movieUploadDir, originalFileName);
const buffer = Buffer.from(await file.arrayBuffer());
await fs.writeFile(movieFilePath, buffer);
console.log(`동영상 저장 완료: ${movieFilePath}`);
// 트랜잭션 시작
const client = await pool.connect();
try {
await client.query('BEGIN');
// --- 2. FFprobe로 TotalFrameCount 계산 및 TumerMovie에 INSERT ---
const ffprobeCommand = `ffprobe -v error -select_streams v:0 -count_frames -show_entries stream=nb_read_frames -of default=nokey=1:noprint_wrappers=1 "${movieFilePath}"`;
const { stdout } = await execAsync(ffprobeCommand);
const totalFrameCount = parseInt(stdout.trim(), 10);
if (isNaN(totalFrameCount)) {
throw new Error('프레임 수를 계산할 수 없습니다.');
}
const locationNetwork = '192.168.1.50'; // 예시 IP
const objectType = 0;
// ✅ TumerMovie에 INSERT 후, 생성된 Index 값을 반환받음
const movieInsertQuery = `
INSERT INTO public."TumerMovie" ("LocationNetwork", "LocationFile", "TotalFrameCount", "ObjectType")
VALUES ($1, $2, $3, $4)
RETURNING "Index"
`;
const movieInsertRes = await client.query(movieInsertQuery, [locationNetwork, originalFileName, totalFrameCount, objectType]);
const tumerMovieIndex = movieInsertRes.rows[0].Index;
console.log(`TumerMovie 저장 완료, Index: ${tumerMovieIndex}`);
// --- 3. FFmpeg로 모든 프레임 추출 ---
const videoFileNameWithoutExt = originalFileName.split('.').slice(0, -1).join('.');
const imageRefBaseDir = path.join('/workspace/image/image_ref', videoFileNameWithoutExt);
const tempFrameDir = path.join(imageRefBaseDir, 'temp_frames'); // 임시 추출 폴더
await fs.mkdir(tempFrameDir, { recursive: true });
const ffmpegCommand = `ffmpeg -i "${movieFilePath}" "${tempFrameDir}/%d.png"`;
await execAsync(ffmpegCommand);
console.log(`${totalFrameCount}개 프레임 추출 완료`);
// --- 4. 프레임 재구성 및 TumerDatasetRef 데이터 준비 ---
const extractedFrames = await fs.readdir(tempFrameDir);
const datasetRefValues: (string | number)[] = [];
const queryParams: string[] = [];
let paramIndex = 1;
for (const frameFile of extractedFrames) {
const frameNumber = parseInt(path.basename(frameFile, '.png'), 10);
const shardDirNumber = Math.floor((frameNumber - 1) / 1000);
const finalDir = path.join(imageRefBaseDir, String(shardDirNumber));
await fs.mkdir(finalDir, { recursive: true });
const oldPath = path.join(tempFrameDir, frameFile);
const newPath = path.join(finalDir, `${frameNumber}.png`);
await fs.rename(oldPath, newPath);
// DB에 저장될 경로 (예: image_ref/my_video/0/1.png)
//const dbLocationFile = path.join('image_ref', videoFileNameWithoutExt, String(shardDirNumber), `${frameNumber}.png`);
const dbLocationFile = path.join('image', 'image_ref', videoFileNameWithoutExt, String(shardDirNumber), `${frameNumber}.png`);
queryParams.push(`($${paramIndex++}, $${paramIndex++}, $${paramIndex++})`);
datasetRefValues.push(tumerMovieIndex, frameNumber, dbLocationFile);
}
await fs.rm(tempFrameDir, { recursive: true, force: true }); // 임시 폴더 삭제
console.log('프레임 파일 재구성 완료');
// --- 5. TumerDatasetRef에 대량 INSERT ---
if (datasetRefValues.length > 0) {
const datasetRefQuery = `
INSERT INTO public."TumerDatasetRef" ("IndexMovie", "DataNumber", "LocationFile")
VALUES ${queryParams.join(', ')}
`;
await client.query(datasetRefQuery, datasetRefValues);
console.log(`${extractedFrames.length}개 프레임 정보 DB 저장 완료`);
}
// 모든 작업 성공 시 트랜잭션 커밋
await client.query('COMMIT');
return json({
success: true,
message: '업로드, 프레임 추출 및 DB 저장이 모두 완료되었습니다.',
tumerMovieIndex,
totalFrameCount
});
} catch (err) {
// 오류 발생 시 트랜잭션 롤백
await client.query('ROLLBACK');
console.error('전체 작업 중 오류 발생:', err);
throw error(500, '서버 처리 중 오류가 발생했습니다.');
} finally {
// 클라이언트 연결 해제
client.release();
}
}

View File

@ -0,0 +1,76 @@
// src/routes/api/upload/+server.ts
import { error, json } from '@sveltejs/kit';
import fs from 'fs/promises';
import path from 'path';
import { promisify } from 'util';
import { exec } from 'child_process';
import pool from '$lib/server/database'; // ✅ DB 풀 임포트
const execAsync = promisify(exec);
export async function POST({ request }) {
try {
const formData = await request.formData();
const file = formData.get('video') as File | null;
const originalFileName = formData.get('fileName') as string | null;
if (!file || !originalFileName) {
throw error(400, '필수 데이터가 누락되었습니다.');
}
// --- 1. 파일 저장 ---
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}`;
const baseDir = '/workspace/image/movie';
const uploadDir = path.join(baseDir, dateFolder);
await fs.mkdir(uploadDir, { recursive: true });
const filePath = path.join(uploadDir, originalFileName);
const buffer = Buffer.from(await file.arrayBuffer());
await fs.writeFile(filePath, buffer);
console.log(`파일 저장 완료: ${filePath}`);
// --- ✅ 2. FFprobe로 TotalFrameCount 계산 ---
const ffprobeCommand = `ffprobe -v error -select_streams v:0 -count_frames -show_entries stream=nb_read_frames -of default=nokey=1:noprint_wrappers=1 "${filePath}"`;
const { stdout } = await execAsync(ffprobeCommand);
const totalFrameCount = parseInt(stdout.trim(), 10);
if (isNaN(totalFrameCount)) {
throw new Error('프레임 수를 계산할 수 없습니다.');
}
console.log(`프레임 수 계산 완료: ${totalFrameCount}`);
// --- ✅ 3. PostgreSQL에 데이터 INSERT ---
const locationNetwork = 'localhost'; // 예시 IP, 실제 환경에 맞게 수정 필요
const locationFile = originalFileName;
const objectType = 0;
const queryText = `
INSERT INTO public."TumerMovie" ("LocationNetwork", "LocationFile", "TotalFrameCount", "ObjectType")
VALUES ($1, $2, $3, $4)
`;
const queryValues = [locationNetwork, locationFile, totalFrameCount, objectType];
await pool.query(queryText, queryValues);
console.log(`데이터베이스 저장 완료: ${locationFile}`);
// --- 4. 성공 응답 반환 ---
return json({
success: true,
filePath,
db_inserted: true,
frameCount: totalFrameCount
});
} catch (err) {
console.error('파일 업로드 및 DB 처리 오류:', err);
throw error(500, '서버 처리 중 오류가 발생했습니다.');
}
}

View File

@ -0,0 +1,28 @@
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 });
}
};

13
src/routes/page.svelte.spec.ts Executable file
View File

@ -0,0 +1,13 @@
import { page } from '@vitest/browser/context';
import { describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte';
import Page from './+page.svelte';
describe('/+page.svelte', () => {
it('should render h1', async () => {
render(Page);
const heading = page.getByRole('heading', { level: 1 });
await expect.element(heading).toBeInTheDocument();
});
});

22
src/routes/t1/+page.server.ts Executable file
View File

@ -0,0 +1,22 @@
// =======================================================================
// src/routes/+page.server.ts (확장자 변경 및 타입 추가)
// =======================================================================
import pool from '$lib/server/database';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
try {
const result = await pool.query<{ IndexMovie: number }>(
'SELECT DISTINCT "IndexMovie" FROM public."TumerDatasetRef" ORDER BY "IndexMovie" ASC'
);
return {
movies: result.rows.map(row => row.IndexMovie)
};
} catch (error) {
console.error("Failed to load initial movies", error);
return {
movies: [],
error: "데이터베이스에서 영화 목록을 가져오는 데 실패했습니다."
};
}
};

557
src/routes/t1/+page.svelte Executable file
View File

@ -0,0 +1,557 @@
<script lang="ts">
import type { PageData } from './$types';
import { tick } from '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;
// --- 상호작용 상태 변수 ---
let selectedRectIds: number[] = [];
let imageElement: HTMLImageElement;
let svgElement: SVGSVGElement;
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 (selectedDataNumberInfo) {
imageHasError = false;
selectedRectIds = []; // 이미지 변경 시 선택 해제
// clipboard = null;
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' });
}
}
}
}
// --- 데이터 로딩 함수 ---
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;
isLoadingImage = true;
selectedRectIds = [];
try {
const preloadImagePromise = new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(true);
img.onerror = () => reject(new Error('Image preloading failed'));
img.src = `/api/images/${dataInfo.LocationFile}`;
});
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 newRects = await response.json();
rectangles = newRects;
selectedDataNumberInfo = dataInfo;
await tick();
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;
}
}
// --- 사각형 CRUD 함수 ---
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) {
const errorBody = await response.text();
throw new Error(`Failed to create rectangle on server: ${errorBody}`);
}
const newRectFromServer = await response.json();
const finalNewRect = { ...defaultRect, id: newRectFromServer.id };
rectangles = [...rectangles, finalNewRect];
selectedRectIds = [finalNewRect.id];
} catch (error) {
console.error(error);
alert((error as Error).message);
}
}
async function saveRectangle(rect: Rectangle) {
const url = `/api/rectangles/by-id/${rect.id}`;
try {
const response = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rect),
});
if (!response.ok) throw new Error('Failed to save rectangle');
} catch (error) {
console.error(error);
alert((error as Error).message);
}
}
async function deleteSelectedRect() {
if (selectedRectIds.length === 0) return;
//if (!confirm(`Are you sure you want to delete ${selectedRectIds.length} rectangle(s)?`)) return;
const idsToDelete = [...selectedRectIds]; // 나중에 사용하기 위해 ID 복사
// UI에서 먼저 삭제 (Optimistic Update)
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);
// 서버 삭제 실패 시 사용자에게 알림 (여기서는 콘솔에만 기록)
// 필요하다면 UI 복구 로직을 추가할 수 있습니다.
}
}
function copySelectedRect() {
if (selectedRectangles.length === 0) {
clipboard = null;
return;
}
// 선택된 사각형들의 정보에서 id를 제외하고 복사
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}`;
// 각 사각형 데이터에 대해 POST 요청을 보내고 모든 프로미스가 완료되길 기다림
const createdRectsFromServer = await Promise.all(
newRectsData.map(rectData =>
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rectData),
}).then(async response => {
if (!response.ok) {
throw new Error(`Failed to create rectangle on server`);
}
// 서버는 생성된 객체의 id를 포함한 정보를 반환한다고 가정
return response.json();
})
)
);
// 서버로부터 받은 id와 우리가 계산한 위치 정보를 합쳐 최종 객체 배열 생성
const finalNewRects = newRectsData.map((rectData, index) => ({
...rectData,
id: createdRectsFromServer[index].id,
}));
// UI 상태 업데이트
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) return { x: 0, y: 0 };
const svgRect = svgElement.getBoundingClientRect();
const scaleX = svgElement.clientWidth / imageElement.naturalWidth;
const scaleY = svgElement.clientHeight / imageElement.naturalHeight;
return {
x: (event.clientX - svgRect.left) / scaleX,
y: (event.clientY - svgRect.top) / scaleY,
};
}
function handleInteractionStart(event: MouseEvent, rect: Rectangle, type: 'move' | 'resize', handle: string) {
event.stopPropagation();
// --- 다중 선택 로직 시작 ---
const alreadySelected = selectedRectIds.includes(rect.id);
if (event.shiftKey) {
// Shift 키를 누른 경우: 선택 토글
if (alreadySelected) {
// 이미 선택된 사각형이면 선택 해제
selectedRectIds = selectedRectIds.filter(id => id !== rect.id);
} else {
// 선택되지 않은 사각형이면 선택 추가
selectedRectIds = [...selectedRectIds, rect.id];
}
} else if (!alreadySelected) {
// Shift 키 없이 아직 선택되지 않은 사각형을 클릭한 경우: 단일 선택
selectedRectIds = [rect.id];
}
// Shift 키 없이 이미 선택된 사각형(그룹의 일부일 수 있음)을 클릭한 경우,
// 선택 상태를 변경하지 않고 그대로 이동/리사이즈를 시작합니다.
// --- 다중 선택 로직 끝 ---
// 리사이즈는 단일 선택일 때만 활성화 (UI에서 이미 핸들이 보이지 않지만, 안전장치)
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 || !activeInteraction.initialRects || activeInteraction.initialRects.length === 0) 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; }
if (width < 10) width = 10;
if (height < 10) height = 10;
rectangles[i] = { ...rectangles[i], x, y, width, 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 && activeInteraction.initialRects) {
// 변경된 모든 사각형을 찾아서 저장 요청
const rectsToSave = rectangles.filter(r =>
activeInteraction!.initialRects!.some(ir => ir.id === r.id)
);
// 여러 API 요청을 동시에 보냄
Promise.all(rectsToSave.map(rect => saveRectangle(rect)));
}
activeInteraction = null;
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
}
function deselectRect(event: MouseEvent) {
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;
switch (event.key) {
case 'ArrowDown':
case 'ArrowRight':
case 'PageDown':
event.preventDefault();
if (currentIndex < dataNumbers.length - 1) nextIndex = currentIndex + 1;
break;
case 'ArrowUp':
case 'ArrowLeft':
case 'PageUp':
event.preventDefault();
if (currentIndex > 0) nextIndex = currentIndex - 1;
break;
}
if (nextIndex !== -1) selectDataNumber(dataNumbers[nextIndex]);
}
const handles = ['n', 's', 'e', 'w', 'nw', 'ne', 'sw', 'se'];
</script>
<svelte:window on:keydown={handleKeyDown} />
<div class="flex w-full h-full">
<!-- 1. 왼쪽: Movie Index 내비게이션 -->
<nav class="w-[15%] min-w-[200px] bg-white p-4 border-r border-gray-200 overflow-y-auto">
<div class="nav-section border border-gray-200 rounded-lg p-3">
<h2 class="text-sm font-bold text-gray-600 uppercase tracking-wider mb-2 px-1">Movie Index</h2>
<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"
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>
</nav>
<!-- 2. 중앙: Data Number 목록 -->
<div bind:this={dataNumberPanel} class="w-[15%] min-w-[200px] bg-gray-50 p-4 border-r border-gray-200 overflow-y-auto">
{#if selectedMovie}
<div class="nav-section border border-gray-200 rounded-lg p-3 bg-white">
<h2 class="text-sm font-bold text-gray-600 uppercase tracking-wider mb-2 px-1">Data Number</h2>
{#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"
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}
</div>
{:else}
<div class="text-center text-gray-400 text-sm mt-4">
<p>Select a Movie Index to see data numbers.</p>
</div>
{/if}
</div>
<!-- 3. 오른쪽: 이미지 뷰어 및 툴바 -->
<div class="w-[70%] flex flex-col bg-gray-200">
<!-- 툴바에 Copy/Paste 버튼 추가 -->
<div class="flex-shrink-0 p-2 bg-white border-b border-gray-300 shadow-sm flex items-center gap-4">
<div>
<button on:click={addNewRectangle} disabled={!selectedDataNumberInfo}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed">
Add Rectangle
</button>
<button on:click={copySelectedRect} disabled={selectedRectangles.length === 0}
class="ml-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 disabled:bg-gray-400 disabled:cursor-not-allowed">
Copy
</button>
<button on:click={pasteRect} disabled={!clipboard || clipboard.length === 0}
class="ml-2 px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-md hover:bg-purple-700 disabled:bg-gray-400 disabled:cursor-not-allowed">
Paste
</button>
<button on:click={deleteSelectedRect} disabled={selectedRectangles.length === 0}
class="ml-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed">
Delete Selected
</button>
</div>
{#if selectedRectangles.length === 1}
{@const selectedRectangle = selectedRectangles[0]}
<div class="text-xs text-gray-600 border-l-2 border-gray-300 pl-4 flex items-center gap-3">
<span class="font-mono"><strong class="font-bold text-gray-800">ID:</strong> {selectedRectangle.id}</span>
<span class="font-mono"><strong class="font-bold text-gray-800">X1:</strong> {Math.round(selectedRectangle.x)}</span>
<span class="font-mono"><strong class="font-bold text-gray-800">Y1:</strong> {Math.round(selectedRectangle.y)}</span>
<span class="font-mono"><strong class="font-bold text-gray-800">X2:</strong> {Math.round(selectedRectangle.x + selectedRectangle.width)}</span>
<span class="font-mono"><strong class="font-bold text-gray-800">Y2:</strong> {Math.round(selectedRectangle.y + selectedRectangle.height)}</span>
</div>
{/if}
</div>
<!-- 뷰어 -->
<div class="flex-grow p-6 overflow-auto flex justify-center items-center">
{#if !selectedDataNumberInfo}
<div class="text-center text-gray-500"><p>Select a data number to view the image.</p></div>
{:else}
<div class="relative max-w-full max-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 max-w-full max-h-[calc(100vh-180px)] object-contain" on:error={() => imageHasError = true} />
{/if}
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<svg bind:this={svgElement} class="absolute top-0 left-0 w-full h-full" on:mousedown|self={deselectRect} role="region" aria-label="Image annotation area">
{#if !imageHasError}
{#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}
</svg>
</div>
{/if}
</div>
</div>
</div>
<style>
.image-viewer-container {
user-select: none; /* 사용자의 텍스트 드래그 및 더블클릭 선택을 비활성화합니다. */
-webkit-user-select: none; /* Safari, Chrome 등 웹킷 브라우저 호환성 */
-moz-user-select: none; /* Firefox 호환성 */
-ms-user-select: none; /* IE, Edge 호환성 */
}
rect.selected {
stroke: #0ea5e9; /* 눈에 띄는 하늘색 (sky-500) */
stroke-width: 3px; /* 테두리 굵게 */
stroke-dasharray: 6 3; /* 점선 스타일로 변경 */
}
/* TailwindCSS JIT 컴파일러가 동적 클래스 이름을 인식하도록 커서 스타일을 미리 정의합니다. */
.cursor-n-resize { cursor: n-resize; }
.cursor-s-resize { cursor: s-resize; }
/* TailwindCSS JIT 컴파일러가 동적 클래스 이름을 인식하도록 커서 스타일을 미리 정의합니다. */
.cursor-n-resize { cursor: n-resize; }
.cursor-s-resize { cursor: s-resize; }
.cursor-e-resize { cursor: e-resize; }
.cursor-w-resize { cursor: w-resize; }
.cursor-nw-resize { cursor: nw-resize; }
.cursor-ne-resize { cursor: ne-resize; }
.cursor-sw-resize { cursor: sw-resize; }
.cursor-se-resize { cursor: se-resize; }
</style>

22
src/routes/t2/+page.server.ts Executable file
View File

@ -0,0 +1,22 @@
// =======================================================================
// src/routes/+page.server.ts (확장자 변경 및 타입 추가)
// =======================================================================
import pool from '$lib/server/database';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
try {
const result = await pool.query<{ IndexMovie: number }>(
'SELECT DISTINCT "IndexMovie" FROM public."TumerDatasetRef" ORDER BY "IndexMovie" ASC'
);
return {
movies: result.rows.map(row => row.IndexMovie)
};
} catch (error) {
console.error("Failed to load initial movies", error);
return {
movies: [],
error: "데이터베이스에서 영화 목록을 가져오는 데 실패했습니다."
};
}
};

584
src/routes/t2/+page.svelte Executable file
View File

@ -0,0 +1,584 @@
<script lang="ts">
import type { PageData } from './$types';
import { tick, onMount } from '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;
// --- OpenCV 상태 변수 ---
let isCvReady = false;
let opticalFlowCanvas: HTMLCanvasElement;
let showOpticalFlow = true;
// --- 상호작용 상태 변수 ---
let selectedRectIds: number[] = [];
let imageElement: HTMLImageElement;
let svgElement: SVGSVGElement;
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 (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' });
}
}
}
}
onMount(() => {
const initialize = () => {
if (cv && cv.Mat) {
console.log("✅ OpenCV was already initialized.");
isCvReady = true;
return;
}
if (cv) {
console.log("cv object found, setting onRuntimeInitialized callback.");
cv.onRuntimeInitialized = () => {
console.log("✅✅✅ OpenCV runtime initialized successfully!");
isCvReady = true;
};
}
};
const intervalId = setInterval(() => {
if (typeof cv !== 'undefined') {
clearInterval(intervalId);
initialize();
} else {
console.log("Waiting for cv object to appear...");
}
}, 50);
});
async function calculateAndApplyOpticalFlow(prevImgSrc: string, currentImgSrc: string, prevRects: Rectangle[]): Promise<Rectangle[]> {
if (!isCvReady) return rectangles;
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[] = [];
const flowLines: { p1: any; p2: 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 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, 100, 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 avg_dx = 0, 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]);
flowLines.push({ p1, p2 });
avg_dx += (p2.x - p1.x);
avg_dy += (p2.y - p1.y);
tracked_count++;
}
}
if (tracked_count > 0) { avg_dx /= tracked_count; avg_dy /= tracked_count; }
stabilizedRects.push({ ...prevRect, x: prevRect.x + avg_dx, y: prevRect.y + avg_dy });
points1.delete(); points2.delete(); status.delete(); err.delete(); roiGray1.delete();
}
const ctx = opticalFlowCanvas.getContext('2d');
if (ctx) {
const canvasWidth = opticalFlowCanvas.clientWidth;
const canvasHeight = opticalFlowCanvas.clientHeight;
opticalFlowCanvas.width = canvasWidth;
opticalFlowCanvas.height = canvasHeight;
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
const imgNaturalWidth = currentImg.naturalWidth;
const imgNaturalHeight = currentImg.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 = 2;
ctx.strokeStyle = 'rgba(0, 255, 0, 0.8)';
flowLines.forEach(line => {
ctx.beginPath();
ctx.moveTo((line.p1.x * scale) + offsetX, (line.p1.y * scale) + offsetY);
ctx.lineTo((line.p2.x * scale) + offsetX, (line.p2.y * scale) + offsetY);
ctx.stroke();
});
}
src1.delete(); src2.delete(); gray1.delete(); gray2.delete();
return stabilizedRects;
} catch (error) {
console.error("Optical Flow 이미지 로딩 또는 처리 중 에러:", error);
return rectangles;
}
}
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;
isLoadingImage = true;
selectedRectIds = [];
if (opticalFlowCanvas) {
const ctx = opticalFlowCanvas.getContext('2d');
ctx?.clearRect(0, 0, opticalFlowCanvas.width, opticalFlowCanvas.height);
}
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();
selectedDataNumberInfo = dataInfo;
await tick();
const conditions = {
"1. 이전 이미지 경로 존재 여부": !!prevImgSrc,
"2. 이전 사각형 존재 여부": prevRects.length > 0,
"3. OpenCV 준비 여부": isCvReady
};
if (conditions["1. 이전 이미지 경로 존재 여부"] && conditions["2. 이전 사각형 존재 여부"] && conditions["3. OpenCV 준비 여부"]) {
const stabilizedRects = await calculateAndApplyOpticalFlow(prevImgSrc!, newImgSrc, prevRects);
rectangles = stabilizedRects;
} else {
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;
}
}
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) {
const errorBody = await response.text();
throw new Error(`Failed to create rectangle on server: ${errorBody}`);
}
const newRectFromServer = await response.json();
const finalNewRect = { ...defaultRect, id: newRectFromServer.id };
rectangles = [...rectangles, finalNewRect];
selectedRectIds = [finalNewRect.id];
} catch (error) {
console.error(error);
alert((error as Error).message);
}
}
async function saveRectangle(rect: Rectangle) {
const url = `/api/rectangles/by-id/${rect.id}`;
try {
const response = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rect),
});
if (!response.ok) throw new Error('Failed to save rectangle');
} 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(async response => {
if (!response.ok) {
throw new Error(`Failed to create rectangle on server`);
}
return response.json();
})
)
);
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 };
// SVG 요소의 화면상 크기와 위치를 가져옵니다.
const svgRect = svgElement.getBoundingClientRect();
// --- getMousePosition 수정: object-fit: contain 비율 계산 추가 ---
// imageElement는 실제 렌더링된 이미지 DOM 요소입니다.
const { naturalWidth, naturalHeight, clientWidth, clientHeight } = imageElement;
// object-contain으로 인해 계산된 스케일과 여백을 구합니다.
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;
// 마우스 좌표(event.clientX, Y)를 이미지의 원본 좌표계로 변환합니다.
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) {
if (alreadySelected) {
selectedRectIds = selectedRectIds.filter(id => id !== rect.id);
} else {
selectedRectIds = [...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 || !activeInteraction.initialRects || activeInteraction.initialRects.length === 0) 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; }
if (width < 10) width = 10;
if (height < 10) height = 10;
rectangles[i] = { ...rectangles[i], x, y, width, 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 && activeInteraction.initialRects) {
const rectsToSave = rectangles.filter(r =>
activeInteraction!.initialRects!.some(ir => ir.id === r.id)
);
// 자동 저장은 비활성화
// Promise.all(rectsToSave.map(rect => saveRectangle(rect)));
}
activeInteraction = null;
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
}
function deselectRect(event: MouseEvent) {
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;
switch (event.key) {
case 'ArrowDown':
case 'ArrowRight':
case 'PageDown':
event.preventDefault();
if (currentIndex < dataNumbers.length - 1) nextIndex = currentIndex + 1;
break;
case 'ArrowUp':
case 'ArrowLeft':
case 'PageUp':
event.preventDefault();
if (currentIndex > 0) nextIndex = currentIndex - 1;
break;
}
if (nextIndex !== -1) selectDataNumber(dataNumbers[nextIndex]);
}
const handles = ['n', 's', 'e', 'w', 'nw', 'ne', 'sw', 'se'];
</script>
<svelte:window on:keydown={handleKeyDown} />
<div class="flex w-full h-full">
<nav class="w-[15%] min-w-[200px] bg-white p-4 border-r border-gray-200 overflow-y-auto">
</nav>
<div bind:this={dataNumberPanel} class="w-[15%] min-w-[200px] bg-gray-50 p-4 border-r border-gray-200 overflow-y-auto">
</div>
<div class="w-[70%] flex flex-col bg-gray-200">
<div class="flex-shrink-0 p-2 bg-white border-b border-gray-300 shadow-sm flex items-center gap-4">
</div>
<div class="flex-grow 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}
<svg bind:this={svgElement} class="absolute top-0 left-0 w-full h-full" on:mousedown|self={deselectRect} role="region" aria-label="Image annotation area">
{#if !imageHasError}
{#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}
</svg>
<canvas bind:this={opticalFlowCanvas} class="absolute top-0 left-0 w-full h-full pointer-events-none"></canvas>
</div>
{/if}
</div>
</div>
</div>
<style>
.image-viewer-container {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
rect.selected {
stroke: #0ea5e9;
stroke-width: 3px;
stroke-dasharray: 6 3;
}
.cursor-n-resize { cursor: n-resize; }
.cursor-s-resize { cursor: s-resize; }
.cursor-e-resize { cursor: e-resize; }
.cursor-w-resize { cursor: w-resize; }
.cursor-nw-resize { cursor: nw-resize; }
.cursor-ne-resize { cursor: ne-resize; }
.cursor-sw-resize { cursor: sw-resize; }
.cursor-se-resize { cursor: se-resize; }
</style>

22
src/routes/t3/+page.server.ts Executable file
View File

@ -0,0 +1,22 @@
// =======================================================================
// src/routes/+page.server.ts (확장자 변경 및 타입 추가)
// =======================================================================
import pool from '$lib/server/database';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
try {
const result = await pool.query<{ IndexMovie: number }>(
'SELECT DISTINCT "IndexMovie" FROM public."TumerDatasetRef" ORDER BY "IndexMovie" ASC'
);
return {
movies: result.rows.map(row => row.IndexMovie)
};
} catch (error) {
console.error("Failed to load initial movies", error);
return {
movies: [],
error: "데이터베이스에서 영화 목록을 가져오는 데 실패했습니다."
};
}
};

730
src/routes/t3/+page.svelte Executable file
View File

@ -0,0 +1,730 @@
<script lang="ts">
import type { PageData } from './$types';
import { tick, onMount } from '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;
let rectanglesOpticalFlow: Rectangle[] = [];
// --- OpenCV 상태 변수 ---
let isCvReady = false;
let opticalFlowCanvas: HTMLCanvasElement;
let showOpticalFlow = true;
// --- 상호작용 상태 변수 ---
let selectedRectIds: number[] = [];
let imageElement: HTMLImageElement;
let svgElement: SVGSVGElement;
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 viewBox = '0 0 100 100'; // 기본 viewBox 값
let selectedRectangles: Rectangle[] = [];
$: if (imageElement) {
// 이미지가 로드되기를 기다립니다.
const updateViewBox = () => {
if (imageElement.naturalWidth > 0 && imageElement.naturalHeight > 0) {
viewBox = `0 0 ${imageElement.naturalWidth} ${imageElement.naturalHeight}`;
console.log('SVG viewBox updated:', viewBox);
}
};
// 이미지가 이미 로드된 경우 즉시 업데이트, 아니면 onload 이벤트를 기다립니다.
if (imageElement.complete) {
updateViewBox();
} else {
imageElement.onload = updateViewBox;
}
}
$: selectedRectangles = rectangles.filter(r => selectedRectIds.includes(r.id));
$: 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' });
}
}
}
}
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);
});
async function calculateAndApplyOpticalFlow(prevImgSrc: string, currentImgSrc: string, prevRects: Rectangle[]): Promise<Rectangle[]> {
if (!isCvReady) return rectangles;
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[] = [];
const flowLines: { p1: any; p2: 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 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, 100, 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 avg_dx = 0, 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]);
flowLines.push({ p1, p2 });
avg_dx += (p2.x - p1.x);
avg_dy += (p2.y - p1.y);
tracked_count++;
}
}
if (tracked_count > 0) { avg_dx /= tracked_count; avg_dy /= tracked_count; }
stabilizedRects.push({ ...prevRect, x: prevRect.x + avg_dx, y: prevRect.y + avg_dy });
points1.delete(); points2.delete(); status.delete(); err.delete(); roiGray1.delete();
}
const ctx = opticalFlowCanvas.getContext('2d');
if (ctx) {
const canvasWidth = opticalFlowCanvas.clientWidth;
const canvasHeight = opticalFlowCanvas.clientHeight;
opticalFlowCanvas.width = canvasWidth;
opticalFlowCanvas.height = canvasHeight;
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
// 스케일 및 여백 계산 (기존과 동일)
const imgNaturalWidth = currentImg.naturalWidth;
const imgNaturalHeight = currentImg.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;
// 1. 옵티컬 플로우 화살표 그리기 (기존과 동일)
ctx.lineWidth = 2;
ctx.strokeStyle = 'rgba(255, 255, 0, 0.8)'; // 화살표는 노란색으로 변경하여 가독성 확보
ctx.fillStyle = 'rgba(255, 255, 0, 0.8)';
const headlen = 5;
flowLines.forEach(line => {
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) < 1 && Math.abs(startY - endY) < 1) 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();
});
// 2. 안정화된 위치에 초록색 사각형 오버레이 그리기
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.fillStyle = 'rgba(0, 255, 0, 0.25)'; // 반투명 녹색
ctx.strokeStyle = 'rgba(0, 255, 0, 0.9)'; // 진한 녹색 테두리
ctx.lineWidth = 2;
ctx.fillRect(rectX, rectY, rectWidth, rectHeight);
ctx.strokeRect(rectX, rectY, rectWidth, rectHeight);
});
}
src1.delete(); src2.delete(); gray1.delete(); gray2.delete();
return stabilizedRects;
} catch (error) {
console.error("Optical Flow 이미지 로딩 또는 처리 중 에러:", error);
return rectangles;
}
}
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;
if(rectanglesOpticalFlow.length===0) {
rectanglesOpticalFlow = prevRects;
}
isLoadingImage = true;
selectedRectIds = [];
if (opticalFlowCanvas) {
const ctx = opticalFlowCanvas.getContext('2d');
ctx?.clearRect(0, 0, opticalFlowCanvas.width, opticalFlowCanvas.height);
}
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();
selectedDataNumberInfo = dataInfo;
await tick();
const conditions = {
"1. 이전 이미지 경로 존재 여부": !!prevImgSrc,
"2. 이전 사각형 존재 여부": prevRects.length > 0,
"3. OpenCV 준비 여부": isCvReady
};
if (conditions["1. 이전 이미지 경로 존재 여부"] && conditions["2. 이전 사각형 존재 여부"] && conditions["3. OpenCV 준비 여부"]) {
const stabilizedRects = await calculateAndApplyOpticalFlow(prevImgSrc!, newImgSrc, prevRects);
//const stabilizedRects = await calculateAndApplyOpticalFlow(prevImgSrc!, newImgSrc, rectanglesOpticalFlow);
//rectangles = stabilizedRects;
rectangles = newRectsFromAPI;
} else {
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;
}
}
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) {
const errorBody = await response.text();
throw new Error(`Failed to create rectangle on server: ${errorBody}`);
}
const newRectFromServer = await response.json();
const finalNewRect = { ...defaultRect, id: newRectFromServer.id };
rectangles = [...rectangles, finalNewRect];
selectedRectIds = [finalNewRect.id];
} catch (error) {
console.error(error);
alert((error as Error).message);
}
}
async function saveRectangle(rect: Rectangle) {
const url = `/api/rectangles/by-id/${rect.id}`;
try {
const response = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rect),
});
if (!response.ok) throw new Error('Failed to save rectangle');
} 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(async response => {
if (!response.ok) {
throw new Error(`Failed to create rectangle on server`);
}
return response.json();
})
)
);
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 };
// SVG 요소의 화면상 크기와 위치를 가져옵니다.
const svgRect = svgElement.getBoundingClientRect();
// imageElement는 실제 렌더링된 이미지 DOM 요소입니다.
const { naturalWidth, naturalHeight, clientWidth, clientHeight } = imageElement;
// object-contain으로 인해 계산된 스케일과 여백을 구합니다.
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;
// 마우스 좌표(event.clientX, Y)를 이미지의 원본 좌표계로 변환합니다.
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) {
if (alreadySelected) {
selectedRectIds = selectedRectIds.filter(id => id !== rect.id);
} else {
selectedRectIds = [...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 || !activeInteraction.initialRects || activeInteraction.initialRects.length === 0) 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; }
if (width < 10) width = 10;
if (height < 10) height = 10;
rectangles[i] = { ...rectangles[i], x, y, width, 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 && activeInteraction.initialRects) {
const rectsToSave = rectangles.filter(r =>
activeInteraction!.initialRects!.some(ir => ir.id === r.id)
);
}
activeInteraction = null;
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
}
function deselectRect(event: MouseEvent) {
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;
switch (event.key) {
case 'ArrowDown':
case 'ArrowRight':
case 'PageDown':
event.preventDefault();
if (currentIndex < dataNumbers.length - 1) nextIndex = currentIndex + 1;
break;
case 'ArrowUp':
case 'ArrowLeft':
case 'PageUp':
event.preventDefault();
if (currentIndex > 0) nextIndex = currentIndex - 1;
break;
}
if (nextIndex !== -1) selectDataNumber(dataNumbers[nextIndex]);
}
const handles = ['n', 's', 'e', 'w', 'nw', 'ne', 'sw', 'se'];
</script>
<svelte:window on:keydown={handleKeyDown} />
<div class="flex w-full h-full">
<nav class="w-[15%] min-w-[200px] bg-white p-4 border-r border-gray-200 overflow-y-auto">
<div class="nav-section border border-gray-200 rounded-lg p-3">
<h2 class="text-sm font-bold text-gray-600 uppercase tracking-wider mb-2 px-1">Movie Index</h2>
<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"
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>
</nav>
<div bind:this={dataNumberPanel} class="w-[15%] min-w-[200px] bg-gray-50 p-4 border-r border-gray-200 overflow-y-auto">
{#if selectedMovie}
<div class="nav-section border border-gray-200 rounded-lg p-3 bg-white">
<h2 class="text-sm font-bold text-gray-600 uppercase tracking-wider mb-2 px-1">Data Number</h2>
{#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"
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}
</div>
{:else}
<div class="text-center text-gray-400 text-sm mt-4">
<p>Select a Movie Index to see data numbers.</p>
</div>
{/if}
</div>
<div class="w-[70%] flex flex-col bg-gray-200">
<div class="flex-shrink-0 p-2 bg-white border-b border-gray-300 shadow-sm flex items-center gap-4">
<div>
<button on:click={addNewRectangle} disabled={!selectedDataNumberInfo}
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed">
Add Rectangle
</button>
<button on:click={copySelectedRect} disabled={selectedRectangles.length === 0}
class="ml-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 disabled:bg-gray-400 disabled:cursor-not-allowed">
Copy
</button>
<button on:click={pasteRect} disabled={!clipboard || clipboard.length === 0}
class="ml-2 px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-md hover:bg-purple-700 disabled:bg-gray-400 disabled:cursor-not-allowed">
Paste
</button>
<button on:click={deleteSelectedRect} disabled={selectedRectangles.length === 0}
class="ml-2 px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed">
Delete Selected
</button>
</div>
{#if selectedRectangles.length === 1}
{@const selectedRectangle = selectedRectangles[0]}
<div class="text-xs text-gray-600 border-l-2 border-gray-300 pl-4 flex items-center gap-3">
<span class="font-mono"><strong class="font-bold text-gray-800">ID:</strong> {selectedRectangle.id}</span>
<span class="font-mono"><strong class="font-bold text-gray-800">X1:</strong> {Math.round(selectedRectangle.x)}</span>
<span class="font-mono"><strong class="font-bold text-gray-800">Y1:</strong> {Math.round(selectedRectangle.y)}</span>
<span class="font-mono"><strong class="font-bold text-gray-800">X2:</strong> {Math.round(selectedRectangle.x + selectedRectangle.width)}</span>
<span class="font-mono"><strong class="font-bold text-gray-800">Y2:</strong> {Math.round(selectedRectangle.y + selectedRectangle.height)}</span>
</div>
{/if}
</div>
<div class="flex-grow 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}
<svg
bind:this={svgElement}
viewBox={viewBox}
class="absolute top-0 left-0 w-full h-full"
on:mousedown|self={deselectRect}
role="region"
aria-label="Image annotation area">
{#if !imageHasError}
{#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}
</svg>
<canvas bind:this={opticalFlowCanvas} class="absolute top-0 left-0 w-full h-full pointer-events-none"></canvas>
</div>
{/if}
</div>
</div>
</div>
<style>
.image-viewer-container {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
rect.selected {
stroke: #0ea5e9;
stroke-width: 3px;
stroke-dasharray: 6 3;
}
.cursor-n-resize { cursor: n-resize; }
.cursor-s-resize { cursor: s-resize; }
.cursor-e-resize { cursor: e-resize; }
.cursor-w-resize { cursor: w-resize; }
.cursor-nw-resize { cursor: nw-resize; }
.cursor-ne-resize { cursor: ne-resize; }
.cursor-sw-resize { cursor: sw-resize; }
.cursor-se-resize { cursor: se-resize; }
</style>

22
src/routes/t7/+page.server.ts Executable file
View File

@ -0,0 +1,22 @@
// =======================================================================
// src/routes/+page.server.ts (확장자 변경 및 타입 추가)
// =======================================================================
import pool from '$lib/server/database';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
try {
const result = await pool.query<{ IndexMovie: number }>(
'SELECT DISTINCT "IndexMovie" FROM public."TumerDatasetRef" ORDER BY "IndexMovie" ASC'
);
return {
movies: result.rows.map(row => row.IndexMovie)
};
} catch (error) {
console.error("Failed to load initial movies", error);
return {
movies: [],
error: "데이터베이스에서 영화 목록을 가져오는 데 실패했습니다."
};
}
};

793
src/routes/t7/+page.svelte Executable file
View File

@ -0,0 +1,793 @@
<script lang="ts">
import type { PageData } from './$types';
import { tick, onMount } from '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[] = []; // DB에서 불러온 원본 사각형
let stabilizedRectsResult: { // OF 계산 결과를 저장 (사각형 + flow lines)
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' });
}
}
}
}
// 반응형으로 캔버스 다시 그리기
$: if (opticalFlowCanvas) {
showOpticalFlowVectors, stabilizedRectsResult, redrawCanvas();
}
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);
});
// 계산만 담당하는 함수
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) {
const { stabilizedRects, flowLines } = stabilizedRectsResult;
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 = 2;
ctx.strokeStyle = 'rgba(255, 255, 0, 0.8)';
ctx.fillStyle = 'rgba(255, 255, 0, 0.8)';
const headlen = 5;
flowLines.forEach(line => {
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) < 1 && Math.abs(startY - endY) < 1) 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();
const conditions = {
"1. 이전 이미지 경로 존재 여부": !!prevImgSrc,
"2. 이전 사각형 존재 여부": prevRects.length > 0,
"3. OpenCV 준비 여부": isCvReady
};
if (conditions["1. 이전 이미지 경로 존재 여부"] && conditions["2. 이전 사각형 존재 여부"] && conditions["3. OpenCV 준비 여부"]) {
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;
}
}
// --- Apply/Undo 함수 ---
function applyOpticalFlowResult() {
// OF 결과나 DB 원본 데이터가 없으면 실행하지 않음
if (!stabilizedRectsResult || !originalRectsFromDB) return;
const stabilizedRects = stabilizedRectsResult.stabilizedRects;
// 이전 프레임과 현재 프레임의 사각형 개수가 다르면 로직을 중단 (예외 처리)
if (stabilizedRects.length !== originalRectsFromDB.length) {
console.error("사각형 개수가 일치하지 않아 Apply Flow를 실행할 수 없습니다.");
alert("The number of rectangles does not match between frames. Cannot apply flow.");
return;
}
// --- 여기가 최종 수정된 부분입니다 ---
// 현재 프레임의 사각형 ID는 유지하면서,
// x, y, width, height는 모두 OF 예측 결과로 업데이트합니다.
const updatedRects = originalRectsFromDB.map((originalRect, index) => {
const stabilizedRect = stabilizedRects[index];
return {
id: originalRect.id, // id는 현재 프레임의 원본 값을 사용
x: stabilizedRect.x, // x 좌표를 OF 결과로 업데이트
y: stabilizedRect.y, // y 좌표를 OF 결과로 업데이트
width: stabilizedRect.width, // width를 OF 결과로 업데이트
height: stabilizedRect.height, // height를 OF 결과로 업데이트
};
});
// 1. 화면의 사각형을 최종적으로 업데이트된 정보로 교체
rectangles = updatedRects;
// 2. 변경된 사각형 정보를 데이터베이스에 저장
saveRectangles(rectangles);
}
function undoOpticalFlowResult() {
// 1. 현재 사각형을 메모리에 저장해둔 DB 원본 값으로 즉시 되돌림
rectangles = JSON.parse(JSON.stringify(originalRectsFromDB));
// 2. 되돌린 사각형 정보를 데이터베이스에 저장
saveRectangles(rectangles);
}
// --- 사각형 CRUD 함수 ---
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) {
const errorBody = await response.text();
throw new Error(`Failed to create rectangle on server: ${errorBody}`);
}
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');
}
console.log(`${rects.length}개의 사각형이 성공적으로 저장되었습니다.`);
} 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(async response => {
if (!response.ok) {
throw new Error(`Failed to create rectangle on server`);
}
return response.json();
})
)
);
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) {
if (alreadySelected) {
selectedRectIds = selectedRectIds.filter(id => id !== rect.id);
} else {
selectedRectIds = [...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 || !activeInteraction.initialRects || activeInteraction.initialRects.length === 0) 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; }
if (width < 10) width = 10;
if (height < 10) height = 10;
rectangles[i] = { ...rectangles[i], x, y, width, 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: MouseEvent) {
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;
switch (event.key) {
case 'ArrowDown':
case 'ArrowRight':
case 'PageDown':
event.preventDefault();
if (currentIndex < dataNumbers.length - 1) nextIndex = currentIndex + 1;
break;
case 'ArrowUp':
case 'ArrowLeft':
case 'PageUp':
event.preventDefault();
if (currentIndex > 0) nextIndex = currentIndex - 1;
break;
}
if (nextIndex !== -1) selectDataNumber(dataNumbers[nextIndex]);
}
const handles = ['n', 's', 'e', 'w', 'nw', 'ne', 'sw', 'se'];
</script>
<svelte:window on:keydown={handleKeyDown} />
<div class="flex w-full h-full">
<nav class="w-[15%] min-w-[200px] bg-white p-4 border-r border-gray-200 overflow-y-auto">
<div class="nav-section border border-gray-200 rounded-lg p-3">
<h2 class="text-sm font-bold text-gray-600 uppercase tracking-wider mb-2 px-1">Movie Index</h2>
<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"
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>
</nav>
<div bind:this={dataNumberPanel} class="w-[15%] min-w-[200px] bg-gray-50 p-4 border-r border-gray-200 overflow-y-auto">
{#if selectedMovie}
<div class="nav-section border border-gray-200 rounded-lg p-3 bg-white">
<h2 class="text-sm font-bold text-gray-600 uppercase tracking-wider mb-2 px-1">Data Number</h2>
{#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"
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}
</div>
{:else}
<div class="text-center text-gray-400 text-sm mt-4">
<p>Select a Movie Index to see data numbers.</p>
</div>
{/if}
</div>
<div class="w-[70%] flex flex-col bg-gray-200">
<div class="flex-shrink-0 p-2 bg-white border-b border-gray-300 shadow-sm flex items-center justify-between">
<div class="flex items-center gap-2">
<button on:click={addNewRectangle} disabled={!selectedDataNumberInfo}
class="px-3 py-1.5 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:bg-gray-400">
Add Rectangle
</button>
<button on:click={copySelectedRect} disabled={selectedRectangles.length === 0}
class="px-3 py-1.5 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 disabled:bg-gray-400">
Copy
</button>
<button on:click={pasteRect} disabled={!clipboard || clipboard.length === 0}
class="px-3 py-1.5 text-sm font-medium text-white bg-purple-600 rounded-md hover:bg-purple-700 disabled:bg-gray-400">
Paste
</button>
<button on:click={deleteSelectedRect} disabled={selectedRectangles.length === 0}
class="px-3 py-1.5 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 disabled:bg-gray-400">
Delete Selected
</button>
<button on:click={() => saveRectangles(rectangles)} disabled={rectangles.length === 0}
class="px-3 py-1.5 text-sm font-medium text-white bg-green-600 rounded-md hover:bg-green-700 disabled:bg-gray-400">
Save All
</button>
<div class="flex items-center gap-2 border-l-2 border-gray-200 ml-2 pl-2">
<button on:click={applyOpticalFlowResult} disabled={!stabilizedRectsResult}
class="px-3 py-1.5 text-sm font-medium text-white bg-green-600 rounded-md hover:bg-green-700 disabled:bg-gray-400">
Apply Flow
</button>
<button on:click={undoOpticalFlowResult} disabled={rectangles === originalRectsFromDB}
class="px-3 py-1.5 text-sm font-medium text-white bg-gray-500 rounded-md hover:bg-gray-600 disabled:bg-gray-400">
Undo
</button>
</div>
</div>
<div class="flex items-center 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-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}
<svg
bind:this={svgElement}
viewBox={viewBox}
class="absolute top-0 left-0 w-full h-full"
on:mousedown|self={deselectRect}
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>
<style>
.image-viewer-container {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
rect.selected {
stroke: #0ea5e9;
stroke-width: 3px;
stroke-dasharray: 6 3;
}
.cursor-n-resize { cursor: n-resize; }
.cursor-s-resize { cursor: s-resize; }
.cursor-e-resize { cursor: e-resize; }
.cursor-w-resize { cursor: w-resize; }
.cursor-nw-resize { cursor: nw-resize; }
.cursor-ne-resize { cursor: ne-resize; }
.cursor-sw-resize { cursor: sw-resize; }
.cursor-se-resize { cursor: se-resize; }
</style>

68
src/routes/tt/+page.svelte Executable file
View File

@ -0,0 +1,68 @@
<script>
// 1. Iconify에서 Icon 컴포넌트를 가져옵니다.
import Icon from '@iconify/svelte';
// 2. 버튼으로 만들 아이콘 목록을 배열로 관리하면 편리합니다.
// 아이콘 이름은 Iconify 공식 웹사이트(https://icones.js.org/)에서 쉽게 찾을 수 있습니다.
const actions = [
{ label: 'Add', icon: 'mdi:plus-box-outline' },
{ label: 'Copy', icon: 'mdi:content-copy' },
{ label: 'Paste', icon: 'mdi:content-paste' },
{ label: 'Delete', icon: 'mdi:delete-outline' },
{ label: 'Save', icon: 'mdi:content-save-outline' },
{ label: 'Apply Flow', icon: 'mdi:arrow-right-bold-box-outline' },
{ label: 'Undo', icon: 'mdi:undo' }
];
// 각 버튼 클릭 시 실행될 함수 (예시)
function handleAction(actionLabel) {
alert(`'${actionLabel}' action clicked!`);
}
</script>
<main>
<h1>Icon Button Toolbar</h1>
<div class="toolbar">
{#each actions as action}
<button
class="icon-button"
title={action.label}
aria-label={action.label}
on:click={() => handleAction(action.label)}
>
<Icon icon={action.icon} width="24" height="24" />
</button>
{/each}
</div>
</main>
<style>
main {
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
font-family: sans-serif;
}
.toolbar {
display: flex;
gap: 0.5rem; /* 버튼 사이 간격 */
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 8px;
background-color: #f9f9f9;
}
.icon-button {
background: none;
border: none;
padding: 0.5rem;
cursor: pointer;
border-radius: 6px;
color: #333;
transition: background-color 0.2s, color 0.2s;
}
.icon-button:hover {
background-color: #e0e0e0;
color: #000;
}
</style>

524
src/routes/video-cut/+page.svelte Executable file
View File

@ -0,0 +1,524 @@
<script lang="ts">
// --- 상태 변수 선언 ---
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>

View File

@ -0,0 +1,554 @@
<script lang="ts">
import { onMount } from 'svelte';
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile, toBlobURL } from '@ffmpeg/util';
import type { LogEvent } from '@ffmpeg/ffmpeg/dist/esm/types';
// --- 상태 변수 선언 ---
let ffmpeg: FFmpeg | null = null;
let videoURL: string = '';
let videoElement: HTMLVideoElement;
let originalFileName: string = '';
let message: string = 'FFmpeg를 로드하려면 클릭하세요.';
let startTime: number = 0;
let endTime: number = 0;
let isLoading: boolean = false;
let detectedCropParams: string = ''; // 계산된 Crop 파라미터를 저장할 변수
let cropInfo: string = ''; // 계산된 Crop 정보를 표시할 변수
let isDetectingCrop: boolean = false; // Crop 계산 중 상태를 추적할 변수
let isUploadingOriginal: boolean = false;
let uploadProgress: number = 0; // 0에서 100 사이의 업로드 진행률
// --- 다중 구간 편집을 위한 타입 및 상태 변수 ---
interface TimeSegment {
id: number;
start: number;
end: number;
resultURL?: string;
isUploading?: boolean;
uploadStatus?: 'success' | 'error';
}
let segments: TimeSegment[] = [];
let nextSegmentId = 0;
// --- 반응형 변수 ---
$: allProcessed = segments.length > 0 && segments.every((s) => s.resultURL);
// --- 핵심 기능 함수 ---
const loadFFmpeg = async () => {
if (isLoading || ffmpeg) return;
isLoading = true;
message = 'FFmpeg core 로딩 중...';
try {
ffmpeg = new FFmpeg();
ffmpeg.on('log', ({ message }: LogEvent) => {
// console.log(message); // 디버깅 시에만 활성화
});
const baseURL = '/';
await ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}ffmpeg-core.js`, 'text/javascript'),
wasmURL: await toBlobURL(`${baseURL}ffmpeg-core.wasm`, 'application/wasm')
});
ffmpeg = ffmpeg; // Svelte 반응성 트리거
message = 'FFmpeg 로드 완료! 동영상을 선택하세요.';
} catch (err) {
console.error(err);
message = 'FFmpeg 로드 실패. 인터넷 연결을 확인하거나 브라우저를 새로고침하세요.';
} finally {
isLoading = false;
}
};
const handleFileSelect = async (e: Event) => {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (file && ffmpeg) {
// 새 영상이므로 기존 상태 초기화
videoURL = URL.createObjectURL(file);
originalFileName = file.name;
startTime = 0;
endTime = 0;
segments = [];
detectedCropParams = ''; // Crop 파라미터 초기화
cropInfo = ''; // Crop 정보 초기화
isDetectingCrop = true; // Crop 계산 시작 상태로 변경
await uploadOriginalVideo(file);
try {
// FFmpeg 메모리 파일 시스템에 파일 쓰기
await ffmpeg.writeFile(file.name, await fetchFile(file));
// Crop 영역 계산 함수 호출
const cropParams = await detectCropAreaProgrammatically(file.name);
detectedCropParams = cropParams; // ✅ 계산 결과를 전용 변수에 저장!
if (cropParams) {
const [width, height, x, y] = cropParams.split(':');
cropInfo = `시작점: (${x}, ${y}), 크기: (${width} x ${height})`;
} else {
cropInfo = '자동 Crop 영역을 찾지 못했습니다.';
}
} catch (error) {
console.error('Crop 영역 계산 실패:', error);
cropInfo = 'Crop 영역 계산 중 오류가 발생했습니다.';
} finally {
isDetectingCrop = false; // Crop 계산 종료 상태로 변경
}
}
};
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 }];
};
const removeSegment = (id: number) => {
segments = segments.filter((segment) => segment.id !== id);
};
const previewSegment = (segment: TimeSegment) => {
if (videoElement) {
videoElement.currentTime = segment.start;
videoElement.play();
const stopAtEnd = () => {
if (videoElement.currentTime >= segment.end) {
videoElement.pause();
videoElement.removeEventListener('timeupdate', stopAtEnd);
}
};
videoElement.addEventListener('timeupdate', stopAtEnd);
}
};
const detectCropAreaProgrammatically = async (inputFileName: string): Promise<string> => {
message = '최적의 Crop 영역을 계산하는 중...';
const tempFramePath = 'temp-frame.png';
await ffmpeg.exec([
'-ss', '5',
'-i', inputFileName,
'-vframes', '1',
'-q:v', '2',
tempFramePath
]);
const frameData = await ffmpeg.readFile(tempFramePath);
const blob = new Blob([frameData], { type: 'image/png' });
const url = URL.createObjectURL(blob);
try {
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = reject;
image.src = url;
});
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return '';
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
ctx.drawImage(img, 0, 0);
// ### START: 추가된 부분 - 오버레이 영역 검게 칠하기 ###
// GIMP에서 확인한 좌표를 기반으로 정보 오버레이 영역을 정의합니다.
// 참고: 이 좌표는 1920x1080 해상도에만 유효할 수 있습니다.
const blackoutAreas = [
{ x: 0, y: 0, width: 154, height: 94 }, // 좌상단 "Live" 텍스트
{ x: 1766, y: 0, width: 154, height: 94 }, // 우상단 "1.00x" 텍스트
{ x: 0, y: 995, width: 1920, height: 85 } // 하단 정보 바
];
// 해당 영역들을 검은색 사각형으로 덮어씁니다.
ctx.fillStyle = 'black';
for (const area of blackoutAreas) {
ctx.fillRect(area.x, area.y, area.width, area.height);
}
// ### END: 추가된 부분 ###
// 수정된 캔버스에서 이미지 데이터를 다시 읽어옵니다.
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
const verticalProfile = new Array(canvas.height).fill(0);
const horizontalProfile = new Array(canvas.width).fill(0);
for (let y = 0; y < canvas.height; y++) {
for (let x = 0; x < canvas.width; x++) {
const i = (y * canvas.width + x) * 4;
const brightness = (imageData[i] + imageData[i + 1] + imageData[i + 2]) / 3;
verticalProfile[y] += brightness;
horizontalProfile[x] += brightness;
}
}
const totalVerticalBrightness = verticalProfile.reduce((sum, val) => sum + val, 0);
const avgVerticalBrightness = totalVerticalBrightness / canvas.height;
const verticalThreshold = avgVerticalBrightness * 0.1;
const totalHorizontalBrightness = horizontalProfile.reduce((sum, val) => sum + val, 0);
const avgHorizontalBrightness = totalHorizontalBrightness / canvas.width;
const horizontalThreshold = avgHorizontalBrightness * 0.1;
let y1 = 0, y2 = canvas.height - 1, x1 = 0, x2 = canvas.width - 1;
for (let i = 0; i < canvas.height; i++) { if (verticalProfile[i] > verticalThreshold) { y1 = i; break; } }
for (let i = canvas.height - 1; i >= 0; i--) { if (verticalProfile[i] > verticalThreshold) { y2 = i; break; } }
for (let i = 0; i < canvas.width; i++) { if (horizontalProfile[i] > horizontalThreshold) { x1 = i; break; } }
for (let i = canvas.width - 1; i >= 0; i--) { if (horizontalProfile[i] > horizontalThreshold) { x2 = i; break; } }
const width = x2 - x1 + 1;
const height = y2 - y1 + 1;
if (width <= 0 || height <= 0) return '';
const cropParams = `${width}:${height}:${x1}:${y1}`;
console.log(`프로그래밍 방식으로 찾은 Crop 영역: ${cropParams}`);
return cropParams;
} catch (error) {
console.error('Crop 영역 계산 중 오류:', error);
return '';
} finally {
URL.revokeObjectURL(url);
}
};
const cutAllSegments = async () => {
if (!ffmpeg || !ffmpeg.loaded) { message = 'FFmpeg를 로드해주세요.'; return; }
if (segments.length === 0) { message = '먼저 구간을 추가해주세요.'; return; }
isLoading = true;
const inputFileName = 'input.mp4';
await ffmpeg.writeFile(inputFileName, await fetchFile(videoURL));
//const cropParams = await detectCropAreaProgrammatically(inputFileName);
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
const outputFileName = `output-${segment.id}.mp4`;
message = `${segments.length}개 중 ${i + 1}번째 구간 처리 중 (재인코딩으로 인해 시간이 걸릴 수 있습니다)...`;
const duration = segment.end - segment.start;
// ✅ -ss와 -t를 -i 앞으로 옮겨서 입력 파일 자체를 필요한 부분만 읽도록 최적화합니다.
const command = [
'-ss', `${segment.start}`,
'-t', `${duration}`,
'-i', inputFileName,
];
// crop 파라미터를 찾았을 경우에만 crop 필터 추가
if (detectedCropParams) {
command.push('-vf', `crop=${detectedCropParams}`);
}
// 오디오는 그대로 복사하고, 비디오는 기본 설정으로 재인코딩
command.push('-c:a', 'copy', outputFileName);
await ffmpeg.exec(command);
const data = await ffmpeg.readFile(outputFileName);
const url = URL.createObjectURL(new Blob([(data as Uint8Array).buffer], { type: 'video/mp4' }));
segments = segments.map(s => s.id === segment.id ? { ...s, resultURL: url } : s);
}
message = '모든 구간 처리 완료!';
isLoading = false;
};
const uploadSegment = async (segment: TimeSegment) => {
if (!segment.resultURL) return;
segments = segments.map(s => s.id === segment.id ? { ...s, isUploading: true } : s);
try {
const response = await fetch(segment.resultURL);
const blob = await response.blob();
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`;
const formData = new FormData();
formData.append('video', blob);
formData.append('fileName', fileName);
const uploadResponse = await fetch('/api/upload', { method: 'POST', body: formData });
if (!uploadResponse.ok) throw new Error('서버 업로드 실패');
segments = segments.map(s => s.id === segment.id ? { ...s, isUploading: false, uploadStatus: 'success' } : s);
} catch (err) {
console.error('업로드 실패:', err);
segments = segments.map(s => s.id === segment.id ? { ...s, isUploading: false, uploadStatus: 'error' } : s);
}
};
const uploadAllSegments = async () => {
if (!allProcessed) return;
message = '전체 업로드를 시작합니다...';
isLoading = true;
for (const segment of segments) {
if (segment.resultURL && segment.uploadStatus !== 'success') {
await uploadSegment(segment);
}
}
isLoading = false;
message = '전체 업로드 요청이 완료되었습니다.';
};
const downloadAllSegments = async () => {
if (!allProcessed) return;
message = '개별 파일 다운로드를 시작합니다...';
isLoading = true;
try {
const processedSegments = segments.filter(s => s.resultURL);
for (const segment of processedSegments) {
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;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(link.href), 100);
await new Promise(resolve => setTimeout(resolve, 300));
}
message = `${processedSegments.length}개 파일의 다운로드를 요청했습니다.`;
} catch (error) {
console.error('개별 파일 다운로드 오류:', error);
message = '파일 다운로드 중 오류가 발생했습니다.';
} finally {
isLoading = false;
}
};
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) {
const percentComplete = (event.loaded / event.total) * 100;
uploadProgress = percentComplete;
}
};
// 업로드 성공 시
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
console.log('원본 파일 업로드 결과:', xhr.responseText);
message = '원본 파일 업로드 완료! Crop 영역을 계산합니다.';
isUploadingOriginal = false;
resolve();
} else {
// 서버에서 에러 응답 시
console.error('원본 파일 업로드 실패:', xhr.statusText);
message = '원본 파일 업로드 중 오류가 발생했습니다.';
isUploadingOriginal = false;
reject(new Error(xhr.statusText));
}
};
// 업로드 중 네트워크 에러 등 발생 시
xhr.onerror = () => {
console.error('원본 파일 업로드 실패: 네트워크 오류');
message = '원본 파일 업로드 중 네트워크 오류가 발생했습니다.';
isUploadingOriginal = false;
reject(new Error('네트워크 오류'));
};
xhr.open('POST', '/api/upload-original', true);
xhr.send(formData);
});
};
</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 동영상 편집기
</h1>
</div>
{#if !ffmpeg?.loaded}
<div class="text-center">
<button
on:click={loadFFmpeg}
disabled={isLoading}
class="inline-flex items-center rounded-md border border-transparent bg-indigo-600 px-6 py-3 text-base font-medium text-white shadow-sm hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{message}
</button>
</div>
{:else}
<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">
<div class="p-6">
<h2 class="text-xl font-semibold text-gray-800 mb-4">동영상 재생</h2>
<video src={videoURL} bind:this={videoElement} class="w-full rounded-lg" controls>
<track kind="captions" src="data:text/vtt,WEBVTT%0A%0A" srclang="ko" label="Korean captions" default />
</video>
</div>
</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" />
</label>
<div class="mt-4 space-y-1 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 cropInfo}
<div>
<p><strong class="font-medium text-gray-900">자동 Crop 영역:</strong></p>
<p class="text-gray-700 font-mono bg-gray-50 p-2 rounded">{cropInfo}</p>
</div>
{/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-[calc(100vh-15rem)]">
<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
role="button"
tabindex="0"
on:click={() => previewSegment(segment)}
on:keydown={(e) => { if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') { e.preventDefault(); previewSegment(segment); } }}
class="p-3 border rounded-lg flex items-center justify-between transition-all cursor-pointer hover:bg-gray-100"
class:bg-green-50={segment.resultURL}
class:border-blue-300={segment.isUploading}
class:border-green-400={segment.uploadStatus === 'success'}
class:border-red-400={segment.uploadStatus === 'error'}
>
<div class="flex-1 overflow-hidden">
<p class="font-medium text-gray-800 truncate">{segment.start.toFixed(2)}초 ~ {segment.end.toFixed(2)}</p>
{#if segment.resultURL}
<div class="flex items-center gap-2 mt-1">
<a
href={segment.resultURL}
download={`${originalFileName.split('.').slice(0, -1).join('.')}_cut-${segment.id}-${segment.start.toFixed(0)}s-${segment.end.toFixed(0)}s.mp4`}
class="text-sm text-indigo-600 hover:underline"
>다운로드</a>
<button
on:click|stopPropagation={() => uploadSegment(segment)}
disabled={segment.isUploading || segment.uploadStatus === 'success'}
class="text-xs px-2 py-1 rounded bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-400"
>
{#if segment.isUploading}
업로드 중...
{:else if segment.uploadStatus === 'success'}
업로드 완료 ✅
{:else}
업로드
{/if}
</button>
{#if segment.uploadStatus === 'error'}
<span class="text-xs text-red-500">실패</span>
{/if}
</div>
{/if}
</div>
<button on:click|stopPropagation={() => removeSegment(segment.id)} class="text-red-500 hover:text-red-700 font-bold px-2 ml-2">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={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 disabled:bg-gray-400">
📦 전체 다운로드
</button>
<button
on:click={uploadAllSegments}
disabled={isLoading || !allProcessed}
class="w-full rounded-md border border-transparent bg-purple-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-purple-700 disabled:opacity-50 disabled:bg-gray-400"
>
🚀 전체 업로드
</button>
</div>
</section>
</div>
{/if}
</div>
{/if}
</main>

16
static/ffmpeg-core.js Executable file

File diff suppressed because one or more lines are too long

48
static/opencv.js Executable file

File diff suppressed because one or more lines are too long

3
static/robots.txt Executable file
View File

@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

20
svelte.config.js Executable file
View File

@ -0,0 +1,20 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
alias: {
'$components': 'src/lib/components',
'$components/*': 'src/lib/components/*',
'$utils': 'src/lib/utils'
}
}
};
export default config;

12
tsconfig.json Executable file
View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "es2017",
"module": "esnext",
"moduleResolution": "node",
"lib": ["es2017", "dom"],
"skipLibCheck": true,
"strict": true
// "types"
},
"include": ["src/**/*"]
}

56
vite.config.ts Executable file
View File

@ -0,0 +1,56 @@
import devtoolsJson from 'vite-plugin-devtools-json';
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit(), devtoolsJson()],
server: {
port: 14022,
host: true, // '--host' 플래그와 동일한 효과
allowedHosts: [
'bigdata.ssdoctors.com',
'ssdoctors.com',
'localhost',
],
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp'
}
},
optimizeDeps: {
exclude: ['@ffmpeg/ffmpeg', '@ffmpeg/util']
},
ssr: {
noExternal: ['@iconify/svelte']
},
test: {
expect: { requireAssertions: true },
projects: [
{
extends: './vite.config.ts',
test: {
name: 'client',
environment: 'browser',
browser: {
enabled: true,
provider: 'playwright',
instances: [{ browser: 'chromium' }]
},
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
exclude: ['src/lib/server/**'],
setupFiles: ['./vitest-setup-client.ts']
}
},
{
extends: './vite.config.ts',
test: {
name: 'server',
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
}
}
]
}
});

2
vitest-setup-client.ts Executable file
View File

@ -0,0 +1,2 @@
/// <reference types="@vitest/browser/matchers" />
/// <reference types="@vitest/browser/providers/playwright" />