First Initialize source
This commit is contained in:
parent
bcdd46c452
commit
8e7e06c4f1
3
.gitignore
vendored
3
.gitignore
vendored
@ -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
38
README.md
Executable 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
6
e2e/demo.test.ts
Executable 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
40
eslint.config.js
Executable 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
5900
package-lock.json
generated
Executable file
File diff suppressed because it is too large
Load Diff
57
package.json
Executable file
57
package.json
Executable 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
9
playwright.config.ts
Executable 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
3
src/app.css
Executable file
@ -0,0 +1,3 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/forms';
|
||||
@plugin '@tailwindcss/typography';
|
||||
33
src/app.d.ts
vendored
Executable file
33
src/app.d.ts
vendored
Executable 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
12
src/app.html
Executable 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
7
src/demo.spec.ts
Executable 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
12
src/hooks.server.ts
Executable 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
1
src/lib/assets/favicon.svg
Executable 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
1
src/lib/index.ts
Executable 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
17
src/lib/server/database.ts
Executable 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
2
src/lib/stores.ts
Executable file
@ -0,0 +1,2 @@
|
||||
import { writable } from 'svelte/store';
|
||||
export const progressStream = writable(0);
|
||||
15
src/routes/+layout.svelte
Executable file
15
src/routes/+layout.svelte
Executable 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
22
src/routes/+page.server.ts
Executable 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
734
src/routes/+page.svelte
Executable 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>
|
||||
25
src/routes/api/datanumbers/[movieIndex]/+server.ts
Executable file
25
src/routes/api/datanumbers/[movieIndex]/+server.ts
Executable 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 });
|
||||
}
|
||||
};
|
||||
37
src/routes/api/images/[...path]/+server.ts
Executable file
37
src/routes/api/images/[...path]/+server.ts
Executable 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');
|
||||
}
|
||||
};
|
||||
18
src/routes/api/movies/+server.ts
Executable file
18
src/routes/api/movies/+server.ts
Executable 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 });
|
||||
}
|
||||
};
|
||||
207
src/routes/api/process-video/+server.ts
Executable file
207
src/routes/api/process-video/+server.ts
Executable 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 });
|
||||
}
|
||||
};
|
||||
|
||||
24
src/routes/api/progress/+server.ts
Executable file
24
src/routes/api/progress/+server.ts
Executable 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'
|
||||
}
|
||||
});
|
||||
}
|
||||
66
src/routes/api/rectangles/bulk-update/+server.ts
Executable file
66
src/routes/api/rectangles/bulk-update/+server.ts
Executable 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 });
|
||||
}
|
||||
};
|
||||
34
src/routes/api/rectangles/by-id/[id]/+server.ts
Executable file
34
src/routes/api/rectangles/by-id/[id]/+server.ts
Executable 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 });
|
||||
}
|
||||
};
|
||||
47
src/routes/api/rectangles/by-ref/[refIndex]/+server.ts
Executable file
47
src/routes/api/rectangles/by-ref/[refIndex]/+server.ts
Executable 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 });
|
||||
}
|
||||
};
|
||||
39
src/routes/api/upload-cut/+server.ts
Normal file
39
src/routes/api/upload-cut/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
37
src/routes/api/upload-original/+server.ts
Executable file
37
src/routes/api/upload-original/+server.ts
Executable 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
130
src/routes/api/upload/+server.ts
Executable 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();
|
||||
}
|
||||
}
|
||||
76
src/routes/api/upload/server.ts.ori
Executable file
76
src/routes/api/upload/server.ts.ori
Executable 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, '서버 처리 중 오류가 발생했습니다.');
|
||||
}
|
||||
}
|
||||
28
src/routes/api/video-preview/+server.ts
Normal file
28
src/routes/api/video-preview/+server.ts
Normal 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
13
src/routes/page.svelte.spec.ts
Executable 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
22
src/routes/t1/+page.server.ts
Executable 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
557
src/routes/t1/+page.svelte
Executable 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
22
src/routes/t2/+page.server.ts
Executable 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
584
src/routes/t2/+page.svelte
Executable 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
22
src/routes/t3/+page.server.ts
Executable 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
730
src/routes/t3/+page.svelte
Executable 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
22
src/routes/t7/+page.server.ts
Executable 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
793
src/routes/t7/+page.svelte
Executable 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
68
src/routes/tt/+page.svelte
Executable 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
524
src/routes/video-cut/+page.svelte
Executable 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>
|
||||
554
src/routes/video-cut_v1/+page.svelte
Executable file
554
src/routes/video-cut_v1/+page.svelte
Executable 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
16
static/ffmpeg-core.js
Executable file
File diff suppressed because one or more lines are too long
48
static/opencv.js
Executable file
48
static/opencv.js
Executable file
File diff suppressed because one or more lines are too long
3
static/robots.txt
Executable file
3
static/robots.txt
Executable file
@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
20
svelte.config.js
Executable file
20
svelte.config.js
Executable 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
12
tsconfig.json
Executable 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
56
vite.config.ts
Executable 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
2
vitest-setup-client.ts
Executable file
@ -0,0 +1,2 @@
|
||||
/// <reference types="@vitest/browser/matchers" />
|
||||
/// <reference types="@vitest/browser/providers/playwright" />
|
||||
Loading…
Reference in New Issue
Block a user